Compare commits

..
Author SHA1 Message Date
weichen 35c6995546 Fix cross-action slot inheritance and fail-closed cancel errors
Why: review blockers B1/M2 - Suspend inherited the previous action's
accumulated slots into a new action, and applyCancel swallowed store
errors so an abandoned fill could become an unconfirmed real cancel.
Evidence: review-a1-run.json findings fixed; intentsession/server
tests pass with the reviewer's sequences (verify-a1fix.json).
Impact: different-action suspend starts fresh; non-stale cancel store
errors fail closed; all other flows unchanged.
Refs: feature/intent-multi-turn-a1
2026-10-22 11:27:45 +00:00
5 changed files with 296 additions and 130 deletions
+13 -1
View File
@@ -185,12 +185,17 @@ func (s *Service) Pending(key Key) (*Session, error) {
// Suspend 挂起一个可执行动作的预期并累计已抽到的槽位(续期)。已抽到的
// 槽位不再计入仍缺清单(挂起即如实反映还差什么)。
//
// 累计只在同一个动作内成立:新预期指向别的动作(意图 id 或动作引用变了)
// 时,上一轮已抽到的槽位一律丢弃——新任务从零开始,revision 照常推进,
// 陈旧回答因此作废。否则上一轮抽到的运行号会跟着新动作一起派发:旧任务的
// 槽位漏进新任务,正是本包纪律里明令禁止的那一类。
func (s *Service) Suspend(key Key, expectation Expectation, slots map[string]string) (*Session, error) {
cur, err := s.load(key)
if err != nil {
return nil, err
}
if cur != nil {
if cur != nil && cur.Expectation != nil && sameExpectation(*cur.Expectation, expectation) {
slots = mergeSlots(cur.Slots, slots)
}
var missing []intentgate.SlotQuestion
@@ -408,6 +413,13 @@ func (s *Service) accepted(cur *Session, answer Answer) map[string]string {
return out
}
// sameExpectation 报告新旧预期是否同一个动作(意图 id 与动作引用都一致):
// 只有同一个动作再次挂起才继续累计已抽到的槽位。预期是「已取消」记录时
// 动作种类为 cancel,与任何待补齐的动作都不相同——取消记录绝不被借走。
func sameExpectation(a, b Expectation) bool {
return a.IntentID == b.IntentID && a.Action.Kind == b.Action.Kind && a.Action.ID == b.Action.ID
}
// missingOf 投影到存储形态(缺失槽位表)。
func missingOf(list []intentgate.SlotQuestion) []store.IntentSlotQuestion {
out := make([]store.IntentSlotQuestion, 0, len(list))
@@ -108,6 +108,88 @@ func TestSuspendThenAnswerCompletes(t *testing.T) {
}
}
// 换动作即换任务(跨动作槽位继承的回归序列):A 抽到运行号后挂起 → 用户
// 改口做另一个动作 B(B 缺别的槽位)→ 用户补齐 B → B 齐全时只带 B 自己
// 的槽位。A 的运行号绝不跟着 B 一起派发。
func TestSuspendDiscardsSlotsOfAnotherAction(t *testing.T) {
svc := newService(t)
key := cancelKey()
first, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-7"})
if err != nil {
t.Fatal(err)
}
report := Expectation{IntentID: "demo.report",
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.report"},
Missing: []intentgate.SlotQuestion{{Key: "scope", Question: "哪个范围?"}},
Question: "哪个范围?"}
second, err := svc.Suspend(key, report, nil)
if err != nil {
t.Fatal(err)
}
if second.Revision <= first.Revision {
t.Fatalf("换动作必须推进 revision(让在途回答作废):%d vs %d", second.Revision, first.Revision)
}
if second.Expectation == nil || second.Expectation.IntentID != "demo.report" ||
second.Expectation.Action.ID != "demo.report" {
t.Fatalf("挂起的应是新动作:%+v", second)
}
if _, leaked := second.Slots["run_id"]; leaked {
t.Fatalf("上一个动作的槽位被继承:%+v", second.Slots)
}
if keys := questionKeys(second.Expectation.Missing); len(keys) != 1 || keys[0] != "scope" {
t.Fatalf("新动作的缺失清单:%v", keys)
}
// 旧动作的陈旧回答也作废(不得借新动作的壳补齐)。
if _, err := svc.ApplyAnswer(key, first.Revision,
Answer{Slots: map[string]string{"reason": "误触发"}}); !errors.Is(err, ErrStale) {
t.Fatalf("旧 revision 必须被拒:%v", err)
}
// 用户补齐 B:B 齐全,且槽位只有 B 自己的(没有 A 的 run_id)。
done, err := svc.ApplyAnswer(key, second.Revision, Answer{Slots: map[string]string{"scope": "华东"}})
if err != nil {
t.Fatal(err)
}
if !done.Complete || done.Session != nil {
t.Fatalf("B 应被补齐并消费会话:%+v", done)
}
if len(done.Slots) != 1 || done.Slots["scope"] != "华东" {
t.Fatalf("派发槽位混进了别的动作的值:%v", done.Slots)
}
}
// 同一动作再次挂起仍累计已抽到的槽位(缺失清单变窄不算换动作)。
func TestSuspendOfSameActionKeepsAccumulatedSlots(t *testing.T) {
svc := newService(t)
key := cancelKey()
first, err := svc.Suspend(key, cancelExpectation(), nil)
if err != nil {
t.Fatal(err)
}
// 同一意图与动作引用,只是路由面收敛了缺失清单(仍缺原因)。
narrowed := cancelExpectation()
narrowed.Missing = []intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}}
again, err := svc.Suspend(key, narrowed, map[string]string{"run_id": "R-9"})
if err != nil {
t.Fatal(err)
}
if again.Revision <= first.Revision {
t.Fatalf("再次挂起必须推进 revision:%d vs %d", again.Revision, first.Revision)
}
if again.Slots["run_id"] != "R-9" {
t.Fatalf("同一动作的累计槽位被丢弃:%+v", again.Slots)
}
if keys := questionKeys(again.Expectation.Missing); len(keys) != 1 || keys[0] != "reason" {
t.Fatalf("同一动作的缺失清单:%v", keys)
}
done, err := svc.ApplyAnswer(key, again.Revision, Answer{Slots: map[string]string{"reason": "误触发"}})
if err != nil {
t.Fatal(err)
}
if !done.Complete || done.Slots["run_id"] != "R-9" || done.Slots["reason"] != "误触发" {
t.Fatalf("同一动作补齐:%+v", done)
}
}
// 两轮部分填充:set_slot 落一个槽位,再由回答补另一个。
func TestSetSlotThenAnswerCompletes(t *testing.T) {
svc := newService(t)
+2 -3
View File
@@ -361,9 +361,8 @@ func TestIntentCancelRecordIsNotACancelTarget(t *testing.T) {
}
waitRunStatus(t, h, runID, string(core.RunCancelled))
gate.script("再取消一次", taskCancelIntent(nil))
// 第二轮是占槽位的澄清回合:等上一轮被取消的运行彻底收工再发车(忙 409 是
// 既有语义,见 postConversationTurn)。
code, out := postConversationTurn(t, srv, h, st, convID, "再取消一次")
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "再取消一次"})
if code != 200 {
t.Fatalf("第二轮取消:%d %v", code, out)
}
+21 -5
View File
@@ -132,7 +132,11 @@ func (s *Server) fuseIntentSession(key intentsession.Key,
}
answer := answerFromRecognition(pending, recognized)
if answer.Cancel {
return recognized, s.applyCancel(svc, key, pending, answer), nil
outcome, cerr := s.applyCancel(svc, key, pending, answer)
if cerr != nil {
return recognized, nil, cerr
}
return recognized, outcome, nil
}
// 本轮对同一动作已是一次完整的新识别:它就是新的指令,按它派发;
// 旧累计槽位不混进新任务(会话就此作废)。
@@ -162,11 +166,23 @@ func (s *Server) fuseIntentSession(key intentsession.Key,
}
// applyCancel 消费库层 cancel:清空挂起预期并如实确认取消(不派发业务动作)。
//
// fail-closed:除 ErrStale(会话已被推进/取消,按「没有会话语义」如实处理)
// 之外的任何存储错误一律向上传播。绝不把「清不掉挂起预期」当成「本轮没有
// 会话语义」——那一行里的运行号仍会被 sessionRunTarget 解成取消靶子,一次
// 未确认的「放弃补充」就会变成一次真的取消。
func (s *Server) applyCancel(svc IntentSessions, key intentsession.Key,
pending *intentsession.Session, answer intentsession.Answer) *intentSessionOutcome {
pending *intentsession.Session, answer intentsession.Answer) (*intentSessionOutcome, error) {
out, err := svc.ApplyAnswer(key, pending.Revision, answer)
if errors.Is(err, intentsession.ErrStale) || err != nil || out == nil || !out.Cancelled {
return nil
if errors.Is(err, intentsession.ErrStale) {
// 陈旧取消作废:如实按「没有会话语义」处理。
return nil, nil
}
if err != nil {
return nil, err
}
if out == nil || !out.Cancelled {
return nil, nil
}
action, intent := "", pending.Expectation.IntentID
if pending.Expectation.Action.ID != "" {
@@ -176,7 +192,7 @@ func (s *Server) applyCancel(svc IntentSessions, key intentsession.Key,
cancelled: true,
note: &intentSessionNote{Kind: "cancelled", Action: action, Intent: intent, Pending: false,
Reply: "已取消本次槽位补充,未执行任何动作。"},
}
}, nil
}
// answerFromRecognition 把一次识别投影成会话语义的答案:库层会话命令
+178 -121
View File
@@ -3,7 +3,7 @@ package server
import (
"context"
"encoding/json"
"fmt"
"errors"
"net/http"
"sort"
"strings"
@@ -115,105 +115,6 @@ func settle(t *testing.T, h http.Handler, out map[string]any) map[string]any {
return out
}
// 会话忙(409)是既有语义,不改:占槽位的回合(普通回合、澄清回合)在忙时如
// 实 409,意图命中的命令则不占槽位、会话忙时也能用。测试侧要解决的是「等发得
// 出去」,而不是放宽断言:上一轮的 run 行在结算提交那一刻就终态了,槽位要到收
// 尾(补锚点、发终态事件、摘执行句柄)之后才释放,机器繁忙时这段窗口会被放大,
// 于是「run 一终态就立刻发下一轮」会偶发撞上忙 409。
// postConversationTurn 发一轮消息:先等会话空闲(见 waitConversationIdle),
// 发车时若仍撞上忙(409)则在「忙拒没有留下痕迹」的前提下按 deadline 重发。
// 忙不写消息与运行,但抢槽位发生在意图会话折叠之后——被忙拒掉的轮次仍可能已经
// 消费或挂起了会话,再发一次走的就是另一条路径了;所以每次重发前先比对意图会
// 话状态:变过了就如实失败,没变过才重发。
func postConversationTurn(t *testing.T, srv *Server, h http.Handler, st *store.Store,
convID, text string) (int, map[string]any) {
t.Helper()
waitConversationIdle(t, srv, h, convID)
deadline := time.Now().Add(testRunTerminalTimeout)
for {
before := intentSessionTrace(t, st, convID)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text})
if code != http.StatusConflict {
return code, out
}
if after := intentSessionTrace(t, st, convID); after != before {
t.Fatalf("%s:忙(409)之前已改动意图会话,重发会走另一条路径:%s → %s", text, before, after)
}
if !time.Now().Before(deadline) {
t.Fatalf("%s:会话持续忙(409)超过 %v:%v", text, testRunTerminalTimeout, out)
}
time.Sleep(20 * time.Millisecond)
}
}
// waitConversationIdle 等会话真正让出槽位:既没有未终态运行,也没有仍在本地
// 执行的 run——执行句柄在槽位释放之后才摘除(runmgr 的 execute 收尾),所以
// 「没有活跃句柄」就是槽位已空的可观测证据,不用赌收尾窗口有多宽。
func waitConversationIdle(t *testing.T, srv *Server, h http.Handler, convID string) {
t.Helper()
deadline := time.Now().Add(testRunTerminalTimeout)
for time.Now().Before(deadline) {
if code, out := doJSON(t, h, "GET", "/api/conversations/"+convID, nil); code == 200 &&
!conversationHasOpenRun(out) && !conversationHasActiveRun(srv, out) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("会话 %s 在 %v 内未空闲", convID, testRunTerminalTimeout)
}
// conversationHasOpenRun 看会话里是否还有未终态运行(结算尚未提交)。
func conversationHasOpenRun(detail map[string]any) bool {
for _, run := range conversationRuns(detail) {
switch run["status"] {
case string(core.RunCompleted), string(core.RunFailed),
string(core.RunCancelled), string(core.RunInterrupted):
default:
return true
}
}
return false
}
// conversationHasActiveRun 看会话里是否还有本地在执行的 run(槽位未让出)。
func conversationHasActiveRun(srv *Server, detail map[string]any) bool {
for _, run := range conversationRuns(detail) {
if id, _ := run["id"].(string); id != "" && srv.mgr.RunActiveLocally(id) {
return true
}
}
return false
}
func conversationRuns(detail map[string]any) []map[string]any {
raw, _ := detail["runs"].([]any)
runs := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if run, ok := item.(map[string]any); ok {
runs = append(runs, run)
}
}
return runs
}
// intentSessionTrace 描述会话上当前的意图会话状态(忙拒前后各取一次比对)。
func intentSessionTrace(t *testing.T, st *store.Store, convID string) string {
t.Helper()
conv, err := st.GetConversation(store.OwnerDefault, convID)
if err != nil {
t.Fatal(err)
}
sess, err := st.GetIntentSession(store.OwnerDefault, string(conv.ProjectID), convID)
if err == store.ErrNotFound {
return "none"
}
if err != nil {
t.Fatal(err)
}
return fmt.Sprintf("rev=%d exp=%v slots=%v", sess.Revision, sess.Expectation, sess.Slots)
}
// 缺必填槽位 → 澄清并挂起;下一轮补齐 → 按同一条准入与派发路径执行。
func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
srv, st, gate, log := cancelServer(t)
@@ -223,7 +124,7 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: runIDQ}, {Key: "reason", Question: "取消原因?"}}))
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
@@ -246,7 +147,7 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
// 下一轮:仅补齐槽位(库层挂起预期命中分支的同形结果)。
answerText := "编号:R-7 原因:误触发"
gate.script(answerText, cancelAnswer(map[string]string{"run_id": "R-7", "reason": "误触发"}, nil))
code, out = postConversationTurn(t, srv, h, st, convID, answerText)
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": answerText})
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发同一动作:%d %v", code, out)
}
@@ -268,12 +169,12 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
// 两轮部分填充:先 set_slot 落一个槽位(仍缺 → 重新澄清),再由回答补齐。
func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
srv, st, gate, log := cancelServer(t)
srv, _, gate, log := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
@@ -282,7 +183,7 @@ func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
// 第二轮:库层 set_slot(只带一个槽位),仍缺原因 → 如实重新澄清。
gate.script("编号 R-7", intentgate.MatchResult{Branch: "expectation_hit", ID: "demo.run-cancel",
Commands: []intentgate.Command{{Kind: intentgate.CommandSetSlot, Name: "run_id", Value: "R-7"}}})
code, out = postConversationTurn(t, srv, h, st, convID, "编号 R-7")
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "编号 R-7"})
if code != 200 {
t.Fatalf("set_slot 轮:%d %v", code, out)
}
@@ -304,7 +205,7 @@ func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
// 第三轮:补上原因 → 派发,两个槽位都来自会话累计。
gate.script("原因:误触发", cancelAnswer(map[string]string{"reason": "误触发"}, nil))
code, out = postConversationTurn(t, srv, h, st, convID, "原因:误触发")
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "原因:误触发"})
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发:%d %v", code, out)
}
@@ -321,7 +222,7 @@ func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
@@ -329,7 +230,7 @@ func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
gate.script("算了不取消了", intentgate.MatchResult{Branch: "matched", ID: "demo.run-cancel",
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Target: "demo.run-cancel"}}})
code, out = postConversationTurn(t, srv, h, st, convID, "算了不取消了")
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "算了不取消了"})
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
@@ -354,7 +255,7 @@ func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
// 取消之后的回答不得补齐旧动作(这里识别仍不完整 → 只澄清,不派发)。
gate.script("编号:R-7", cancelAnswer(map[string]string{"run_id": "R-7"},
[]intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}}))
code, out = postConversationTurn(t, srv, h, st, convID, "编号:R-7")
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "编号:R-7"})
if code != 200 {
t.Fatalf("取消后回答轮:%d %v", code, out)
}
@@ -376,7 +277,8 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
// 第一轮:抽到 run_id,仍缺原因 → 挂起(累计槽位含 run_id)。
gate.script("取消运行 编号:R-7", cancelStart(map[string]string{"run_id": "R-7"},
[]intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}}))
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行 编号:R-7")
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消运行 编号:R-7"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
@@ -397,7 +299,7 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
// 第二轮只补了原因:过期的 run_id 不得被借来凑齐动作。
answerText := "原因:误触发"
gate.script(answerText, cancelStart(map[string]string{"reason": "误触发"}, nil))
code, out = postConversationTurn(t, srv, h, st, convID, answerText)
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": answerText})
if code != 200 {
t.Fatalf("过期后的回答轮:%d %v", code, out)
}
@@ -439,7 +341,7 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
t.Fatal(err)
}
gate.script("清理缓存", otherIntentStart("demo.purge"))
code, out = postConversationTurn(t, srv, h, st, convID, "清理缓存")
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "清理缓存"})
if code != 200 || out["command"] != "demo.purge" {
t.Fatalf("新任务应照常派发:%d %v", code, out)
}
@@ -448,6 +350,105 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
}
}
// reportSpec 是第二个动作(生成报告):必填 scope,并声明了可选 run_id。
// 声明过的键即准入会映射的键,所以上一轮继承来的运行号会在派发参数里
// 现形——这正是回归要断言的那条泄漏路径。
var reportSpec = agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","scope"],
"properties":{"scope":{"type":"string"},"run_id":{"type":"string"}}}`)}
// reportStart 是第二个动作的一次识别(缺 scope → 路由层澄清 + 挂起)。
func reportStart(slots map[string]string, missing []intentgate.SlotQuestion) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "matched", ID: "demo.report", Slots: slots, Missing: missing,
Commands: []intentgate.Command{{Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.report"}, Slots: slots}}}
}
// reportAnswer 是对第二个动作挂起预期的回答(库层 expectation_hit 同形)。
func reportAnswer(slots map[string]string) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "expectation_hit", ID: "demo.report", Slots: slots}
}
// 换动作即换任务(跨动作槽位继承的回归序列):A 抽到运行号后仍缺原因而
// 挂起 → 用户改口做另一个动作 B(B 缺 scope)→ 用户补齐 B 的 scope →
// B 派发时只带 B 自己的槽位,A 的运行号绝不跟着过去。
func TestIntentSessionNewActionDoesNotInheritOldSlots(t *testing.T) {
srv, st, gate, log := cancelServer(t)
if err := srv.Bus.RegisterBuiltin(reportSpec, "", func(_ context.Context, req cmdbus.Request) (any, error) {
log.record(req)
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
h := srv.Handler()
projectID, convID := setupConversation(t, h)
// T1:动作 A(demo.run-cancel)抽到 run_id,仍缺原因 → 挂起。
gate.script("取消运行 编号:R-7", cancelStart(map[string]string{"run_id": "R-7"},
[]intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消运行 编号:R-7"})
if code != 200 {
t.Fatalf("A 轮:%d %v", code, out)
}
settle(t, h, out)
pending := pendingIntent(t, st, projectID, convID)
if pending == nil || pending.Expectation.Action.ID != "demo.run-cancel" || pending.Slots["run_id"] != "R-7" {
t.Fatalf("A 未挂起/未累计槽位:%+v", pending)
}
// T2:用户改口做动作 B(demo.report,缺 scope)→ 新预期,不继承 A 的槽位。
scopeQ := "哪个范围?"
gate.script("生成报告", reportStart(nil, []intentgate.SlotQuestion{{Key: "scope", Question: scopeQ}}))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "生成报告"})
if code != 200 {
t.Fatalf("B 轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("槽位未就绪不得派发:%v", out)
}
if c := clarifyOf(t, out); c["command"] != "demo.report" || c["question"] != scopeQ {
t.Fatalf("B 的澄清诉求:%v", c)
}
if len(log.snapshot()) != 0 {
t.Fatalf("澄清轮不得派发:%v", log.snapshot())
}
pending = pendingIntent(t, st, projectID, convID)
if pending == nil || pending.Expectation == nil || pending.Expectation.Action.ID != "demo.report" {
t.Fatalf("B 未挂起预期:%+v", pending)
}
if _, leaked := pending.Slots["run_id"]; leaked {
t.Fatalf("A 的槽位被 B 继承:%+v", pending.Slots)
}
// T3:用户补齐 B 的 scope → B 派发,参数里只有 B 的槽位(没有 A 的 run_id)。
answerText := "范围:华东"
gate.script(answerText, reportAnswer(map[string]string{"scope": "华东"}))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": answerText})
if code != 200 || out["command"] != "demo.report" {
t.Fatalf("补齐后应派发 B:%d %v", code, out)
}
if _, asked := out["clarify"]; asked {
t.Fatalf("补齐后不应再澄清:%v", out)
}
calls := log.snapshot()
if len(calls) != 1 {
t.Fatalf("派发次数:%d", len(calls))
}
if calls[0]["scope"] != "华东" || calls[0]["text"] != answerText {
t.Fatalf("B 的派发参数:%v", calls[0])
}
if _, leaked := calls[0]["run_id"]; leaked {
t.Fatalf("A 的运行号漏进了 B 的派发参数:%v", calls[0])
}
if pendingIntent(t, st, projectID, convID) != nil {
t.Fatal("补齐后会话应被消费")
}
}
// otherIntentStart 是另一个动作的完整识别(换任务)。
func otherIntentStart(id string) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "matched", ID: id,
@@ -463,7 +464,7 @@ func TestIntentSessionIsolatesConversations(t *testing.T) {
projectB, convB := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := postConversationTurn(t, srv, h, st, convA, "取消运行")
code, out := doJSON(t, h, "POST", "/api/conversations/"+convA+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("A 会话第一轮:%d %v", code, out)
}
@@ -478,7 +479,7 @@ func TestIntentSessionIsolatesConversations(t *testing.T) {
// B 会话收到同样的回答:A 的槽位不得被借走(没有 A 的累计值)。
answer := "编号:R-7 原因:误触发"
gate.script(answer, cancelAnswer(map[string]string{"run_id": "R-7", "reason": "误触发"}, nil))
code, out = postConversationTurn(t, srv, h, st, convB, answer)
code, out = doJSON(t, h, "POST", "/api/conversations/"+convB+"/messages", map[string]any{"text": answer})
if code != 200 {
t.Fatalf("B 会话回答轮:%d %v", code, out)
}
@@ -495,19 +496,19 @@ func TestIntentSessionIsolatesConversations(t *testing.T) {
func TestIntentSessionStillRequiresHighRiskConfirmation(t *testing.T) {
risky := cancelSpec
risky.Danger = agentcommand.DangerHigh
srv, st, gate, log := cancelServerWith(t, risky)
srv, _, gate, log := cancelServerWith(t, risky)
h := srv.Handler()
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
answer := "编号:R-7 原因:误触发"
gate.script(answer, cancelAnswer(map[string]string{"run_id": "R-7", "reason": "误触发"}, nil))
code, out = postConversationTurn(t, srv, h, st, convID, answer)
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": answer})
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发到总线:%d %v", code, out)
}
@@ -526,7 +527,7 @@ func TestIntentSessionInvalidAnswerKeepsClarifying(t *testing.T) {
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}}))
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
@@ -535,7 +536,7 @@ func TestIntentSessionInvalidAnswerKeepsClarifying(t *testing.T) {
gate.script("编号:R-9", intentgate.MatchResult{Branch: "expectation_hit", ID: "demo.run-cancel",
Slots: map[string]string{"run_id": "R-9"},
Invalid: []intentgate.SlotDefect{{Key: "run_id", Reason: "unknown run", Question: "取消哪个运行?", Invalid: true}}})
code, out = postConversationTurn(t, srv, h, st, convID, "编号:R-9")
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "编号:R-9"})
if code != 200 {
t.Fatalf("非法补充轮:%d %v", code, out)
}
@@ -595,7 +596,7 @@ func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
}
run := func(t *testing.T, sessions IntentSessions) []string {
t.Helper()
srv, st, gate, log := cancelServer(t)
srv, _, gate, log := cancelServer(t)
if sessions != nil {
srv.SetIntentSessionService(sessions)
}
@@ -604,7 +605,7 @@ func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
_, convID := setupConversation(t, h)
var summaries []string
for _, text := range []string{"取消运行 编号:R-3 原因:误触发", "今天天气如何", "算了", "取消运行"} {
code, out := postConversationTurn(t, srv, h, st, convID, text)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text})
if code != 200 {
t.Fatalf("%s:%d %v", text, code, out)
}
@@ -636,6 +637,62 @@ func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
}
}
// brokenCancelStore 是「放弃补充时写库失败」的替身:其余方法照常委派给
// 真实会话服务,只有 cancel 的折叠返回非 ErrStale 的存储错误(真实写库
// 故障的注入点)。
type brokenCancelStore struct{ IntentSessions }
func (b brokenCancelStore) ApplyAnswer(key intentsession.Key, revision int,
answer intentsession.Answer) (*intentsession.Outcome, error) {
if answer.Cancel {
return nil, errors.New("clear intent session: injected store failure")
}
return b.IntentSessions.ApplyAnswer(key, revision, answer)
}
// 放弃补充时写库失败 → fail-closed:本轮报错,绝不降级成「没有会话语义」
// 去规划/派发一次取消(挂起行里的运行号仍在,会被解成取消靶子)。
func TestIntentSessionCancelFailsClosedOnStoreError(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
projectID, convID := setupConversation(t, h)
runID := liveRun(t, h, convID)
seedBoundRun(t, st, projectID, convID, runID)
srv.SetIntentSessionService(brokenCancelStore{IntentSessions: intentsession.New(st)})
// 放弃本次槽位补充(库层 cancel,指向正在挂起的 A)——写库故障就在此刻。
gate.script("算了不取消了", intentgate.MatchResult{Branch: "matched", ID: "demo.run-cancel",
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Target: "demo.run-cancel"}}})
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["cancel"]; asked {
t.Fatalf("写库失败时不得回执一次取消:%v", out)
}
if _, ran := out["run"]; ran {
t.Fatalf("写库失败时不得起新回合:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("写库失败时不得派发:%v", log.snapshot())
}
// 挂起行确实没被清掉(故障没被伪装成成功),这一轮因此更不能取消它。
if pendingIntent(t, st, projectID, convID) == nil {
t.Fatal("注入的写库失败不应真的清空挂起预期")
}
// 绝不能真的取消那个运行:给可能的误派发一点时间,再如实断言。
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
if code, d := doJSON(t, h, "GET", "/api/runs/"+runID, nil); code == 200 {
if r, _ := d["run"].(map[string]any); r["status"] == string(core.RunCancelled) {
t.Fatalf("写库失败的一轮仍然取消了运行 %s:%v", runID, r)
}
}
time.Sleep(20 * time.Millisecond)
}
}
// inertSessions 是「关闭会话语义」的替身:任何调用都如实报告没有会话。
type inertSessions struct{}