Files
Agent-System-Backend/internal/server/intent_session_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

677 lines
26 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
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"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"
)
// A1/U-A1b 会话语义的 chat 端到端(合成数据 + 现有 fakes):缺槽位动作先
// 澄清挂起,下一轮补齐后按同一条准入/派发路径执行;取消清理会话;过期会话
// 不影响任何新轮次。高危确认仍由 cmdbus 收口(会话只带槽位)。
// scriptedGate 按输入文本返回脚本化的识别结果;未命中的输入走普通回合
// (fallback)。这是库层识别的替身,不改 Stage-1 语义。
type scriptedGate struct {
mu sync.Mutex
results map[string]intentgate.MatchResult
}
func (g *scriptedGate) script(input string, res intentgate.MatchResult) {
g.mu.Lock()
defer g.mu.Unlock()
if g.results == nil {
g.results = map[string]intentgate.MatchResult{}
}
g.results[input] = res
}
func (g *scriptedGate) Recognize(_ context.Context, input string) intentgate.MatchResult {
g.mu.Lock()
defer g.mu.Unlock()
res, ok := g.results[input]
if !ok {
return intentgate.MatchResult{Branch: "fallback"}
}
return res
}
func (g *scriptedGate) Match(_ context.Context, input string) (string, string) {
res := g.Recognize(context.Background(), input)
return res.Branch, res.ID
}
// cancelStart 是「取消运行」动作的一次完整/不完整识别(run-cancel 形态)。
func cancelStart(slots map[string]string, missing []intentgate.SlotQuestion) intentgate.MatchResult {
res := intentgate.MatchResult{
Branch: "matched", ID: "demo.run-cancel", Slots: slots, Missing: missing,
Commands: []intentgate.Command{{Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.run-cancel"}, Slots: slots}},
}
if len(missing) > 0 {
res.Commands = append(res.Commands, intentgate.Command{Kind: intentgate.CommandClarify,
Question: missing[0].Question})
}
return res
}
// cancelAnswer 是「对挂起预期的回答」(库层 expectation_hit 分支的同形结果)。
func cancelAnswer(slots map[string]string, missing []intentgate.SlotQuestion) intentgate.MatchResult {
res := intentgate.MatchResult{Branch: "expectation_hit", ID: "demo.run-cancel", Slots: slots, Missing: missing}
if len(missing) == 0 {
res.Commands = []intentgate.Command{{Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.run-cancel"}, Slots: slots}}
}
return res
}
var cancelSpec = agentcommand.CommandSpec{ID: "demo.run-cancel", Title: "取消运行",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","run_id","reason"],
"properties":{"run_id":{"type":"string"},"reason":{"type":"string"}}}`)}
// cancelServer 组装带脚本化意图闸门与 demo.run-cancel 命令的服务器。
func cancelServer(t *testing.T) (*Server, *store.Store, *scriptedGate, *dispatchLog) {
t.Helper()
return cancelServerWith(t, cancelSpec)
}
// cancelServerWith 同上但命令声明可替换(同 id 只注册一次)。
func cancelServerWith(t *testing.T, spec agentcommand.CommandSpec) (*Server, *store.Store, *scriptedGate, *dispatchLog) {
t.Helper()
srv, st := newTestServer(t)
log := &dispatchLog{}
if err := srv.Bus.RegisterBuiltin(spec, "", func(_ context.Context, req cmdbus.Request) (any, error) {
log.record(req)
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
gate := &scriptedGate{}
srv.Gate = gate
return srv, st, gate, log
}
// settle 等本轮启动的回合落终态(会话槽位随即释放,便于下一轮)。
func settle(t *testing.T, h http.Handler, out map[string]any) map[string]any {
t.Helper()
if run, ok := out["run"].(map[string]any); ok {
if id, _ := run["id"].(string); id != "" {
waitRunTerminal(t, h, id, testRunTerminalTimeout)
}
}
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)
h := srv.Handler()
_, convID := setupConversation(t, h)
runIDQ := "取消哪个运行?"
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: runIDQ}, {Key: "reason", Question: "取消原因?"}}))
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
if _, executed := out["command"]; executed {
t.Fatalf("槽位未就绪不得派发业务动作:%v", out)
}
c := clarifyOf(t, out)
if c["command"] != "demo.run-cancel" || c["question"] != runIDQ {
t.Fatalf("澄清诉求:%v", c)
}
settle(t, h, out)
if len(log.snapshot()) != 0 {
t.Fatalf("澄清轮不得派发:%v", log.snapshot())
}
pending := pendingIntent(t, st, "", convID)
if pending == nil || pending.Expectation.Action.ID != "demo.run-cancel" {
t.Fatalf("未挂起预期:%+v", pending)
}
// 下一轮:仅补齐槽位(库层挂起预期命中分支的同形结果)。
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)
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发同一动作:%d %v", code, out)
}
if _, hasClarify := out["clarify"]; hasClarify {
t.Fatalf("补齐后不应再澄清:%v", out)
}
calls := log.snapshot()
if len(calls) != 1 {
t.Fatalf("处理器调用次数:%d", len(calls))
}
params := calls[0]
if params["run_id"] != "R-7" || params["reason"] != "误触发" || params["text"] != answerText {
t.Fatalf("累计槽位未进派发参数:%v", params)
}
if pendingIntent(t, st, "", convID) != nil {
t.Fatal("补齐后会话应被消费")
}
}
// 两轮部分填充:先 set_slot 落一个槽位(仍缺 → 重新澄清),再由回答补齐。
func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
srv, st, 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, "取消运行")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
// 第二轮:库层 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")
if code != 200 {
t.Fatalf("set_slot 轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("仍缺槽位不得派发:%v", out)
}
c := clarifyOf(t, out)
if c["question"] != "取消原因?" {
t.Fatalf("仍缺的提问:%v", c)
}
missing, _ := c["missing"].([]any)
if len(missing) != 1 || missing[0].(map[string]any)["key"] != "reason" {
t.Fatalf("仍缺清单:%v", c)
}
if len(log.snapshot()) != 0 {
t.Fatalf("部分填充不得派发:%v", log.snapshot())
}
// 第三轮:补上原因 → 派发,两个槽位都来自会话累计。
gate.script("原因:误触发", cancelAnswer(map[string]string{"reason": "误触发"}, nil))
code, out = postConversationTurn(t, srv, h, st, convID, "原因:误触发")
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发:%d %v", code, out)
}
calls := log.snapshot()
if len(calls) != 1 || calls[0]["run_id"] != "R-7" || calls[0]["reason"] != "误触发" {
t.Fatalf("累计槽位:%v", calls)
}
}
// 取消挂起的补充:如实确认取消,不派发任何业务动作,会话清空。
func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
srv, st, 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, "取消运行")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
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, "算了不取消了")
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("取消不得派发业务动作:%v", out)
}
note, ok := out["intent_session"].(map[string]any)
if !ok {
t.Fatalf("取消应如实回话:%v", out)
}
if note["kind"] != "cancelled" || note["action"] != "demo.run-cancel" || note["reply"] == "" || note["pending"] != false {
t.Fatalf("取消回话:%v", note)
}
if len(log.snapshot()) != 0 {
t.Fatalf("取消轮派发了动作:%v", log.snapshot())
}
if pendingIntent(t, st, "", convID) != nil {
t.Fatal("取消后仍挂起预期")
}
// 取消之后的回答不得补齐旧动作(这里识别仍不完整 → 只澄清,不派发)。
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")
if code != 200 {
t.Fatalf("取消后回答轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("取消后的回答不得补齐旧动作:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("取消后的回答派发了动作:%v", log.snapshot())
}
}
// 过期会话既不补齐旧动作,也不影响新的动作:过期的累计槽位不得被新的一轮
// 借走,新的任务照常派发。
func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
projectID, convID := setupConversation(t, h)
// 第一轮:抽到 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")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
pending := pendingIntent(t, st, projectID, convID)
if pending == nil || pending.Slots["run_id"] != "R-7" {
t.Fatalf("未挂起预期/未累计槽位:%+v", pending)
}
// 把挂起预期推到过去(等价于 30 分钟未续期后仍在库里的行)。
expired := &store.IntentExpectation{IntentID: "demo.run-cancel", ActionKind: "command",
ActionID: "demo.run-cancel", Missing: []store.IntentSlotQuestion{{Key: "reason", Question: "取消原因?"}}}
if _, err := st.PutIntentSession(store.OwnerDefault, projectID, convID, expired,
map[string]string{"run_id": "R-7"}, -time.Minute); err != nil {
t.Fatal(err)
}
// 第二轮只补了原因:过期的 run_id 不得被借来凑齐动作。
answerText := "原因:误触发"
gate.script(answerText, cancelStart(map[string]string{"reason": "误触发"}, nil))
code, out = postConversationTurn(t, srv, h, st, convID, answerText)
if code != 200 {
t.Fatalf("过期后的回答轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("过期会话不得补齐旧动作:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("过期会话派发了动作:%v", log.snapshot())
}
c := clarifyOf(t, out)
if c["command"] != "demo.run-cancel" {
t.Fatalf("澄清诉求:%v", c)
}
missing, _ := c["missing"].([]any)
foundRunID := false
for _, m := range missing {
if m.(map[string]any)["key"] == "run_id" {
foundRunID = true
}
}
if !foundRunID {
t.Fatalf("过期的累计槽位被借走了:%v", c)
}
if _, busy := out["intent_session"]; busy {
t.Fatalf("过期会话不得产生会话语义字段:%v", out)
}
if _, err := st.GetIntentSession(store.OwnerDefault, projectID, convID); err != store.ErrNotFound {
t.Fatalf("过期行未被惰性删除:%v", err)
}
// 新任务照常派发(过期会话从不干扰新轮次)。
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.purge", Title: "清理缓存",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)},
"", 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("清理缓存", otherIntentStart("demo.purge"))
code, out = postConversationTurn(t, srv, h, st, convID, "清理缓存")
if code != 200 || out["command"] != "demo.purge" {
t.Fatalf("新任务应照常派发:%d %v", code, out)
}
if calls := log.snapshot(); len(calls) != 1 || calls[0]["text"] != "清理缓存" {
t.Fatalf("新任务派发参数:%v", calls)
}
}
// otherIntentStart 是另一个动作的完整识别(换任务)。
func otherIntentStart(id string) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "matched", ID: id,
Commands: []intentgate.Command{{Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: id}}}}
}
// 别的会话/项目的挂起预期绝不互相影响(键含 project + conversation)。
func TestIntentSessionIsolatesConversations(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
projectA, convA := setupConversation(t, h)
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, "取消运行")
if code != 200 {
t.Fatalf("A 会话第一轮:%d %v", code, out)
}
settle(t, h, out)
if pendingIntent(t, st, projectA, convA) == nil {
t.Fatal("A 会话未挂起预期")
}
if pendingIntent(t, st, projectB, convB) != nil {
t.Fatal("B 会话看到了 A 的挂起预期")
}
// 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)
if code != 200 {
t.Fatalf("B 会话回答轮:%d %v", code, out)
}
settle(t, h, out)
if len(log.snapshot()) != 0 {
t.Fatalf("无挂起预期的会话不得凭别人的槽位派发:%v", log.snapshot())
}
if pendingIntent(t, st, projectA, convA) == nil {
t.Fatal("B 会话的轮次改动了 A 的挂起预期")
}
}
// 高危动作仍走 cmdbus 确认:会话补齐不绕过确认卡(处理器不执行)。
func TestIntentSessionStillRequiresHighRiskConfirmation(t *testing.T) {
risky := cancelSpec
risky.Danger = agentcommand.DangerHigh
srv, st, 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, "取消运行")
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)
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发到总线:%d %v", code, out)
}
if out["card"] == nil {
t.Fatalf("高危动作必须仍要确认卡:%v", out)
}
if len(log.snapshot()) != 0 {
t.Fatalf("确认前不得执行处理器:%v", log.snapshot())
}
}
// 非法槽位的补充照旧按非法追问:值不采纳、动作不派发。
func TestIntentSessionInvalidAnswerKeepsClarifying(t *testing.T) {
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}}))
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
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")
if code != 200 {
t.Fatalf("非法补充轮:%d %v", code, out)
}
settle(t, h, out)
if _, executed := out["command"]; executed {
t.Fatalf("非法补充不得派发:%v", out)
}
c := clarifyOf(t, out)
invalid, _ := c["invalid"].([]any)
if len(invalid) != 1 || invalid[0].(map[string]any)["reason"] != "unknown run" {
t.Fatalf("非法原因未透传:%v", c)
}
if len(log.snapshot()) != 0 {
t.Fatalf("非法补充派发了动作:%v", log.snapshot())
}
pending := pendingIntent(t, st, "", convID)
if pending == nil || pending.Slots["run_id"] != "" {
t.Fatalf("非法值不得进入会话槽位表:%+v", pending)
}
}
// 无会话时行为逐字节不变:与「会话关闭」的服务器跑同一脚本,响应(去掉
// run/message 这类每次都不同的回执)逐字节一致,且不得出现会话语义字段。
func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
script := func(gate *scriptedGate) {
// 槽位齐全:照常派发(会话层不得介入)。
gate.script("取消运行 编号:R-3 原因:误触发", cancelStart(
map[string]string{"run_id": "R-3", "reason": "误触发"}, nil))
gate.script("今天天气如何", intentgate.MatchResult{Branch: "fallback"})
gate.script("算了", intentgate.MatchResult{Branch: "matched", ID: "demo.purge",
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Target: "demo.purge"}}})
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
}
// summarize 把响应投影成与运行无关的稳定形态:顶层键、命令、澄清诉求。
summarize := func(out map[string]any) string {
keys := make([]string, 0, len(out))
for k := range out {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString(strings.Join(keys, ","))
if cmd, ok := out["command"]; ok {
raw, _ := json.Marshal(cmd)
b.WriteString("|command=" + string(raw))
}
if c, ok := out["clarify"]; ok {
raw, _ := json.Marshal(c)
b.WriteString("|clarify=" + string(raw))
}
if n, ok := out["intent_session"]; ok {
raw, _ := json.Marshal(n)
b.WriteString("|session=" + string(raw))
}
return b.String()
}
run := func(t *testing.T, sessions IntentSessions) []string {
t.Helper()
srv, st, gate, log := cancelServer(t)
if sessions != nil {
srv.SetIntentSessionService(sessions)
}
script(gate)
h := srv.Handler()
_, convID := setupConversation(t, h)
var summaries []string
for _, text := range []string{"取消运行 编号:R-3 原因:误触发", "今天天气如何", "算了", "取消运行"} {
code, out := postConversationTurn(t, srv, h, st, convID, text)
if code != 200 {
t.Fatalf("%s:%d %v", text, code, out)
}
settle(t, h, out)
summaries = append(summaries, text+" -> "+summarize(out))
}
if len(log.snapshot()) != 1 {
t.Fatalf("脚本只应派发一次完整动作:%v", log.snapshot())
}
return summaries
}
withSessions := run(t, nil)
// 关闭会话语义(替身永不挂起/折叠)后同一脚本的响应必须逐字节一致。
without := run(t, inertSessions{})
if len(withSessions) != len(without) {
t.Fatalf("轮次数不一致:%v vs %v", withSessions, without)
}
for i := range withSessions {
if withSessions[i] != without[i] {
t.Fatalf("无会话时行为发生变化:\n 有:%s\n 无:%s", withSessions[i], without[i])
}
if strings.Contains(withSessions[i], "session=") {
t.Fatalf("无会话语义的轮次带上了会话语义字段:%s", withSessions[i])
}
}
if !strings.Contains(strings.Join(withSessions, "\n"), "|clarify=") {
t.Fatalf("脚本未触发澄清,测试失去意义:%v", withSessions)
}
}
// inertSessions 是「关闭会话语义」的替身:任何调用都如实报告没有会话。
type inertSessions struct{}
func (inertSessions) Pending(intentsession.Key) (*intentsession.Session, error) { return nil, nil }
func (inertSessions) Suspend(intentsession.Key, intentsession.Expectation, map[string]string) (*intentsession.Session, error) {
return nil, nil
}
func (inertSessions) ApplyAnswer(intentsession.Key, int, intentsession.Answer) (*intentsession.Outcome, error) {
return nil, nil
}
func (inertSessions) ApplyCommand(intentsession.Key, int, string, string, string) (*intentsession.Outcome, error) {
return nil, nil
}
func (inertSessions) Drop(intentsession.Key) error { return nil }
func (inertSessions) Binding(intentsession.Key) (*intentsession.Binding, error) {
return nil, nil
}
func (inertSessions) RecordCancel(intentsession.Key, intentsession.Binding) (*intentsession.Session, error) {
return nil, nil
}
// pendingIntent 读当前会话的挂起预期(测试只读断言面;过期即视为没有)。
func pendingIntent(t *testing.T, st *store.Store, projectID, convID string) *intentsession.Session {
t.Helper()
conv, err := st.GetConversation(store.OwnerDefault, convID)
if err != nil {
t.Fatal(err)
}
if projectID == "" {
projectID = string(conv.ProjectID)
}
sess, err := intentsession.New(st).Pending(intentsession.Key{
Owner: store.OwnerDefault, Project: projectID, Conversation: convID})
if err != nil {
t.Fatal(err)
}
return sess
}