Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c6995546 |
@@ -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)
|
||||
|
||||
@@ -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 把一次识别投影成会话语义的答案:库层会话命令
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/intentsession"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// A1/U-A1b 会话语义的 chat 端到端(合成数据 + 现有 fakes):缺槽位动作先
|
||||
@@ -348,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,
|
||||
@@ -536,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{}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user