Files
Agent-System-Backend/internal/server/intent_cancel_test.go
T
agent-exec-transport bd051ec895 Harden intent-session tests against load-sensitive cancellation
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
2026-10-22 14:53:10 +00:00

435 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}