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
677 lines
26 KiB
Go
677 lines
26 KiB
Go
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
|
||
}
|