906 lines
33 KiB
Go
906 lines
33 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
)
|
|
|
|
// ---- fakes (no network, no real runner) ----
|
|
|
|
type fakeCardSender struct {
|
|
mu sync.Mutex
|
|
sent []*ApprovalRequest
|
|
updates []string
|
|
failSend bool
|
|
lastTrack string
|
|
}
|
|
|
|
func (f *fakeCardSender) SendApprovalCard(_ context.Context, _ string, req *ApprovalRequest) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.failSend {
|
|
return "", context.DeadlineExceeded
|
|
}
|
|
f.sent = append(f.sent, req)
|
|
f.lastTrack = "track-" + req.ID
|
|
return f.lastTrack, nil
|
|
}
|
|
|
|
func (f *fakeCardSender) UpdateApprovalCard(_ context.Context, _, text string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.updates = append(f.updates, text)
|
|
return nil
|
|
}
|
|
|
|
// fakeRunner records the invocations it is asked to run and reports them as
|
|
// implemented (so the orchestrator treats them as executed).
|
|
type fakeRunner struct {
|
|
mu sync.Mutex
|
|
runs []executor.Invocation
|
|
fail bool // when true, Run returns an error (simulates identity not ready)
|
|
}
|
|
|
|
func (r *fakeRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.runs = append(r.runs, inv)
|
|
if r.fail {
|
|
return executor.Result{}, context.DeadlineExceeded
|
|
}
|
|
inv.Implemented = true
|
|
return executor.Result{Invocation: inv}, nil
|
|
}
|
|
|
|
func (r *fakeRunner) setFail(v bool) { r.mu.Lock(); defer r.mu.Unlock(); r.fail = v }
|
|
|
|
func (r *fakeRunner) count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.runs) }
|
|
|
|
// fakeNotifier records the proactive 1:1 messages text-mode approval sends to
|
|
// the owner and the requester.
|
|
type fakeNotifier struct {
|
|
mu sync.Mutex
|
|
sent []sentMsg
|
|
}
|
|
|
|
type sentMsg struct {
|
|
to []string
|
|
text string
|
|
}
|
|
|
|
func (n *fakeNotifier) sendOTOText(_ context.Context, userIDs []string, text string) error {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
n.sent = append(n.sent, sentMsg{to: append([]string(nil), userIDs...), text: text})
|
|
return nil
|
|
}
|
|
|
|
// toUser reports whether any recorded message was sent to userID.
|
|
func (n *fakeNotifier) toUser(userID string) bool {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
for _, m := range n.sent {
|
|
for _, u := range m.to {
|
|
if u == userID {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (n *fakeNotifier) count() int { n.mu.Lock(); defer n.mu.Unlock(); return len(n.sent) }
|
|
|
|
func TestDingTalkApprovalCardSenderCoverage(t *testing.T) {
|
|
if newDingtalkApprovalCardSender("client", "secret", " ") != nil {
|
|
t.Fatal("empty approval template created a sender")
|
|
}
|
|
rec, srv := newCardAPIServer(t)
|
|
withCardAPIBase(t, srv.URL)
|
|
sender, ok := newDingtalkApprovalCardSender("client", "secret", "template").(*dingtalkApprovalCardSender)
|
|
if !ok {
|
|
t.Fatal("approval sender type mismatch")
|
|
}
|
|
req := &ApprovalRequest{ID: "approval", Summary: "Create todo", Requester: "requester"}
|
|
track, err := sender.SendApprovalCard(context.Background(), "owner", req)
|
|
if err != nil || track != "dws_approval_approval" {
|
|
t.Fatalf("SendApprovalCard() = %q, %v", track, err)
|
|
}
|
|
if err := sender.UpdateApprovalCard(context.Background(), track, "done"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec.fail["PUT /v1.0/card/instances"] = 500
|
|
if err := sender.UpdateApprovalCard(context.Background(), track, "failed"); err == nil {
|
|
t.Fatal("approval update HTTP failure was ignored")
|
|
}
|
|
rec.fail = map[string]int{"POST /v1.0/card/instances": 500}
|
|
if _, err := sender.SendApprovalCard(context.Background(), "owner", req); err == nil {
|
|
t.Fatal("approval create HTTP failure was ignored")
|
|
}
|
|
}
|
|
|
|
func TestApprovalAutoRetryLifecycleCoverage(t *testing.T) {
|
|
var nilOrchestrator *approvalOrchestrator
|
|
nilOrchestrator.startAutoRetry(context.Background(), time.Millisecond)
|
|
(&approvalOrchestrator{}).startAutoRetry(context.Background(), time.Millisecond)
|
|
notifier := &fakeNotifier{}
|
|
orchestrator := &approvalOrchestrator{notifier: notifier, gate: newApprovalGate("")}
|
|
orchestrator.startAutoRetry(context.Background(), 0)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
orchestrator.startAutoRetry(ctx, time.Millisecond)
|
|
time.Sleep(3 * time.Millisecond)
|
|
cancel()
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// cardClickReq builds a card callback as DingTalk delivers it: the tapped
|
|
// button's action id plus its private params (approval id + decision).
|
|
func cardClickReq(outTrackID, approvalID, decision, actionID, userID string) *card.CardRequest {
|
|
return &card.CardRequest{
|
|
OutTrackId: outTrackID,
|
|
UserId: userID,
|
|
CardActionData: card.PrivateCardActionData{
|
|
CardPrivateData: card.CardPrivateData{
|
|
ActionIdList: []string{actionID},
|
|
Params: map[string]any{
|
|
approvalParamID: approvalID,
|
|
approvalParamDecision: decision,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestDecodeCardAction verifies the action parser pulls the approval id and the
|
|
// approve/reject decision from a button tap, via params and via the action id
|
|
// fallback.
|
|
func TestDecodeCardAction(t *testing.T) {
|
|
approve := cardClickReq("t1", "appr-1", approvalDecisionApprove, approvalActionApprove, "owner")
|
|
id, ok2, ok := decodeCardAction(approve)
|
|
if !ok || !ok2 || id != "appr-1" {
|
|
t.Fatalf("approve decode: id=%q approve=%v ok=%v", id, ok2, ok)
|
|
}
|
|
|
|
reject := cardClickReq("t2", "appr-2", approvalDecisionReject, approvalActionReject, "owner")
|
|
id, ok2, ok = decodeCardAction(reject)
|
|
if !ok || ok2 || id != "appr-2" {
|
|
t.Fatalf("reject decode: id=%q approve=%v ok=%v", id, ok2, ok)
|
|
}
|
|
|
|
// Decision param missing → fall back to the tapped action id.
|
|
fallback := &card.CardRequest{
|
|
OutTrackId: "t3",
|
|
CardActionData: card.PrivateCardActionData{
|
|
CardPrivateData: card.CardPrivateData{
|
|
ActionIdList: []string{approvalActionApprove},
|
|
Params: map[string]any{approvalParamID: "appr-3"},
|
|
},
|
|
},
|
|
}
|
|
id, ok2, ok = decodeCardAction(fallback)
|
|
if !ok || !ok2 || id != "appr-3" {
|
|
t.Fatalf("fallback decode: id=%q approve=%v ok=%v", id, ok2, ok)
|
|
}
|
|
|
|
// A non-approval card → not ok.
|
|
if _, _, ok := decodeCardAction(&card.CardRequest{OutTrackId: "x"}); ok {
|
|
t.Fatal("non-approval card should not decode as a decision")
|
|
}
|
|
}
|
|
|
|
// TestHandleCardCallback_DrivesDecide verifies a button callback flips the gate
|
|
// state and that an approval id missing from params is recovered by outTrackId.
|
|
func TestHandleCardCallback_DrivesDecide(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("c1")
|
|
o := newApprovalOrchestrator(gate, &fakeCardSender{}, &fakeRunner{}, "owner")
|
|
|
|
app := gate.Submit(ApprovalRequest{Summary: "x"})
|
|
gate.setOutTrackID(app.ID, "track-x")
|
|
|
|
// Callback carries no approval id in params → associate by outTrackId.
|
|
req := &card.CardRequest{
|
|
OutTrackId: "track-x",
|
|
UserId: "owner",
|
|
CardActionData: card.PrivateCardActionData{
|
|
CardPrivateData: card.CardPrivateData{ActionIdList: []string{approvalActionApprove}},
|
|
},
|
|
}
|
|
if _, err := o.handleCardCallback(context.Background(), req); err != nil {
|
|
t.Fatalf("callback err: %v", err)
|
|
}
|
|
if got := gate.Get(app.ID); !got.approved() {
|
|
t.Fatalf("state = %s, want approved after approve tap", got.State)
|
|
}
|
|
}
|
|
|
|
// TestHandleReply_RejectDoesNotExecute is the reject-path guarantee: a rejected
|
|
// approval must NOT run the planned command.
|
|
func TestHandleReply_RejectDoesNotExecute(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("c2")
|
|
sender := &fakeCardSender{}
|
|
runner := &fakeRunner{}
|
|
o := newApprovalOrchestrator(gate, sender, runner, "owner")
|
|
o.awaitWindow = time.Second
|
|
|
|
var replies []string
|
|
var rmu sync.Mutex
|
|
reply := func(_ context.Context, _, text string) error {
|
|
rmu.Lock()
|
|
replies = append(replies, text)
|
|
rmu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// The owner rejects shortly after the card is sent.
|
|
go func() {
|
|
deadline := time.Now().Add(time.Second)
|
|
for time.Now().Before(deadline) {
|
|
sender.mu.Lock()
|
|
n := len(sender.sent)
|
|
var id string
|
|
if n > 0 {
|
|
id = sender.sent[0].ID
|
|
}
|
|
sender.mu.Unlock()
|
|
if id != "" {
|
|
gate.Decide(id, false, "owner")
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}()
|
|
|
|
agentReply := `好的。[[ACTION:todo.create title="交方案"]]`
|
|
out, handled := o.handleReply(context.Background(), "requester", "conv-1", agentReply, reply)
|
|
if !handled {
|
|
t.Fatal("an action reply must be handled by the gate")
|
|
}
|
|
if out != "" {
|
|
t.Fatalf("handled reply should return empty connector reply, got %q", out)
|
|
}
|
|
if runner.count() != 0 {
|
|
t.Fatalf("reject path must NOT execute, runner ran %d times", runner.count())
|
|
}
|
|
if gate.Get(gate.list()[0].ID).State != approvalRejected {
|
|
t.Fatal("request should be rejected")
|
|
}
|
|
rmu.Lock()
|
|
joined := strings.Join(replies, " | ")
|
|
rmu.Unlock()
|
|
if !strings.Contains(joined, "没批准") {
|
|
t.Fatalf("reject reply not posted: %q", joined)
|
|
}
|
|
}
|
|
|
|
// TestToPlannedAction_Actions covers the action verbs the twin can execute:
|
|
// each maps to the right product/tool, and missing required args degrade to
|
|
// "not an action" (ok=false) so a malformed marker never submits a no-op.
|
|
func TestToPlannedAction_Actions(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
act detectedAction
|
|
wantOK bool
|
|
wantProduct string
|
|
wantTool string
|
|
}{
|
|
{"todo", detectedAction{Verb: "todo.create", Args: map[string]string{"title": "交方案"}}, true, "todo", "create_personal_todo"},
|
|
{"calendar", detectedAction{Verb: "calendar.create", Args: map[string]string{"title": "评审会", "start": "2026-06-20T10:00:00+08:00", "end": "2026-06-20T11:00:00+08:00"}}, true, "calendar", "create_calendar_event"},
|
|
{"calendar missing time", detectedAction{Verb: "calendar.create", Args: map[string]string{"title": "评审会"}}, false, "", ""},
|
|
{"doc", detectedAction{Verb: "doc.create", Args: map[string]string{"name": "周报"}}, true, "doc", "create_document"},
|
|
{"doc missing name", detectedAction{Verb: "doc.create", Args: map[string]string{}}, false, "", ""},
|
|
{"unknown verb", detectedAction{Verb: "drive.delete", Args: map[string]string{}}, false, "", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
pa, summary, ok := toPlannedAction(tc.act, "owner")
|
|
if ok != tc.wantOK {
|
|
t.Fatalf("ok=%v want %v", ok, tc.wantOK)
|
|
}
|
|
if !ok {
|
|
return
|
|
}
|
|
if pa.Product != tc.wantProduct || pa.Tool != tc.wantTool {
|
|
t.Fatalf("got %s/%s want %s/%s", pa.Product, pa.Tool, tc.wantProduct, tc.wantTool)
|
|
}
|
|
if summary == "" {
|
|
t.Fatal("summary should not be empty for a valid action")
|
|
}
|
|
// Every twin action is write-class → must be gated.
|
|
if classifyPlannedAction(pa) != CmdClassWrite {
|
|
t.Fatalf("%s should classify as write (gated), got %s", tc.name, classifyPlannedAction(pa))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestClassifyPlannedAction verifies the gate's read/write bridge: it classifies
|
|
// on the human command path first and falls back to product + RPC tool name.
|
|
func TestClassifyPlannedAction(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
pa plannedAction
|
|
want CmdClass
|
|
}{
|
|
{
|
|
name: "write via legacy path",
|
|
pa: plannedAction{Product: "todo", Tool: "create_personal_todo", LegacyPath: "todo task create"},
|
|
want: CmdClassWrite,
|
|
},
|
|
{
|
|
name: "read via legacy path",
|
|
pa: plannedAction{Product: "todo", Tool: "list_personal_todo", LegacyPath: "todo task list"},
|
|
want: CmdClassRead,
|
|
},
|
|
{
|
|
name: "empty legacy path falls back to product+tool",
|
|
pa: plannedAction{Product: "todo", Tool: "create_personal_todo"},
|
|
want: CmdClassWrite,
|
|
},
|
|
{
|
|
name: "unrecognised path falls back to tool verb",
|
|
pa: plannedAction{Product: "todo", Tool: "delete_personal_todo", LegacyPath: "todo xyz123"},
|
|
want: CmdClassWrite,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := classifyPlannedAction(tc.pa); got != tc.want {
|
|
t.Fatalf("classifyPlannedAction(%+v) = %s, want %s", tc.pa, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleReply_ReadClassBypassesGate proves the "写类才拦" wiring: when the
|
|
// planned command classifies as read-only, the gate does not engage — no card is
|
|
// sent, nothing runs, and the cleaned reply goes out normally (handled=false).
|
|
// We force the todo.create path to read-class via an override so the test drives
|
|
// the real handleReply → classifyPlannedAction path end to end.
|
|
func TestHandleReply_ReadClassBypassesGate(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
SetCmdClassOverride("todo task create", CmdClassRead)
|
|
defer SetCmdClassOverride("todo task create", CmdClassUnknown)
|
|
|
|
gate := newApprovalGate("c3")
|
|
sender := &fakeCardSender{}
|
|
runner := &fakeRunner{}
|
|
o := newApprovalOrchestrator(gate, sender, runner, "owner")
|
|
|
|
reply := func(_ context.Context, _, _ string) error { return nil }
|
|
agentReply := `好的,已为你查到。[[ACTION:todo.create title="交方案"]]`
|
|
out, handled := o.handleReply(context.Background(), "requester", "conv-1", agentReply, reply)
|
|
|
|
if handled {
|
|
t.Fatal("a read-class action must NOT be handled by the gate")
|
|
}
|
|
if !strings.Contains(out, "已为你查到") || strings.Contains(out, "[[ACTION") {
|
|
t.Fatalf("read-class reply should be the cleaned text without the marker, got %q", out)
|
|
}
|
|
sender.mu.Lock()
|
|
nSent := len(sender.sent)
|
|
sender.mu.Unlock()
|
|
if nSent != 0 {
|
|
t.Fatalf("read-class action must not send a confirmation card, sent %d", nSent)
|
|
}
|
|
if runner.count() != 0 {
|
|
t.Fatalf("read-class action must not execute, runner ran %d times", runner.count())
|
|
}
|
|
if len(gate.list()) != 0 {
|
|
t.Fatalf("read-class action must not create an approval request, got %d", len(gate.list()))
|
|
}
|
|
}
|
|
|
|
// TestParseDecisionWord checks whole-message decision parsing: a bare keyword
|
|
// decides, anything else (including a keyword embedded in a sentence) does not.
|
|
func TestParseDecisionWord(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
wantApprove bool
|
|
wantOk bool
|
|
}{
|
|
{"同意", true, true},
|
|
{" 同意 ", true, true},
|
|
{"通过", true, true},
|
|
{"Yes", true, true},
|
|
{"OK", true, true},
|
|
{"拒绝", false, true},
|
|
{"no", false, true},
|
|
{"不同意", false, true},
|
|
{"同意他的看法", false, false}, // embedded, not a bare decision
|
|
{"帮我创建个待办", false, false},
|
|
{"", false, false},
|
|
}
|
|
for _, tc := range cases {
|
|
gotApprove, gotOk := parseDecisionWord(tc.in)
|
|
if gotOk != tc.wantOk || (tc.wantOk && gotApprove != tc.wantApprove) {
|
|
t.Fatalf("parseDecisionWord(%q) = (%v,%v), want (%v,%v)", tc.in, gotApprove, gotOk, tc.wantApprove, tc.wantOk)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPendingForConv finds the pending request for a conversation and stops
|
|
// returning it once decided.
|
|
func TestPendingForConv(t *testing.T) {
|
|
gate := newApprovalGate("") // in-memory
|
|
a := gate.Submit(ApprovalRequest{ConvID: "c1", Summary: "a"})
|
|
gate.Submit(ApprovalRequest{ConvID: "c2", Summary: "b"})
|
|
|
|
if got := gate.pendingForConv("c1"); got == nil || got.ID != a.ID {
|
|
t.Fatalf("pendingForConv(c1) = %v, want request %s", got, a.ID)
|
|
}
|
|
if got := gate.pendingForConv("none"); got != nil {
|
|
t.Fatalf("pendingForConv(none) = %v, want nil", got)
|
|
}
|
|
gate.Decide(a.ID, true, "owner")
|
|
if got := gate.pendingForConv("c1"); got != nil {
|
|
t.Fatalf("decided request must not be pending, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestHandleReply_TextMode_OwnerApproves is the private text-approval happy
|
|
// path: an action privately DMs the OWNER (the requester sees nothing, no
|
|
// execution yet); a non-owner "同意" is ignored; the owner's "同意" — sent from
|
|
// their own chat, NOT the request conversation — decides and executes.
|
|
func TestHandleReply_TextMode_OwnerApproves(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ct1")
|
|
runner := &fakeRunner{}
|
|
notifier := &fakeNotifier{}
|
|
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
|
|
|
// A group requester (not the owner) asks for an action.
|
|
out, handled := o.handleReply(context.Background(), "requester", "conv-group", `好的。[[ACTION:todo.create title="交方案"]]`, nil)
|
|
if !handled || out != "" {
|
|
t.Fatalf("text-mode action: handled=%v out=%q, want handled with empty out (requester sees nothing)", handled, out)
|
|
}
|
|
if runner.count() != 0 {
|
|
t.Fatalf("must not execute before the owner decides, ran %d", runner.count())
|
|
}
|
|
if len(gate.list()) != 1 {
|
|
t.Fatalf("expected one pending request, got %d", len(gate.list()))
|
|
}
|
|
// The approval prompt went privately to the OWNER, never to the requester.
|
|
if !notifier.toUser("owner") {
|
|
t.Fatalf("approval prompt was not DMed to the owner: %+v", notifier.sent)
|
|
}
|
|
if notifier.toUser("requester") {
|
|
t.Fatal("the requester must NOT be notified of the approval")
|
|
}
|
|
|
|
// A non-owner saying 同意 must not decide or execute.
|
|
if o.handleOwnerDecision(context.Background(), "intruder", "同意") {
|
|
t.Fatal("non-owner must not be able to approve")
|
|
}
|
|
if runner.count() != 0 {
|
|
t.Fatalf("non-owner approval must not execute, ran %d", runner.count())
|
|
}
|
|
|
|
// The owner saying 同意 decides and executes; the requester then gets the
|
|
// outcome.
|
|
if !o.handleOwnerDecision(context.Background(), "owner", "同意") {
|
|
t.Fatal("owner approval must be consumed")
|
|
}
|
|
if runner.count() != 1 {
|
|
t.Fatalf("owner approval must execute exactly once, ran %d", runner.count())
|
|
}
|
|
if gate.list()[0].State != approvalExecuted {
|
|
t.Fatalf("request state = %s, want executed", gate.list()[0].State)
|
|
}
|
|
if !notifier.toUser("requester") {
|
|
t.Fatal("the requester should get the final outcome after execution")
|
|
}
|
|
}
|
|
|
|
// TestHandleReply_TextMode_OwnerSelfAutoApproves: when the requester IS the
|
|
// owner, the action runs immediately with NO confirmation round-trip, but is
|
|
// still recorded (auto_approved=true, decided-by owner, executed) for audit.
|
|
func TestHandleReply_TextMode_OwnerSelfAutoApproves(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ctself")
|
|
runner := &fakeRunner{}
|
|
notifier := &fakeNotifier{}
|
|
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
|
|
|
var replies []string
|
|
reply := func(_ context.Context, _, text string) error { replies = append(replies, text); return nil }
|
|
|
|
// The OWNER asks for the action themselves.
|
|
out, handled := o.handleReply(context.Background(), "owner", "conv-owner", `好。[[ACTION:todo.create title="自己的待办"]]`, reply)
|
|
if !handled || out != "" {
|
|
t.Fatalf("owner self-request: handled=%v out=%q", handled, out)
|
|
}
|
|
// Executed immediately — no second confirmation asked.
|
|
if runner.count() != 1 {
|
|
t.Fatalf("owner self-request must execute immediately, ran %d", runner.count())
|
|
}
|
|
if notifier.count() != 0 {
|
|
t.Fatalf("owner self-request must NOT send an approval DM, sent %d", notifier.count())
|
|
}
|
|
// Still recorded for audit: auto_approved + executed + decided by owner.
|
|
rec := gate.list()[0]
|
|
if !rec.AutoApproved || rec.State != approvalExecuted || rec.DecidedBy != "owner" {
|
|
t.Fatalf("audit record wrong: autoApproved=%v state=%s decidedBy=%s", rec.AutoApproved, rec.State, rec.DecidedBy)
|
|
}
|
|
if !strings.Contains(strings.Join(replies, "|"), "已为你完成") {
|
|
t.Fatalf("owner should get the result, replies=%v", replies)
|
|
}
|
|
}
|
|
|
|
// fakeAudit records the requests handed to the audit sink.
|
|
type fakeAudit struct {
|
|
mu sync.Mutex
|
|
recs []*ApprovalRequest
|
|
}
|
|
|
|
func (a *fakeAudit) record(_ context.Context, req *ApprovalRequest) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
cp := *req
|
|
a.recs = append(a.recs, &cp)
|
|
}
|
|
|
|
func (a *fakeAudit) count() int { a.mu.Lock(); defer a.mu.Unlock(); return len(a.recs) }
|
|
|
|
// TestAudit_RecordsTerminalOutcomes verifies the audit sink gets one terminal
|
|
// record per action: an auto-executed owner request, and an approved request.
|
|
func TestAudit_RecordsTerminalOutcomes(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("cta")
|
|
audit := &fakeAudit{}
|
|
o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", &fakeNotifier{})
|
|
o.audit = audit
|
|
reply := func(_ context.Context, _, _ string) error { return nil }
|
|
|
|
// Owner self-request → auto-executed → audited once, as executed.
|
|
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="自己"]]`, reply)
|
|
if audit.count() != 1 {
|
|
t.Fatalf("auto-exec must produce one audit record, got %d", audit.count())
|
|
}
|
|
if audit.recs[0].State != approvalExecuted || !audit.recs[0].AutoApproved {
|
|
t.Fatalf("auto audit record: state=%s auto=%v", audit.recs[0].State, audit.recs[0].AutoApproved)
|
|
}
|
|
|
|
// Someone else's request, approved by the owner → audited as executed.
|
|
o.handleReply(context.Background(), "requester", "conv-g", `[[ACTION:todo.create title="别人"]]`, reply)
|
|
if audit.count() != 1 {
|
|
t.Fatal("a pending (not yet decided) request must not be audited yet")
|
|
}
|
|
o.handleOwnerDecision(context.Background(), "owner", "同意")
|
|
if audit.count() != 2 {
|
|
t.Fatalf("owner approval must produce a second audit record, got %d", audit.count())
|
|
}
|
|
if audit.recs[1].State != approvalExecuted || audit.recs[1].DecidedBy != "owner" {
|
|
t.Fatalf("approved audit record: state=%s decidedBy=%s", audit.recs[1].State, audit.recs[1].DecidedBy)
|
|
}
|
|
}
|
|
|
|
// TestDeferredExecution_HoldsAndRetries is the graceful-degradation path: when
|
|
// execution fails (identity not ready), the request is DEFERRED (not lost, not
|
|
// failed), the owner is privately notified; after the owner recovers and replies
|
|
// "重试", the backlog is replayed, executes, and the requester gets the result.
|
|
func TestDeferredExecution_HoldsAndRetries(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ctdef")
|
|
runner := &fakeRunner{fail: true} // identity not ready → execution fails
|
|
notifier := &fakeNotifier{}
|
|
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
|
|
|
// A non-owner request, approved by the owner, but execution fails → deferred.
|
|
o.handleReply(context.Background(), "requester", "conv-g", `[[ACTION:todo.create title="待恢复"]]`, nil)
|
|
if !o.handleOwnerDecision(context.Background(), "owner", "同意") {
|
|
t.Fatal("owner approval must be consumed")
|
|
}
|
|
rec := gate.list()[0]
|
|
if rec.State != approvalDeferred {
|
|
t.Fatalf("failed execution must DEFER (not fail), got state=%s", rec.State)
|
|
}
|
|
// Owner was told to recover; requester was NOT told it failed (task not lost).
|
|
if !notifier.toUser("owner") {
|
|
t.Fatal("owner should be notified of the deferred action")
|
|
}
|
|
if notifier.toUser("requester") {
|
|
t.Fatal("requester must NOT be told it failed — their task is held, not lost")
|
|
}
|
|
|
|
// A bare non-retry word from the owner should pass through (not consumed).
|
|
if o.handleOwnerDecision(context.Background(), "owner", "在吗") {
|
|
t.Fatal("ordinary owner message must not be consumed")
|
|
}
|
|
|
|
// Owner recovers (identity now works) and replies "重试" → backlog flushes.
|
|
runner.setFail(false)
|
|
if !o.handleOwnerDecision(context.Background(), "owner", "重试") {
|
|
t.Fatal("retry word must be consumed")
|
|
}
|
|
if gate.list()[0].State != approvalExecuted {
|
|
t.Fatalf("after retry the deferred request must execute, got %s", gate.list()[0].State)
|
|
}
|
|
if !notifier.toUser("requester") {
|
|
t.Fatal("requester should get the outcome after the backlog is flushed")
|
|
}
|
|
}
|
|
|
|
// TestConfirmPolicy_Auto: a non-owner request runs without asking under "auto".
|
|
func TestConfirmPolicy_Auto(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ctauto2")
|
|
runner := &fakeRunner{}
|
|
notifier := &fakeNotifier{}
|
|
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
|
o.confirmPolicy = "auto"
|
|
|
|
_, handled := o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="x"]]`, func(_ context.Context, _, _ string) error { return nil })
|
|
if !handled {
|
|
t.Fatal("auto policy should handle (run) the action")
|
|
}
|
|
if runner.count() != 1 {
|
|
t.Fatalf("auto policy must execute without asking, ran %d", runner.count())
|
|
}
|
|
if gate.list()[0].State != approvalExecuted {
|
|
t.Fatalf("state=%s want executed", gate.list()[0].State)
|
|
}
|
|
}
|
|
|
|
// TestConfirmPolicy_Manual: a non-owner request asks the owner (default).
|
|
func TestConfirmPolicy_Manual(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ctman")
|
|
runner := &fakeRunner{}
|
|
notifier := &fakeNotifier{}
|
|
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier) // no policy = manual
|
|
|
|
o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="x"]]`, nil)
|
|
if runner.count() != 0 {
|
|
t.Fatal("manual policy must NOT execute before the owner decides")
|
|
}
|
|
if gate.list()[0].State != approvalPending {
|
|
t.Fatalf("state=%s want pending (awaiting owner)", gate.list()[0].State)
|
|
}
|
|
if !notifier.toUser("owner") {
|
|
t.Fatal("manual policy should DM the owner for approval")
|
|
}
|
|
}
|
|
|
|
// TestConfirmPolicy_Remember: ask once per verb, then reuse the decision.
|
|
func TestConfirmPolicy_Remember(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ctrem")
|
|
runner := &fakeRunner{}
|
|
notifier := &fakeNotifier{}
|
|
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
|
o.confirmPolicy = "remember"
|
|
|
|
// First todo.create from a non-owner → asks (pending).
|
|
o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="第一次"]]`, nil)
|
|
if gate.latestPending() == nil {
|
|
t.Fatal("first request should ask the owner")
|
|
}
|
|
// Owner approves → remembered, executes.
|
|
o.handleOwnerDecision(context.Background(), "owner", "同意")
|
|
if runner.count() != 1 {
|
|
t.Fatalf("approved request should execute, ran %d", runner.count())
|
|
}
|
|
|
|
// Second todo.create → reused decision, auto-runs (no new pending).
|
|
_, handled := o.handleReply(context.Background(), "someone", "c2", `[[ACTION:todo.create title="第二次"]]`, func(_ context.Context, _, _ string) error { return nil })
|
|
if !handled || runner.count() != 2 {
|
|
t.Fatalf("remembered approve should auto-run the 2nd request, handled=%v ran=%d", handled, runner.count())
|
|
}
|
|
}
|
|
|
|
// TestScopeEnforcement: an action outside the role's allowlist is refused up
|
|
// front (the requester is told, nothing reaches the gate); an in-scope action
|
|
// proceeds; an empty allowlist allows everything.
|
|
func TestScopeEnforcement(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ctscope")
|
|
runner := &fakeRunner{}
|
|
o := newTextApprovalOrchestrator(gate, runner, "owner", &fakeNotifier{})
|
|
o.allowedScopes = []string{"todo"} // HR-style: only todo
|
|
|
|
// doc.create is out of scope → refused, NOT gated, requester told.
|
|
out, handled := o.handleReply(context.Background(), "requester", "c1", `[[ACTION:doc.create name="周报"]]`, nil)
|
|
if handled {
|
|
t.Fatal("out-of-scope action must not be handled by the gate")
|
|
}
|
|
if !strings.Contains(out, "没有") || !strings.Contains(out, "doc") {
|
|
t.Fatalf("requester should be told it's out of scope, got %q", out)
|
|
}
|
|
if len(gate.list()) != 0 {
|
|
t.Fatal("out-of-scope action must not create an approval request")
|
|
}
|
|
|
|
// todo.create is in scope → proceeds (owner self-request auto-executes).
|
|
o.handleReply(context.Background(), "owner", "c2", `[[ACTION:todo.create title="排期"]]`, func(_ context.Context, _, _ string) error { return nil })
|
|
if len(gate.list()) != 1 {
|
|
t.Fatalf("in-scope action should be processed, got %d requests", len(gate.list()))
|
|
}
|
|
|
|
// scopeAllows: empty allowlist = allow all.
|
|
o.allowedScopes = nil
|
|
if !o.scopeAllows("anything") {
|
|
t.Fatal("empty allowlist must allow all products")
|
|
}
|
|
}
|
|
|
|
// TestAutoFlushDeferred verifies the background retry: silent while the identity
|
|
// is still wrong (no spam), and it drains + notifies once execution works.
|
|
func TestAutoFlushDeferred(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ctauto")
|
|
runner := &fakeRunner{fail: true}
|
|
notifier := &fakeNotifier{}
|
|
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
|
|
|
// Defer one (identity not ready).
|
|
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="自动恢复"]]`, func(_ context.Context, _, _ string) error { return nil })
|
|
deferNotices := notifier.count() // the deferred DM
|
|
if gate.list()[0].State != approvalDeferred {
|
|
t.Fatalf("want deferred, got %s", gate.list()[0].State)
|
|
}
|
|
|
|
// Auto-retry while still failing → stays deferred, NO new owner message.
|
|
o.autoFlushDeferred(context.Background())
|
|
if gate.list()[0].State != approvalDeferred {
|
|
t.Fatal("should still be deferred while identity is wrong")
|
|
}
|
|
if notifier.count() != deferNotices {
|
|
t.Fatalf("auto-retry must stay silent when nothing completes (sent %d, was %d)", notifier.count(), deferNotices)
|
|
}
|
|
|
|
// Identity recovers → auto-retry drains it and notifies once.
|
|
runner.setFail(false)
|
|
o.autoFlushDeferred(context.Background())
|
|
if gate.list()[0].State != approvalExecuted {
|
|
t.Fatalf("auto-retry should execute once identity is back, got %s", gate.list()[0].State)
|
|
}
|
|
notifier.mu.Lock()
|
|
last := notifier.sent[len(notifier.sent)-1].text
|
|
notifier.mu.Unlock()
|
|
if !strings.Contains(last, "已自动恢复") || !strings.Contains(last, "自动恢复") {
|
|
t.Fatalf("owner should get an auto-recovery notice, got %q", last)
|
|
}
|
|
}
|
|
|
|
// TestIsTransientSheetErr distinguishes retryable throttles from permanent errors.
|
|
func TestIsTransientSheetErr(t *testing.T) {
|
|
for _, e := range []string{"system error: THREADPOOL_BUSY", "server busy", "request timeout", "HTTP 429 too many requests"} {
|
|
if !isTransientSheetErr(fmt.Errorf("%s", e)) {
|
|
t.Fatalf("%q should be transient", e)
|
|
}
|
|
}
|
|
for _, e := range []string{"invalid nodeId", "permission denied", "not found"} {
|
|
if isTransientSheetErr(fmt.Errorf("%s", e)) {
|
|
t.Fatalf("%q should NOT be transient", e)
|
|
}
|
|
}
|
|
if isTransientSheetErr(nil) {
|
|
t.Fatal("nil is not transient")
|
|
}
|
|
}
|
|
|
|
// TestFlushDeferred_ReportsCompletedItems verifies the owner gets a per-item
|
|
// completion list (not just a count) when the backlog is flushed.
|
|
func TestFlushDeferred_ReportsCompletedItems(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ctflush")
|
|
runner := &fakeRunner{fail: true}
|
|
notifier := &fakeNotifier{}
|
|
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
|
|
|
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="我的待办"]]`, func(_ context.Context, _, _ string) error { return nil })
|
|
if gate.list()[0].State != approvalDeferred {
|
|
t.Fatalf("want deferred, got %s", gate.list()[0].State)
|
|
}
|
|
|
|
runner.setFail(false)
|
|
o.handleOwnerDecision(context.Background(), "owner", "重试")
|
|
|
|
notifier.mu.Lock()
|
|
var last string
|
|
if len(notifier.sent) > 0 {
|
|
last = notifier.sent[len(notifier.sent)-1].text
|
|
}
|
|
notifier.mu.Unlock()
|
|
if !strings.Contains(last, "已补做 1 个") || !strings.Contains(last, "创建待办:我的待办") {
|
|
t.Fatalf("flush summary should list the completed item, got %q", last)
|
|
}
|
|
}
|
|
|
|
// TestIsRetryWord checks whole-message retry-keyword matching.
|
|
func TestIsRetryWord(t *testing.T) {
|
|
for _, w := range []string{"重试", " 恢复 ", "retry", "继续"} {
|
|
if !isRetryWord(w) {
|
|
t.Fatalf("%q should be a retry word", w)
|
|
}
|
|
}
|
|
for _, w := range []string{"重试一下他的方案", "", "你好"} {
|
|
if isRetryWord(w) {
|
|
t.Fatalf("%q should NOT be a retry word", w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleReply_TextMode_OwnerRejects: the owner's "拒绝" declines without
|
|
// executing.
|
|
func TestHandleReply_TextMode_OwnerRejects(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ct2")
|
|
runner := &fakeRunner{}
|
|
notifier := &fakeNotifier{}
|
|
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
|
|
|
o.handleReply(context.Background(), "requester", "conv-group", `[[ACTION:todo.create title="x"]]`, nil)
|
|
if !o.handleOwnerDecision(context.Background(), "owner", "拒绝") {
|
|
t.Fatal("owner rejection must be consumed")
|
|
}
|
|
if runner.count() != 0 {
|
|
t.Fatalf("rejection must NOT execute, ran %d", runner.count())
|
|
}
|
|
if gate.list()[0].State != approvalRejected {
|
|
t.Fatalf("state = %s, want rejected", gate.list()[0].State)
|
|
}
|
|
}
|
|
|
|
// TestHandleOwnerDecision_NotADecision: an ordinary owner message (not a bare
|
|
// keyword) is not consumed, so it still reaches the agent.
|
|
func TestHandleOwnerDecision_NotADecision(t *testing.T) {
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
gate := newApprovalGate("ct3")
|
|
o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", &fakeNotifier{})
|
|
gate.Submit(ApprovalRequest{ConvID: "conv-1", Summary: "x", State: approvalPending})
|
|
|
|
if o.handleOwnerDecision(context.Background(), "owner", "顺便帮我查下天气") {
|
|
t.Fatal("a non-decision owner message must pass through to the agent")
|
|
}
|
|
}
|
|
|
|
// TestLatestPending returns the newest pending request across conversations.
|
|
func TestLatestPending(t *testing.T) {
|
|
gate := newApprovalGate("")
|
|
gate.Submit(ApprovalRequest{ConvID: "c1", Summary: "old", CreatedAt: time.Unix(100, 0)})
|
|
newer := gate.Submit(ApprovalRequest{ConvID: "c2", Summary: "new", CreatedAt: time.Unix(200, 0)})
|
|
if got := gate.latestPending(); got == nil || got.ID != newer.ID {
|
|
t.Fatalf("latestPending = %v, want newest %s", got, newer.ID)
|
|
}
|
|
gate.Decide(newer.ID, true, "owner")
|
|
if got := gate.latestPending(); got == nil || got.Summary != "old" {
|
|
t.Fatalf("after deciding newest, latestPending should fall back to the older pending, got %v", got)
|
|
}
|
|
}
|
|
|
|
// list is a tiny test helper to enumerate the gate's requests.
|
|
func (g *approvalGate) list() []*ApprovalRequest {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
out := make([]*ApprovalRequest, 0, len(g.reqs))
|
|
for _, r := range g.reqs {
|
|
cp := *r
|
|
out = append(out, &cp)
|
|
}
|
|
return out
|
|
}
|