Why: TestIntentSessionLegacyResponsesUnchanged raced the cancel dispatch against the prior run settling and 409'd under CI load; the tests now wait for settle and poll busy 409s with a deadline. Evidence: repair receipt (a1ff-run.json); 3x race runs green (verify-a1ff.json, exit=0). Impact: test-only hardening. Refs: feature/exec-recovery-diagnostics-a2
435 lines
18 KiB
Go
435 lines
18 KiB
Go
package server
|
||
|
||
// A1/U-A1c 取消路由的 chat 端到端(合成数据 + 现有 fakes):
|
||
// - 会话里绑定了一个活跃运行 → 无澄清地按既有取消路径派发(回执可见),
|
||
// 并在会话上留下「已取消某运行」的短期记录;
|
||
// - 原文点名了运行 → 点名优先于会话绑定;
|
||
// - 靶子缺失/不可见 → 如实澄清并列出自己最近的运行,绝不猜;
|
||
// - 高危取消命令 → 仍先出确认卡,确认前不执行(会话不绕过确认门禁)。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
agentcommand "agent-command"
|
||
"agent-system-backend/internal/cmdbus"
|
||
"agent-system-backend/internal/intentgate"
|
||
"agent-system-backend/internal/intentsession"
|
||
"agent-system-backend/internal/store"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// taskCancelIntent 是「取消刚才的任务」的一次识别(库层 cancel 命令;与
|
||
// U-A1b 的「取消运行 <id>」是不同意图:前者取消运行,后者放弃补槽)。
|
||
func taskCancelIntent(slots map[string]string) intentgate.MatchResult {
|
||
return intentgate.MatchResult{Branch: "matched", ID: "demo.task-cancel", Slots: slots,
|
||
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Target: "demo.task-cancel"}}}
|
||
}
|
||
|
||
// liveRun 起一个仍在进行的运行(mock runner 的 #hitl 挂起等待审批),返回
|
||
// run id;它一直活到被取消或被审批——就是「我刚才的任务」。
|
||
func liveRun(t *testing.T, h http.Handler, convID string) string {
|
||
t.Helper()
|
||
runID := postMessage(t, h, convID, "#hitl")
|
||
waitActiveHITL(t, h, convID)
|
||
return runID
|
||
}
|
||
|
||
// waitActiveHITL 等到运行挂起在审批上(run 处于活跃/等待态)。
|
||
func waitActiveHITL(t *testing.T, h http.Handler, convID string) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(testRunTerminalTimeout)
|
||
for time.Now().Before(deadline) {
|
||
if code, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil); code == 200 {
|
||
if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 {
|
||
return
|
||
}
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
t.Fatal("运行没有进入等待审批状态")
|
||
}
|
||
|
||
// waitRunStatus 等运行落到期望状态(取消是异步收尾的)。
|
||
func waitRunStatus(t *testing.T, h http.Handler, runID, want string) map[string]any {
|
||
t.Helper()
|
||
deadline := time.Now().Add(testRunTerminalTimeout)
|
||
for time.Now().Before(deadline) {
|
||
if code, d := doJSON(t, h, "GET", "/api/runs/"+runID, nil); code == 200 {
|
||
if r, ok := d["run"].(map[string]any); ok && r["status"] == want {
|
||
return r
|
||
}
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
t.Fatalf("运行 %s 未到达状态 %s", runID, want)
|
||
return nil
|
||
}
|
||
|
||
// seedBoundRun 在会话上挂起一个已抽到运行号的预期(等价于「上一轮澄清里
|
||
// 抽到了这个运行」),用于驱动取消的会话来源。
|
||
func seedBoundRun(t *testing.T, st *store.Store, projectID, convID, runID string) {
|
||
t.Helper()
|
||
_, err := intentsession.New(st).Suspend(intentsession.Key{
|
||
Owner: store.OwnerDefault, Project: projectID, Conversation: convID},
|
||
intentsession.Expectation{IntentID: "demo.run-cancel",
|
||
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.run-cancel"},
|
||
Missing: []intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}},
|
||
Question: "取消原因?"},
|
||
map[string]string{"run_id": runID})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// 会话里绑定了活跃运行 → 无澄清地派发既有取消动作(回执可见、运行真的停了)。
|
||
func TestIntentCancelDispatchesRunBoundInSession(t *testing.T) {
|
||
srv, st, gate, _ := cancelServer(t)
|
||
h := srv.Handler()
|
||
projectID, convID := setupConversation(t, h)
|
||
runID := liveRun(t, h, convID)
|
||
seedBoundRun(t, st, projectID, convID, runID)
|
||
gate.script("取消刚才的任务", taskCancelIntent(nil))
|
||
|
||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||
map[string]any{"text": "取消刚才的任务"})
|
||
if code != 200 {
|
||
t.Fatalf("取消轮:%d %v", code, out)
|
||
}
|
||
if _, asked := out["clarify"]; asked {
|
||
t.Fatalf("有唯一靶子时不得澄清:%v", out)
|
||
}
|
||
cancel, _ := out["cancel"].(map[string]any)
|
||
if cancel == nil || cancel["runId"] != runID || cancel["accepted"] != true || cancel["source"] != "session" {
|
||
t.Fatalf("取消回执:%v", out)
|
||
}
|
||
if _, ran := out["run"]; ran {
|
||
t.Fatalf("取消轮不得再起一个回合:%v", out)
|
||
}
|
||
note, _ := out["intent_session"].(map[string]any)
|
||
if note == nil || note["kind"] != "cancelled" || note["runId"] != runID || note["pending"] != false {
|
||
t.Fatalf("会话回话:%v", out)
|
||
}
|
||
// 派发真的生效:运行被取消。
|
||
waitRunStatus(t, h, runID, string(core.RunCancelled))
|
||
|
||
// 会话上留下短期「已取消」记录:挂起预期已清、记录指向被取消的运行。
|
||
sess := pendingIntent(t, st, projectID, convID)
|
||
if sess == nil || sess.Cancelled == nil || sess.Cancelled.RunID != runID {
|
||
t.Fatalf("取消记录:%+v", sess)
|
||
}
|
||
if len(sess.Expectation.Missing) != 0 || sess.Slots["run_id"] != "" {
|
||
t.Fatalf("取消后仍留着待补齐的槽位:%+v", sess)
|
||
}
|
||
}
|
||
|
||
// 原文点名的运行优先于会话绑定:被点的那个被取消,会话绑定的那个纹丝不动。
|
||
func TestIntentCancelExplicitRunIDWinsOverSessionBinding(t *testing.T) {
|
||
srv, st, gate, _ := cancelServer(t)
|
||
h := srv.Handler()
|
||
projectID, _ := setupConversation(t, h)
|
||
// 两个会话各起一个活跃运行(同一用户、同一项目)。
|
||
boundConv := mustCode(t, h, "POST", "/api/projects/"+projectID+"/conversations",
|
||
map[string]any{"title": "另一个会话"})["id"].(string)
|
||
boundRun := liveRun(t, h, boundConv)
|
||
_, convID := setupConversation(t, h)
|
||
target := liveRun(t, h, convID)
|
||
seedBoundRun(t, st, projectID, convID, boundRun)
|
||
gate.script("取消刚才的任务 编号:"+target, taskCancelIntent(map[string]string{"run_id": target}))
|
||
|
||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||
map[string]any{"text": "取消刚才的任务 编号:" + target})
|
||
if code != 200 {
|
||
t.Fatalf("取消轮:%d %v", code, out)
|
||
}
|
||
cancel, _ := out["cancel"].(map[string]any)
|
||
if cancel == nil || cancel["runId"] != target || cancel["source"] != "explicit" || cancel["accepted"] != true {
|
||
t.Fatalf("显式运行号必须优先:%v", out)
|
||
}
|
||
waitRunStatus(t, h, target, string(core.RunCancelled))
|
||
if code, out := doJSON(t, h, "GET", "/api/runs/"+boundRun, nil); code == 200 {
|
||
if r, _ := out["run"].(map[string]any); r["status"] == string(core.RunCancelled) {
|
||
t.Fatalf("会话绑定的运行被误取消:%v", r)
|
||
}
|
||
}
|
||
}
|
||
|
||
// insertRun 写入一条合成运行行(未在本地执行的运行:候选与可见性用)。
|
||
func insertRun(t *testing.T, st *store.Store, id, convID, projectID, owner, status string, age time.Duration) {
|
||
t.Helper()
|
||
if _, err := st.DB().Exec(`INSERT INTO runs
|
||
(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at)
|
||
VALUES (?,'',?,?,?,'conversation','',?,'','',?)`,
|
||
id, convID, projectID, owner, status, time.Now().UTC().Add(-age).Format(time.RFC3339Nano)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// 靶子不唯一:不猜。如实澄清,并把发起人自己最近的运行列出来(新到旧)。
|
||
func TestIntentCancelWithoutTargetClarifiesWithOwnRuns(t *testing.T) {
|
||
srv, st, gate, _ := cancelServer(t)
|
||
h := srv.Handler()
|
||
projectID, convID := setupConversation(t, h)
|
||
// 会话里三条未终态运行:两条自己的、一条别人的(后者不得出现在候选里)。
|
||
insertRun(t, st, "run_old", convID, projectID, store.OwnerDefault, "running", 2*time.Minute)
|
||
insertRun(t, st, "run_new", convID, projectID, store.OwnerDefault, "waiting_hitl", 1*time.Minute)
|
||
insertRun(t, st, "run_foreign", convID, projectID, "mallory", "running", 30*time.Second)
|
||
insertRun(t, st, "run_done", convID, projectID, store.OwnerDefault, string(core.RunCompleted), 10*time.Second)
|
||
gate.script("取消刚才的任务", taskCancelIntent(nil))
|
||
|
||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||
map[string]any{"text": "取消刚才的任务"})
|
||
if code != 200 {
|
||
t.Fatalf("澄清轮:%d %v", code, out)
|
||
}
|
||
if _, dispatched := out["cancel"]; dispatched {
|
||
t.Fatalf("没有唯一靶子时不得派发:%v", out)
|
||
}
|
||
c := clarifyOf(t, out)
|
||
if c["question"] != "要取消哪一个任务?" {
|
||
t.Fatalf("澄清提问:%v", c)
|
||
}
|
||
missing, _ := c["missing"].([]any)
|
||
got := []string{}
|
||
for _, m := range missing {
|
||
got = append(got, m.(map[string]any)["key"].(string))
|
||
}
|
||
if strings.Join(got, ",") != "run_new,run_old" {
|
||
t.Fatalf("候选必须是自己最近的未终态运行(新到旧):%v", got)
|
||
}
|
||
settle(t, h, out)
|
||
// 澄清不是取消:候选运行的状态一动不动(自己的可见,别人的依旧不可见)。
|
||
for _, id := range []string{"run_old", "run_new"} {
|
||
if _, err := st.GetRun(store.OwnerDefault, id); err != nil {
|
||
t.Fatalf("澄清轮改动了运行 %s:%v", id, err)
|
||
}
|
||
}
|
||
if _, err := st.GetRun("mallory", "run_foreign"); err != nil {
|
||
t.Fatalf("澄清轮改动了别人的运行:%v", err)
|
||
}
|
||
|
||
// 一个可取消的运行都没有时如实说明,而不是空问一句。
|
||
_, idle := setupConversation(t, h)
|
||
gate.script("取消任务", taskCancelIntent(nil))
|
||
code, out = doJSON(t, h, "POST", "/api/conversations/"+idle+"/messages",
|
||
map[string]any{"text": "取消任务"})
|
||
if code != 200 {
|
||
t.Fatalf("无候选轮:%d %v", code, out)
|
||
}
|
||
if q := clarifyOf(t, out)["question"]; q != "现在没有可取消的任务。" {
|
||
t.Fatalf("无候选时的提问:%v", q)
|
||
}
|
||
settle(t, h, out)
|
||
}
|
||
|
||
// 别人的运行既不可见也不可取消:不派发、不泄露存在性。
|
||
func TestIntentCancelNeverResolvesAnotherOwnerRun(t *testing.T) {
|
||
srv, st, gate, _ := cancelServer(t)
|
||
h := srv.Handler()
|
||
_, convID := setupConversation(t, h)
|
||
foreign := "run_foreign"
|
||
insertRun(t, st, foreign, convID, "prj_foreign", "mallory", "running", time.Minute)
|
||
if _, err := st.GetRun("mallory", foreign); err != nil {
|
||
t.Fatalf("前置:别人的运行应存在:%v", err)
|
||
}
|
||
gate.script("取消别人的任务", taskCancelIntent(map[string]string{"run_id": foreign}))
|
||
|
||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||
map[string]any{"text": "取消别人的任务"})
|
||
if code != 200 {
|
||
t.Fatalf("取消轮:%d %v", code, out)
|
||
}
|
||
if _, dispatched := out["cancel"]; dispatched {
|
||
t.Fatalf("别人的运行不得被取消:%v", out)
|
||
}
|
||
c := clarifyOf(t, out)
|
||
if reason, _ := c["reason"].(string); !strings.Contains(reason, "不可见") {
|
||
t.Fatalf("应如实说明不可见(与不存在同一出口):%v", c)
|
||
}
|
||
if missing, _ := c["missing"].([]any); len(missing) != 0 {
|
||
t.Fatalf("不得把别人的运行列为候选:%v", c)
|
||
}
|
||
settle(t, h, out)
|
||
if _, err := st.GetRun("mallory", foreign); err != nil {
|
||
t.Fatalf("别人的运行被改动了:%v", err)
|
||
}
|
||
}
|
||
|
||
// 确认语义不变:注册了高危取消命令时先出确认卡,确认前处理器不执行;
|
||
// 确认后才真的派发。会话不记录「已取消」(尚未生效)。
|
||
func TestIntentCancelSurfacesHighDangerConfirmCard(t *testing.T) {
|
||
srv, st, gate, log := cancelServer(t)
|
||
h := srv.Handler()
|
||
projectID, convID := setupConversation(t, h)
|
||
runID := liveRun(t, h, convID)
|
||
risky := agentcommand.CommandSpec{ID: intentCancelCommandID, Title: "取消运行(高危)",
|
||
Danger: agentcommand.DangerHigh,
|
||
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","run_id"],
|
||
"properties":{"run_id":{"type":"string"},"text":{"type":"string"}}}`)}
|
||
if err := srv.Bus.RegisterBuiltin(risky, "", func(_ context.Context, req cmdbus.Request) (any, error) {
|
||
log.record(req)
|
||
return map[string]any{"ok": true}, nil
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
gate.script("取消刚才的任务", taskCancelIntent(map[string]string{"run_id": runID}))
|
||
|
||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||
map[string]any{"text": "取消刚才的任务"})
|
||
if code != 200 {
|
||
t.Fatalf("取消轮:%d %v", code, out)
|
||
}
|
||
if out["command"] != intentCancelCommandID {
|
||
t.Fatalf("注册了取消命令时应走命令总线:%v", out)
|
||
}
|
||
receipt, _ := out["receipt"].(map[string]any)
|
||
if receipt == nil || receipt["state"] != string(agentcommand.StateAccepted) {
|
||
t.Fatalf("高危命令的受理回执:%v", out)
|
||
}
|
||
card, _ := out["card"].(map[string]any)
|
||
if card == nil {
|
||
t.Fatalf("高危取消必须出确认卡:%v", out)
|
||
}
|
||
if len(log.snapshot()) != 0 {
|
||
t.Fatalf("确认前不得执行处理器:%v", log.snapshot())
|
||
}
|
||
if _, noted := out["intent_session"]; noted {
|
||
t.Fatalf("确认前不得在会话上记「已取消」:%v", out)
|
||
}
|
||
if sess := pendingIntent(t, st, projectID, convID); sess != nil {
|
||
t.Fatalf("确认前会话不得被改写:%+v", sess)
|
||
}
|
||
|
||
// 卡上确认 → 与任何其他高后果命令同款:处理器执行,参数带运行号。
|
||
cardID, _ := card["id"].(string)
|
||
code, confirmed := doJSON(t, h, "POST", "/api/cards/"+cardID+"/actions/confirm", map[string]any{})
|
||
if code != 200 {
|
||
t.Fatalf("确认:%d %v", code, confirmed)
|
||
}
|
||
rec, _ := confirmed["receipt"].(map[string]any)
|
||
if rec == nil || rec["state"] != string(agentcommand.StateCommitted) {
|
||
t.Fatalf("确认回执:%v", confirmed)
|
||
}
|
||
calls := log.snapshot()
|
||
if len(calls) != 1 || calls[0]["run_id"] != runID || calls[0]["text"] != "取消刚才的任务" {
|
||
t.Fatalf("确认后的派发参数:%v", calls)
|
||
}
|
||
}
|
||
|
||
// 没有会话、也没有点名运行的历史行为逐字节不变:普通回合照常起,响应里既
|
||
// 没有取消字段,也没有会话语义字段。
|
||
func TestIntentCancelLegacyTurnUnchangedWithoutSession(t *testing.T) {
|
||
srv, _, gate, log := cancelServer(t)
|
||
h := srv.Handler()
|
||
_, convID := setupConversation(t, h)
|
||
gate.script("今天天气如何", intentgate.MatchResult{Branch: "fallback"})
|
||
|
||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||
map[string]any{"text": "今天天气如何"})
|
||
if code != 200 {
|
||
t.Fatalf("普通轮:%d %v", code, out)
|
||
}
|
||
for _, key := range []string{"cancel", "clarify", "intent_session", "command"} {
|
||
if _, has := out[key]; has {
|
||
t.Fatalf("普通轮不应出现 %s:%v", key, out)
|
||
}
|
||
}
|
||
if _, ran := out["run"]; !ran {
|
||
t.Fatalf("普通轮照常起 run:%v", out)
|
||
}
|
||
settle(t, h, out)
|
||
if len(log.snapshot()) != 0 {
|
||
t.Fatalf("普通轮不得派发命令:%v", log.snapshot())
|
||
}
|
||
}
|
||
|
||
// 会话上的「已取消」记录不再是取消的靶子:重复取消如实回到澄清。
|
||
func TestIntentCancelRecordIsNotACancelTarget(t *testing.T) {
|
||
srv, st, gate, _ := cancelServer(t)
|
||
h := srv.Handler()
|
||
projectID, convID := setupConversation(t, h)
|
||
runID := liveRun(t, h, convID)
|
||
seedBoundRun(t, st, projectID, convID, runID)
|
||
gate.script("取消刚才的任务", taskCancelIntent(nil))
|
||
if code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||
map[string]any{"text": "取消刚才的任务"}); code != 200 {
|
||
t.Fatalf("取消轮:%d %v", code, out)
|
||
}
|
||
waitRunStatus(t, h, runID, string(core.RunCancelled))
|
||
gate.script("再取消一次", taskCancelIntent(nil))
|
||
// 第二轮是占槽位的澄清回合:等上一轮被取消的运行彻底收工再发车(忙 409 是
|
||
// 既有语义,见 postConversationTurn)。
|
||
code, out := postConversationTurn(t, srv, h, st, convID, "再取消一次")
|
||
if code != 200 {
|
||
t.Fatalf("第二轮取消:%d %v", code, out)
|
||
}
|
||
if _, dispatched := out["cancel"]; dispatched {
|
||
t.Fatalf("已取消的记录不得再当靶子:%v", out)
|
||
}
|
||
if _, kept := out["intent_session"]; kept {
|
||
t.Fatalf("澄清轮不得改写会话记录:%v", out)
|
||
}
|
||
settle(t, h, out)
|
||
}
|
||
|
||
// 命令值里的运行号(库层 cancel 命令自带 value)同样能解出靶子。
|
||
func TestIntentCancelResolvesRunIDFromCommandValue(t *testing.T) {
|
||
srv, _, gate, _ := cancelServer(t)
|
||
h := srv.Handler()
|
||
_, convID := setupConversation(t, h)
|
||
runID := liveRun(t, h, convID)
|
||
withValue := taskCancelIntent(nil)
|
||
withValue.Commands[0].Value = runID
|
||
gate.script("取消那个", withValue)
|
||
|
||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||
map[string]any{"text": "取消那个"})
|
||
if code != 200 {
|
||
t.Fatalf("取消轮:%d %v", code, out)
|
||
}
|
||
cancel, _ := out["cancel"].(map[string]any)
|
||
if cancel == nil || cancel["runId"] != runID || cancel["accepted"] != true {
|
||
t.Fatalf("命令值里的运行号未解出:%v", out)
|
||
}
|
||
waitRunStatus(t, h, runID, string(core.RunCancelled))
|
||
}
|
||
|
||
// 消息原文进 params:取消命令与其它命令入口同款(恒带 text)。
|
||
func TestIntentCancelPassesOriginalText(t *testing.T) {
|
||
srv, _, gate, log := cancelServer(t)
|
||
h := srv.Handler()
|
||
_, convID := setupConversation(t, h)
|
||
runID := liveRun(t, h, convID)
|
||
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: intentCancelCommandID,
|
||
Title: "取消运行", ParamsSchema: json.RawMessage(`{"type":"object","required":["text","run_id"],
|
||
"properties":{"run_id":{"type":"string"},"text":{"type":"string"}}}`)},
|
||
"", func(_ context.Context, req cmdbus.Request) (any, error) {
|
||
log.record(req)
|
||
raw, _ := json.Marshal(req)
|
||
if !strings.Contains(string(raw), `"Entrance":"chat"`) {
|
||
t.Errorf("Entrance 必须归一为 chat:%s", raw)
|
||
}
|
||
if req.Actor == "" || req.Owner == "" {
|
||
t.Errorf("Actor/Owner 必须是发起人:%+v", req)
|
||
}
|
||
return map[string]any{"ok": true}, nil
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
gate.script("取消刚才的任务", taskCancelIntent(map[string]string{"run_id": runID}))
|
||
|
||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||
map[string]any{"text": "取消刚才的任务"})
|
||
if code != 200 {
|
||
t.Fatalf("取消轮:%d %v", code, out)
|
||
}
|
||
calls := log.snapshot()
|
||
if len(calls) != 1 || calls[0]["run_id"] != runID || calls[0]["text"] != "取消刚才的任务" {
|
||
t.Fatalf("派发参数:%v", calls)
|
||
}
|
||
}
|