Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd051ec895 | ||
|
|
9ee752c380 | ||
|
|
c2e3104970 | ||
|
|
0fb64c488e | ||
|
|
a75783fd2d | ||
|
|
92c2b4eb12 | ||
|
|
3ab7cd71a4 |
@@ -307,6 +307,10 @@ var copyOrder = []string{
|
||||
"exec_env_requests", "exec_env_bindings",
|
||||
// U8:活动服务锁的库内标记(无外键;迁移须保留以免目标库丢掉锁根)
|
||||
"instance_guard",
|
||||
// v52 intent_session(A1/U-A1b):会话维度的挂起动作预期 + 已收集槽位
|
||||
// (无外键,纯数据表;键含 owner+project+conversation,随 conversations
|
||||
// 之后迁移即可,无需额外排序)
|
||||
"intent_session",
|
||||
}
|
||||
|
||||
// schemaSeededSingletons:schema 迁移在每个新库上预置的单行表。它们不是目标
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-native updated: cff69355f7...f2fb9ffa6d
Vendored
+1
-1
Submodule deps/agent-squad updated: 9edb6ba865...b6d87638cc
@@ -0,0 +1,469 @@
|
||||
// Package intentsession 是宿主侧的「会话内意图会话」服务(A1/U-A1b)。
|
||||
//
|
||||
// 它把一条可执行动作的预期(动作引用 + 仍缺的槽位 + 已收集的槽位)留在
|
||||
// 会话里,等用户后续轮次把槽位补齐,而不是把同一次识别丢掉重来:
|
||||
//
|
||||
// Pending 读当前挂起预期(无会话/已过期即没有,过期行读侧惰性删除)
|
||||
// Suspend 识别出「动作可执行但槽位未就绪」后挂起预期
|
||||
// ApplyAnswer 把一轮用户回答折叠进会话、推进 revision、重新判定齐全性
|
||||
// ApplyCommand 消费库层命令:set_slot/change_slot 改槽位、cancel 清预期
|
||||
// Binding 读会话当前绑定的运行上下文(A1/U-A1c:取消意图的靶子来源)
|
||||
// RecordCancel 记一次已派发的取消:清预期,留一条短期「已取消某运行」记录
|
||||
//
|
||||
// 纪律:
|
||||
// - 会话语义只携带槽位与预期,绝不携带授权:高危动作的确认仍由 cmdbus
|
||||
// 收口,会话补齐不会绕过任何确认门禁。
|
||||
// - 键是 (owner, project, conversation):两个用户、两个项目的会话在构造上
|
||||
// 互不可见。
|
||||
// - revision 单调递增:陈旧回答(期间已被别的轮次推进或取消)一律拒绝
|
||||
// (ErrStale),绝不允许旧槽位漏进新任务。
|
||||
// - 库层 Stage-1 语义不变:本服务只消费库层产出的 set_slot/change_slot/
|
||||
// cancel/expectation 命令,不改动识别分支语义。
|
||||
// - 「已取消」记录不是待补齐的动作:它不再吸收槽位、不再被回答折叠,
|
||||
// 只供紧接着的追问(如「确认取消」)如实回话(CancelRecordTTL 到期即
|
||||
// 惰性删除)。
|
||||
package intentsession
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// DefaultTTL 是挂起预期的默认存活时间(30 分钟;每次更新续期)。
|
||||
const DefaultTTL = 30 * time.Minute
|
||||
|
||||
// CancelRecordTTL 是「已取消」记录的留存时间:只够紧接着的追问读到,
|
||||
// 绝不跨会话长期驻留。
|
||||
const CancelRecordTTL = 5 * time.Minute
|
||||
|
||||
// CancelActionKind 标记会话行的预期是「一次已派发的取消」记录,而不是待补齐
|
||||
// 的动作。宿主据此不再把本行当挂起动作折叠,只把它当如实回话的依据。
|
||||
const CancelActionKind = "cancel"
|
||||
|
||||
// cancelledRunSlot 是取消记录里被取消运行号所在的槽位键(宿主不透明)。
|
||||
const cancelledRunSlot = "cancelled_run_id"
|
||||
|
||||
// runSlotKeys 是识别结果里承载运行号的槽位键(按优先级;固定序,结果确定)。
|
||||
var runSlotKeys = []string{"run_id", "runId", "run"}
|
||||
|
||||
// ErrStale 表示调用方持有的 revision 已过期(期间会话已被推进或取消)。
|
||||
// 调用方遇到它必须丢弃这次折叠,不得据此执行任何动作。
|
||||
var ErrStale = errors.New("intentsession: stale session revision")
|
||||
|
||||
// SlotValidator 按动作声明的 schema 校验一个槽位值(宿主注入:命令总线侧的
|
||||
// DECLARED schema)。返回错误即不采纳该值——非法值永远进不了会话槽位表。
|
||||
// 动作不可解析(未注册/非命令)时宿主返回 nil 表示不校验。
|
||||
type SlotValidator func(actionID, slot, value string) error
|
||||
|
||||
// Service 是意图会话服务。用 New 构造;零值不可用。
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
validate SlotValidator
|
||||
clock func() time.Time
|
||||
ttlValue time.Duration
|
||||
}
|
||||
|
||||
// New 构造意图会话服务(挂起期默认 DefaultTTL,即每次更新后 30 分钟)。
|
||||
func New(st *store.Store) *Service {
|
||||
return &Service{st: st, clock: time.Now, ttlValue: DefaultTTL}
|
||||
}
|
||||
|
||||
// SetSlotValidator 接线动作声明 schema 的槽位校验(启动期调用)。
|
||||
func (s *Service) SetSlotValidator(v SlotValidator) { s.validate = v }
|
||||
|
||||
// SetTTL 覆盖挂起期(<=0 回落 DefaultTTL;仅启动期与测试期调用)。
|
||||
func (s *Service) SetTTL(d time.Duration) {
|
||||
if d <= 0 {
|
||||
d = DefaultTTL
|
||||
}
|
||||
s.ttlValue = d
|
||||
}
|
||||
|
||||
func (s *Service) ttl() time.Duration {
|
||||
if s.ttlValue <= 0 {
|
||||
return DefaultTTL
|
||||
}
|
||||
return s.ttlValue
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
if s.clock == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return s.clock()
|
||||
}
|
||||
|
||||
// Key 是一个会话的标识。Owner 与 Conversation 必填:owner 缺失即 fail-closed,
|
||||
// 绝不会退化成跨用户共享的会话状态。
|
||||
type Key struct {
|
||||
Owner string
|
||||
Project string
|
||||
Conversation string
|
||||
}
|
||||
|
||||
func (k Key) valid() bool { return k.Owner != "" && k.Conversation != "" }
|
||||
|
||||
// Expectation 是挂起的可执行动作预期。Action 是权威动作引用(命令/工作流/
|
||||
// 小队/auto),Missing 是仍缺的槽位及建议提问。
|
||||
type Expectation struct {
|
||||
IntentID string
|
||||
Action intentgate.ActionRef
|
||||
Missing []intentgate.SlotQuestion
|
||||
Question string
|
||||
}
|
||||
|
||||
// Session 是一个会话当前的意图会话状态。
|
||||
type Session struct {
|
||||
Key Key
|
||||
// Expectation 为 nil 表示没有挂起动作(已取消或已补齐)。
|
||||
Expectation *Expectation
|
||||
// Cancelled 非 nil 表示这一行是一次已派发的取消记录(不是待补齐动作)。
|
||||
Cancelled *CancelRecord
|
||||
Slots map[string]string
|
||||
Revision int
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CancelRecord 是一次已派发的取消的短期记录:被取消的运行与其来源动作。
|
||||
type CancelRecord struct {
|
||||
RunID string
|
||||
Intent string
|
||||
Action string
|
||||
}
|
||||
|
||||
// Binding 是会话当前绑定的运行上下文——「取消」意图的靶子来源(A1/U-A1c)。
|
||||
//
|
||||
// 它只声明「这个会话当时对着哪个运行」,不是授权:派发与确认仍在宿主侧的
|
||||
// 既有取消路径上收口。
|
||||
type Binding struct {
|
||||
RunID string
|
||||
Intent string
|
||||
Action string
|
||||
// Cancelled 为真时这条绑定来自一次已派发的取消(而不是待补齐动作):
|
||||
// 宿主不应把它再当成可取消的靶子。
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// Answer 是一轮用户回答折叠后的结果(由宿主从识别结果与库层命令投影)。
|
||||
type Answer struct {
|
||||
// Slots 是本轮补上的槽位;Invalid 是本轮给出但没通过权威校验的槽位
|
||||
// (key→原因:值不采纳,仍按非法追问)。
|
||||
Slots map[string]string
|
||||
Invalid map[string]string
|
||||
// Cancel 为真时本轮是取消:不折叠任何槽位,只清空挂起预期。
|
||||
Cancel bool
|
||||
}
|
||||
|
||||
// Outcome 是一次折叠的结果。nil 结果(error==nil)表示本轮没有任何会话
|
||||
// 语义:宿主保持既有行为,不改响应。
|
||||
type Outcome struct {
|
||||
// Session 是折叠后的会话(取消或补齐被消费时为 nil)。
|
||||
Session *Session
|
||||
// Complete 表示动作已齐全:宿主可按与一次完整识别完全相同的准入与
|
||||
// 派发路径执行(确认语义仍由 cmdbus 收口)。
|
||||
Complete bool
|
||||
// Cancelled 表示用户取消了挂起的补充(不得派发任何业务动作)。
|
||||
Cancelled bool
|
||||
// Slots 是累计后的全部槽位(Complete 时用于派发)。
|
||||
Slots map[string]string
|
||||
// Remaining/Invalid 是仍缺的槽位与本轮未通过的槽位(Complete=false 时
|
||||
// 用于如实重新澄清)。
|
||||
Remaining []intentgate.SlotQuestion
|
||||
Invalid []intentgate.SlotDefect
|
||||
Question string
|
||||
}
|
||||
|
||||
// Pending 返回仍然有效的挂起预期。没有行、没有预期或已过期都返回 nil;
|
||||
// 过期行在读侧惰性删除(过期会话绝不影响新的一轮)。
|
||||
func (s *Service) Pending(key Key) (*Session, error) {
|
||||
return s.load(key)
|
||||
}
|
||||
|
||||
// Suspend 挂起一个可执行动作的预期并累计已抽到的槽位(续期)。已抽到的
|
||||
// 槽位不再计入仍缺清单(挂起即如实反映还差什么)。
|
||||
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 {
|
||||
slots = mergeSlots(cur.Slots, slots)
|
||||
}
|
||||
var missing []intentgate.SlotQuestion
|
||||
for _, q := range expectation.Missing {
|
||||
if _, ok := slots[q.Key]; !ok {
|
||||
missing = append(missing, q)
|
||||
}
|
||||
}
|
||||
stored, err := s.st.PutIntentSession(key.Owner, key.Project, key.Conversation,
|
||||
storeExpectation(Expectation{IntentID: expectation.IntentID, Action: expectation.Action,
|
||||
Missing: missing, Question: expectation.Question}), slots, s.ttl())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fromStore(key, stored), nil
|
||||
}
|
||||
|
||||
// ApplyAnswer 把一轮用户回答折叠进挂起的会话:合并槽位、推进 revision、
|
||||
// 重新判定齐全性。revision 不匹配时返回 ErrStale(不改任何状态)。
|
||||
func (s *Service) ApplyAnswer(key Key, revision int, answer Answer) (*Outcome, error) {
|
||||
if answer.Cancel {
|
||||
return s.ApplyCommand(key, revision, string(intentgate.CommandCancel), "", "")
|
||||
}
|
||||
cur, err := s.current(key, revision)
|
||||
if err != nil || cur == nil {
|
||||
return nil, err
|
||||
}
|
||||
if cur.Expectation == nil || cur.Cancelled != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if len(answer.Slots) == 0 && len(answer.Invalid) == 0 {
|
||||
return nil, nil // 本轮没有槽位信息:不消耗 revision
|
||||
}
|
||||
merged := mergeSlots(cur.Slots, s.accepted(cur, answer))
|
||||
expectation := *cur.Expectation
|
||||
remaining, invalid := s.recheck(&expectation, merged, answer)
|
||||
out := &Outcome{Slots: merged, Remaining: remaining, Invalid: invalid, Question: expectation.Question}
|
||||
if len(remaining) == 0 && len(invalid) == 0 {
|
||||
// 补齐即消费:会话状态归零,宿主按既有准入与派发路径执行。
|
||||
if err := s.st.ClearIntentSession(key.Owner, key.Project, key.Conversation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Complete = true
|
||||
return out, nil
|
||||
}
|
||||
stored, err := s.st.PutIntentSession(key.Owner, key.Project, key.Conversation,
|
||||
storeExpectation(Expectation{IntentID: expectation.IntentID, Action: expectation.Action,
|
||||
Missing: remaining, Question: expectation.Question}), merged, s.ttl())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Session = fromStore(key, stored)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ApplyCommand 消费库层会话命令:
|
||||
// - set_slot / change_slot 写槽位(动作声明可解析时按声明校验,非法值不采纳);
|
||||
// - cancel 清空挂起预期(保留已收集槽位,revision +1 让在途回答作废);
|
||||
// - 其它种类(start/clarify/chitchat/handoff…)一律忽略,如实返回 nil。
|
||||
func (s *Service) ApplyCommand(key Key, revision int, kind, name, value string) (*Outcome, error) {
|
||||
switch kind {
|
||||
case string(intentgate.CommandSetSlot), string(intentgate.CommandChangeSlot):
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return s.ApplyAnswer(key, revision, Answer{Slots: map[string]string{name: value}})
|
||||
case string(intentgate.CommandCancel):
|
||||
cur, err := s.current(key, revision)
|
||||
if err != nil || cur == nil {
|
||||
return nil, err
|
||||
}
|
||||
if cur.Expectation == nil || cur.Cancelled != nil {
|
||||
// 没有挂起动作可放弃;已取消记录更不是「待补齐的补充」,
|
||||
// 如实报告「本会话没有会话语义」,绝不假装又取消了一次。
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := s.st.ClearIntentSessionExpectation(key.Owner, key.Project, key.Conversation); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &Outcome{Cancelled: true, Slots: cur.Slots}, nil
|
||||
default:
|
||||
return nil, nil // 未知命令种类:忽略,绝不假装已消费
|
||||
}
|
||||
}
|
||||
|
||||
// Drop 清空整个会话状态(动作已派发或用户明确放弃时用;幂等)。
|
||||
func (s *Service) Drop(key Key) error {
|
||||
if !key.valid() {
|
||||
return nil
|
||||
}
|
||||
return s.st.ClearIntentSession(key.Owner, key.Project, key.Conversation)
|
||||
}
|
||||
|
||||
// Binding 返回会话当前绑定的运行上下文:
|
||||
// - 挂起预期里已经抽到的运行号(连同其意图与动作);
|
||||
// - 或上一次取消留下的短期记录(Cancelled=true)。
|
||||
//
|
||||
// 没有绑定(无会话/已过期/抽到的槽位里没有运行号)一律返回 nil:宿主据此
|
||||
// 澄清,绝不臆造一个靶子。读取失败向上传播,宿主一律 fail-closed。
|
||||
func (s *Service) Binding(key Key) (*Binding, error) {
|
||||
cur, err := s.load(key)
|
||||
if err != nil || cur == nil || cur.Expectation == nil {
|
||||
return nil, err
|
||||
}
|
||||
if cur.Cancelled != nil {
|
||||
return &Binding{RunID: cur.Cancelled.RunID, Intent: cur.Cancelled.Intent,
|
||||
Action: cur.Cancelled.Action, Cancelled: true}, nil
|
||||
}
|
||||
runID := runIDOf(cur.Slots)
|
||||
if runID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &Binding{RunID: runID, Intent: cur.Expectation.IntentID, Action: cur.Expectation.Action.ID}, nil
|
||||
}
|
||||
|
||||
// RecordCancel 记录一次已派发的取消:清空挂起预期(连带作废在途回答,
|
||||
// revision +1),留下一条短期可读的「已取消某运行」记录,供紧接着的追问
|
||||
// (如「确认取消」)如实回话。没有运行号或键不完整即不记录(fail-closed)。
|
||||
func (s *Service) RecordCancel(key Key, b Binding) (*Session, error) {
|
||||
if !key.valid() || strings.TrimSpace(b.RunID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
slots := map[string]string{cancelledRunSlot: strings.TrimSpace(b.RunID)}
|
||||
stored, err := s.st.PutIntentSession(key.Owner, key.Project, key.Conversation,
|
||||
storeExpectation(Expectation{IntentID: b.Intent,
|
||||
Action: intentgate.ActionRef{Kind: CancelActionKind, ID: b.Action}}),
|
||||
slots, CancelRecordTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fromStore(key, stored), nil
|
||||
}
|
||||
|
||||
// current 读出会话并校验 revision:过期行惰性删除返回 nil;revision 不匹配
|
||||
// 返回 ErrStale。
|
||||
func (s *Service) current(key Key, revision int) (*Session, error) {
|
||||
cur, err := s.load(key)
|
||||
if err != nil || cur == nil {
|
||||
return nil, err
|
||||
}
|
||||
if revision > 0 && revision != cur.Revision {
|
||||
return nil, ErrStale
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
// load 读出会话;过期行惰性删除后返回 nil。
|
||||
func (s *Service) load(key Key) (*Session, error) {
|
||||
if !key.valid() {
|
||||
return nil, nil
|
||||
}
|
||||
cur, err := s.st.GetIntentSession(key.Owner, key.Project, key.Conversation)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.live(cur) {
|
||||
if err := s.st.ClearIntentSession(key.Owner, key.Project, key.Conversation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return fromStore(key, cur), nil
|
||||
}
|
||||
|
||||
// live 报告会话是否仍在有效期内。没有挂起预期的行(已取消/已补齐)对宿主
|
||||
// 等于「没有会话」。
|
||||
func (s *Service) live(cur store.IntentSession) bool {
|
||||
if cur.Expectation == nil {
|
||||
return false
|
||||
}
|
||||
if cur.ExpiresAt.IsZero() {
|
||||
return true
|
||||
}
|
||||
return s.now().Before(cur.ExpiresAt)
|
||||
}
|
||||
|
||||
// recheck 折叠后重算仍缺的槽位与本轮未通过校验的槽位(确定性顺序:沿用挂起
|
||||
// 时的缺失序;未通过的槽位保留其原提问并带上权威原因)。
|
||||
func (s *Service) recheck(expectation *Expectation, slots map[string]string, answer Answer) ([]intentgate.SlotQuestion, []intentgate.SlotDefect) {
|
||||
var remaining []intentgate.SlotQuestion
|
||||
var invalid []intentgate.SlotDefect
|
||||
for _, q := range expectation.Missing {
|
||||
if reason, bad := answer.Invalid[q.Key]; bad {
|
||||
invalid = append(invalid, intentgate.SlotDefect{Key: q.Key, Reason: reason, Question: q.Question, Invalid: true})
|
||||
continue
|
||||
}
|
||||
if _, ok := slots[q.Key]; !ok {
|
||||
remaining = append(remaining, q)
|
||||
}
|
||||
}
|
||||
return remaining, invalid
|
||||
}
|
||||
|
||||
// accepted 采纳本轮通过校验的槽位:非法值不采纳(仍按非法追问),其余按
|
||||
// 动作声明校验(宿主注入;未接线即不校验)。
|
||||
func (s *Service) accepted(cur *Session, answer Answer) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range answer.Slots {
|
||||
if _, bad := answer.Invalid[k]; bad {
|
||||
continue
|
||||
}
|
||||
if s.validate != nil && cur.Expectation != nil && cur.Expectation.Action.ID != "" {
|
||||
if err := s.validate(cur.Expectation.Action.ID, k, v); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// missingOf 投影到存储形态(缺失槽位表)。
|
||||
func missingOf(list []intentgate.SlotQuestion) []store.IntentSlotQuestion {
|
||||
out := make([]store.IntentSlotQuestion, 0, len(list))
|
||||
for _, q := range list {
|
||||
out = append(out, store.IntentSlotQuestion{Key: q.Key, Question: q.Question})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// storeExpectation 投影到存储形态。
|
||||
func storeExpectation(e Expectation) *store.IntentExpectation {
|
||||
return &store.IntentExpectation{
|
||||
IntentID: e.IntentID, ActionKind: e.Action.Kind, ActionID: e.Action.ID,
|
||||
Missing: missingOf(e.Missing), Question: e.Question,
|
||||
}
|
||||
}
|
||||
|
||||
// fromStore 从存储行投影出宿主视图。
|
||||
func fromStore(key Key, cur store.IntentSession) *Session {
|
||||
out := &Session{Key: key, Slots: cur.Slots, Revision: cur.Revision, ExpiresAt: cur.ExpiresAt}
|
||||
if cur.Expectation != nil {
|
||||
exp := &Expectation{IntentID: cur.Expectation.IntentID, Question: cur.Expectation.Question,
|
||||
Action: intentgate.ActionRef{Kind: cur.Expectation.ActionKind, ID: cur.Expectation.ActionID}}
|
||||
for _, q := range cur.Expectation.Missing {
|
||||
exp.Missing = append(exp.Missing, intentgate.SlotQuestion{Key: q.Key, Question: q.Question})
|
||||
}
|
||||
out.Expectation = exp
|
||||
if exp.Action.Kind == CancelActionKind {
|
||||
out.Cancelled = &CancelRecord{RunID: strings.TrimSpace(cur.Slots[cancelledRunSlot]),
|
||||
Intent: exp.IntentID, Action: exp.Action.ID}
|
||||
}
|
||||
}
|
||||
if out.Slots == nil {
|
||||
out.Slots = map[string]string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runIDOf 从槽位表里取运行号(固定键序,结果与 map 迭代序无关;无则空)。
|
||||
func runIDOf(slots map[string]string) string {
|
||||
for _, key := range runSlotKeys {
|
||||
if v := strings.TrimSpace(slots[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mergeSlots 合并两组槽位(后者覆盖前者,返回新表:不改调用方的 map)。
|
||||
func mergeSlots(base, add map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range add {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package intentsession
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// A1/U-A1b:会话内意图会话。合成数据 + 嵌入式存储(PG 档由 store 包双档
|
||||
// 测试覆盖同一张表)。命令动作(run-cancel 形态)先行,取消路由归 U-A1c。
|
||||
|
||||
func newService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "intent-session.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return New(st)
|
||||
}
|
||||
|
||||
func cancelKey() Key { return Key{Owner: "alice", Project: "prj_1", Conversation: "cnv_1"} }
|
||||
|
||||
// cancelExpectation 是首个动作(取消运行):动作引用 + 仍缺槽位 + 提问。
|
||||
func cancelExpectation() Expectation {
|
||||
return Expectation{
|
||||
IntentID: "demo.run-cancel",
|
||||
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.run-cancel"},
|
||||
Missing: []intentgate.SlotQuestion{
|
||||
{Key: "run_id", Question: "取消哪个运行?"},
|
||||
{Key: "reason", Question: "取消原因?"},
|
||||
},
|
||||
Question: "取消哪个运行?",
|
||||
}
|
||||
}
|
||||
|
||||
func questionKeys(list []intentgate.SlotQuestion) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, q := range list {
|
||||
out = append(out, q.Key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 无会话时 Pending 就是「没有」,且不产生任何行。
|
||||
func TestPendingWithoutSession(t *testing.T) {
|
||||
svc := newService(t)
|
||||
got, err := svc.Pending(cancelKey())
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("无会话时应为空:%v %v", got, err)
|
||||
}
|
||||
// 归属不完整即 fail-closed(不会退化成跨用户共享状态)。
|
||||
if got, err := svc.Pending(Key{Conversation: "cnv_1"}); err != nil || got != nil {
|
||||
t.Fatalf("无 owner 的键必须被拒:%v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺槽位动作 → 挂起 → 回答补齐:齐全即消费会话(宿主按既有准入派发)。
|
||||
func TestSuspendThenAnswerCompletes(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
suspended, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if suspended.Revision != 1 || suspended.Expectation == nil || suspended.Expectation.Action.ID != "demo.run-cancel" {
|
||||
t.Fatalf("挂起结果:%+v", suspended)
|
||||
}
|
||||
if keys := questionKeys(suspended.Expectation.Missing); len(keys) != 1 || keys[0] != "reason" {
|
||||
t.Fatalf("已抽到的槽位不应再算缺失:%v", keys)
|
||||
}
|
||||
if !suspended.ExpiresAt.After(time.Now()) {
|
||||
t.Fatalf("到期时间未设:%v", suspended.ExpiresAt)
|
||||
}
|
||||
|
||||
// 部分回答:仍缺原因 → 仍挂起、revision 递增、到期时间续期。
|
||||
partial, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"run_id": "R-2"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if partial.Complete || partial.Cancelled {
|
||||
t.Fatalf("部分回答不得视为完成:%+v", partial)
|
||||
}
|
||||
if keys := questionKeys(partial.Remaining); len(keys) != 1 || keys[0] != "reason" {
|
||||
t.Fatalf("剩余缺失:%v", keys)
|
||||
}
|
||||
if partial.Session.Revision != 2 || !partial.Session.ExpiresAt.After(suspended.ExpiresAt) {
|
||||
t.Fatalf("revision/续期:%+v", partial.Session)
|
||||
}
|
||||
|
||||
// 补齐最后一个:Complete=true,会话被消费。
|
||||
done, err := svc.ApplyAnswer(key, partial.Session.Revision, Answer{Slots: map[string]string{"reason": "误触发"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !done.Complete || done.Session != nil {
|
||||
t.Fatalf("补齐应完成并消费会话:%+v", done)
|
||||
}
|
||||
if done.Slots["run_id"] != "R-2" || done.Slots["reason"] != "误触发" {
|
||||
t.Fatalf("累计槽位:%v", done.Slots)
|
||||
}
|
||||
if got, err := svc.Pending(key); err != nil || got != nil {
|
||||
t.Fatalf("完成后仍挂起:%v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 两轮部分填充:set_slot 落一个槽位,再由回答补另一个。
|
||||
func TestSetSlotThenAnswerCompletes(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
suspended, err := svc.Suspend(key, cancelExpectation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandSetSlot), "run_id", "R-7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if set.Complete || set.Session.Slots["run_id"] != "R-7" || set.Session.Revision != 2 {
|
||||
t.Fatalf("set_slot 后:%+v", set)
|
||||
}
|
||||
if keys := questionKeys(set.Remaining); len(keys) != 1 || keys[0] != "reason" {
|
||||
t.Fatalf("剩余缺失:%v", keys)
|
||||
}
|
||||
change, err := svc.ApplyCommand(key, set.Session.Revision, string(intentgate.CommandChangeSlot), "run_id", "R-8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if change.Session.Slots["run_id"] != "R-8" {
|
||||
t.Fatalf("change_slot 未改写:%+v", change.Session)
|
||||
}
|
||||
done, err := svc.ApplyAnswer(key, change.Session.Revision, Answer{Slots: map[string]string{"reason": "重复提交"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !done.Complete || done.Slots["run_id"] != "R-8" || done.Slots["reason"] != "重复提交" {
|
||||
t.Fatalf("两轮补齐:%+v", done)
|
||||
}
|
||||
}
|
||||
|
||||
// 取消清空挂起预期:确认取消、不假装成功,陈旧回答也作废。
|
||||
func TestCancelClearsPendingExpectation(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
suspended, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandCancel), "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.Cancelled || out.Complete || out.Session != nil {
|
||||
t.Fatalf("取消结果:%+v", out)
|
||||
}
|
||||
if got, err := svc.Pending(key); err != nil || got != nil {
|
||||
t.Fatalf("取消后仍有挂起预期:%v %v", got, err)
|
||||
}
|
||||
if _, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"reason": "误触发"}}); err != nil {
|
||||
t.Fatalf("取消后不应再有会话可折叠:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 陈旧回答一律拒绝:期间 revision 已被别的轮次推进过。
|
||||
func TestStaleRevisionRejected(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
suspended, err := svc.Suspend(key, cancelExpectation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandSetSlot), "run_id", "R-7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"reason": "旧回答"}}); !errors.Is(err, ErrStale) {
|
||||
t.Fatalf("陈旧回答必须被拒:%v", err)
|
||||
}
|
||||
if _, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandCancel), "", ""); !errors.Is(err, ErrStale) {
|
||||
t.Fatalf("陈旧取消必须被拒:%v", err)
|
||||
}
|
||||
// 状态未被陈旧回答改动:仍是推进后的那一版。
|
||||
cur, err := svc.Pending(key)
|
||||
if err != nil || cur.Revision != moved.Session.Revision || cur.Slots["run_id"] != "R-7" || cur.Slots["reason"] != "" {
|
||||
t.Fatalf("陈旧回答改动了状态:%+v %v", cur, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 过期会话既不补齐旧动作,也不影响新的挂起:读侧惰性删除过期行。
|
||||
func TestExpiredSessionNeverCompletesOldAction(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
suspended, err := svc.Suspend(key, cancelExpectation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 时钟前移越过到期时间:过期会话对任何新轮次都不存在。
|
||||
svc.clock = func() time.Time { return suspended.ExpiresAt.Add(time.Second) }
|
||||
|
||||
got, err := svc.Pending(key)
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("过期会话仍可见:%v %v", got, err)
|
||||
}
|
||||
if out, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"run_id": "R-1", "reason": "误触发"}}); err != nil || out != nil {
|
||||
t.Fatalf("过期回答不得补齐旧动作:%+v %v", out, err)
|
||||
}
|
||||
// 过期行已删除 → 新任务重新挂起时从干净状态开始。
|
||||
fresh, err := svc.Suspend(key, Expectation{IntentID: "demo.purge",
|
||||
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.purge"},
|
||||
Missing: []intentgate.SlotQuestion{{Key: "target", Question: "清理哪个对象?"}},
|
||||
Question: "清理哪个对象?"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fresh.Revision != 1 || fresh.Slots["run_id"] != "" {
|
||||
t.Fatalf("新挂起被旧会话污染:%+v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// 两个用户、两个项目在构造上互不可见。
|
||||
func TestSessionsAreIsolatedByOwnerAndProject(t *testing.T) {
|
||||
svc := newService(t)
|
||||
alice := Key{Owner: "alice", Project: "prj_1", Conversation: "cnv_1"}
|
||||
bob := Key{Owner: "bob", Project: "prj_1", Conversation: "cnv_1"}
|
||||
other := Key{Owner: "alice", Project: "prj_2", Conversation: "cnv_1"}
|
||||
otherConv := Key{Owner: "alice", Project: "prj_1", Conversation: "cnv_2"}
|
||||
|
||||
suspended, err := svc.Suspend(alice, cancelExpectation(), map[string]string{"run_id": "R-alice"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []Key{bob, other, otherConv} {
|
||||
got, err := svc.Pending(key)
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("%+v 读到了别人的会话:%v %v", key, got, err)
|
||||
}
|
||||
// 陈旧的答案也绝不命中别人的会话。
|
||||
out, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"run_id": "R-x", "reason": "x"}})
|
||||
if err != nil || out != nil {
|
||||
t.Fatalf("%+v 的回答折叠进了别人的会话:%+v %v", key, out, err)
|
||||
}
|
||||
if err := svc.Drop(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cur, err := svc.Pending(alice)
|
||||
if err != nil || cur == nil || cur.Slots["run_id"] != "R-alice" || cur.Revision != 1 {
|
||||
t.Fatalf("属主会话被影响:%+v %v", cur, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 槽位值按动作声明 schema 校验:非法值不采纳(仍按缺失追问),
|
||||
// 合法值正常折叠。
|
||||
func TestAnswerSlotsValidatedAgainstDeclaredSchema(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
var validated []string
|
||||
svc.SetSlotValidator(func(actionID, slot, value string) error {
|
||||
if actionID != "demo.run-cancel" || slot != "count" {
|
||||
return nil
|
||||
}
|
||||
validated = append(validated, value)
|
||||
if value == "abc" {
|
||||
return errors.New("不是整数")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
expectation := Expectation{IntentID: "demo.run-cancel",
|
||||
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.run-cancel"},
|
||||
Missing: []intentgate.SlotQuestion{{Key: "count", Question: "几次?"}, {Key: "reason", Question: "原因?"}},
|
||||
Question: "几次?"}
|
||||
suspended, err := svc.Suspend(key, expectation, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"count": "abc"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bad.Complete || bad.Session.Slots["count"] != "" {
|
||||
t.Fatalf("非法值被采纳:%+v", bad.Session)
|
||||
}
|
||||
if keys := questionKeys(bad.Remaining); len(keys) != 2 {
|
||||
t.Fatalf("非法值应仍按缺失追问:%v", keys)
|
||||
}
|
||||
if len(validated) != 1 || validated[0] != "abc" {
|
||||
t.Fatalf("声明校验未生效:%v", validated)
|
||||
}
|
||||
good, err := svc.ApplyAnswer(key, bad.Session.Revision, Answer{Slots: map[string]string{"count": "3", "reason": "误触发"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !good.Complete || good.Slots["count"] != "3" {
|
||||
t.Fatalf("合法值未补齐:%+v", good)
|
||||
}
|
||||
}
|
||||
|
||||
// 本轮给出但没通过权威校验的值:如实按非法追问(值不采纳)。
|
||||
func TestInvalidAnswerIsReportedNotAdopted(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
suspended, err := svc.Suspend(key, Expectation{IntentID: "demo.run-cancel",
|
||||
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.run-cancel"},
|
||||
Missing: []intentgate.SlotQuestion{{Key: "mode", Question: "要哪种模式?"}},
|
||||
Question: "要哪种模式?"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := svc.ApplyAnswer(key, suspended.Revision, Answer{
|
||||
Slots: map[string]string{"mode": "随手写"},
|
||||
Invalid: map[string]string{"mode": "value not in enum"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Complete || out.Session.Slots["mode"] != "" {
|
||||
t.Fatalf("非法值被采纳:%+v", out)
|
||||
}
|
||||
if len(out.Invalid) != 1 || out.Invalid[0].Key != "mode" || out.Invalid[0].Reason != "value not in enum" {
|
||||
t.Fatalf("非法原因未透传:%+v", out.Invalid)
|
||||
}
|
||||
if len(out.Remaining) != 0 {
|
||||
t.Fatalf("非法不应重复计入缺失:%+v", out.Remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// 未知命令种类如实忽略:不改状态、不假装已消费。
|
||||
func TestUnknownCommandsAreIgnored(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
suspended, err := svc.Suspend(key, cancelExpectation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, kind := range []string{string(intentgate.CommandStart), string(intentgate.CommandClarify),
|
||||
string(intentgate.CommandChitchat), string(intentgate.CommandHandoff), "made-up"} {
|
||||
out, err := svc.ApplyCommand(key, suspended.Revision, kind, "run_id", "R-9")
|
||||
if err != nil || out != nil {
|
||||
t.Fatalf("%s 不得被当成会话命令:%+v %v", kind, out, err)
|
||||
}
|
||||
}
|
||||
cur, err := svc.Pending(key)
|
||||
if err != nil || cur.Revision != suspended.Revision || cur.Slots["run_id"] != "" {
|
||||
t.Fatalf("未知命令改动了状态:%+v %v", cur, err)
|
||||
}
|
||||
// 没有槽位名时同样忽略(不写入空键)。
|
||||
if out, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandSetSlot), " ", "R-9"); err != nil || out != nil {
|
||||
t.Fatalf("空槽位名必须被忽略:%+v %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop 幂等清空;之后重新挂起从 revision 1 开始。
|
||||
func TestDropIsIdempotent(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
if _, err := svc.Suspend(key, cancelExpectation(), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := svc.Drop(key); err != nil {
|
||||
t.Fatalf("drop %d:%v", i, err)
|
||||
}
|
||||
}
|
||||
if err := svc.Drop(Key{Conversation: "cnv_1"}); err != nil {
|
||||
t.Fatalf("不完整的键必须安全无操作:%v", err)
|
||||
}
|
||||
again, err := svc.Suspend(key, cancelExpectation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.Revision != 1 || again.Slots["run_id"] != "" {
|
||||
t.Fatalf("drop 后重新挂起:%+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- A1/U-A1c:取消的靶子来源(Binding)与已取消记录(RecordCancel)----
|
||||
|
||||
// 挂起预期里已经抽到的运行号就是取消的靶子;连同意图与动作一并透出。
|
||||
func TestBindingComesFromSuspendedSlots(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
// 没有会话 → 没有靶子(绝不臆造)。
|
||||
if got, err := svc.Binding(key); err != nil || got != nil {
|
||||
t.Fatalf("无会话时必须有 binding:%v %v", got, err)
|
||||
}
|
||||
// 槽位里没有运行号 → 同样没有靶子。
|
||||
if _, err := svc.Suspend(key, cancelExpectation(), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := svc.Binding(key); err != nil || got != nil {
|
||||
t.Fatalf("无运行号时必须没有靶子:%v %v", got, err)
|
||||
}
|
||||
// 抽到运行号 → 靶子就绪(取消意图据此可无澄清地派发)。
|
||||
svc.Drop(key)
|
||||
if _, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-7"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := svc.Binding(key)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("有运行号时必须解出靶子:%v %v", got, err)
|
||||
}
|
||||
if got.RunID != "R-7" || got.Intent != "demo.run-cancel" || got.Action != "demo.run-cancel" || got.Cancelled {
|
||||
t.Fatalf("靶子上下文:%+v", got)
|
||||
}
|
||||
// runId/run 两种写法同样识别(键序固定,结果确定)。
|
||||
svc.Drop(key)
|
||||
for _, slot := range []string{"runId", "run"} {
|
||||
if _, err := svc.Suspend(key, cancelExpectation(), map[string]string{slot: "R-8", "reason": "误触发"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := svc.Binding(key); got == nil || got.RunID != "R-8" {
|
||||
t.Fatalf("槽位 %s 未被识别为运行号:%+v", slot, got)
|
||||
}
|
||||
svc.Drop(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 已派发的取消:清挂起预期 + 留短期记录;记录不可再被回答折叠,也不作靶子。
|
||||
func TestRecordCancelClearsExpectationAndKeepsShortLivedRecord(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
suspended, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-7"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorded, err := svc.RecordCancel(key, Binding{RunID: "R-7", Intent: "demo.task-cancel", Action: "demo.run-cancel"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recorded == nil || recorded.Cancelled == nil || recorded.Cancelled.RunID != "R-7" {
|
||||
t.Fatalf("取消记录:%+v", recorded)
|
||||
}
|
||||
if recorded.Cancelled.Intent != "demo.task-cancel" || recorded.Cancelled.Action != "demo.run-cancel" {
|
||||
t.Fatalf("取消记录上下文:%+v", recorded.Cancelled)
|
||||
}
|
||||
if recorded.Revision <= suspended.Revision {
|
||||
t.Fatalf("取消必须推进 revision(让在途回答作废):%d vs %d", recorded.Revision, suspended.Revision)
|
||||
}
|
||||
if len(recorded.Expectation.Missing) != 0 || recorded.Slots["run_id"] != "" {
|
||||
t.Fatalf("取消后不得还留着待补齐的槽位/缺失:%+v", recorded)
|
||||
}
|
||||
// 记录仍可读:宿主据此如实回答「确认取消」这类追问。
|
||||
got, err := svc.Binding(key)
|
||||
if err != nil || got == nil || got.RunID != "R-7" || !got.Cancelled {
|
||||
t.Fatalf("取消记录应可读且标记为已取消:%+v %v", got, err)
|
||||
}
|
||||
// 取消记录不是待补齐的动作:回答与 cancel 命令都不再折叠进它。
|
||||
if out, err := svc.ApplyAnswer(key, recorded.Revision, Answer{Slots: map[string]string{"reason": "误触发"}}); err != nil || out != nil {
|
||||
t.Fatalf("取消记录不得吸收回答:%+v %v", out, err)
|
||||
}
|
||||
if out, err := svc.ApplyCommand(key, recorded.Revision, string(intentgate.CommandCancel), "", ""); err != nil || out != nil {
|
||||
t.Fatalf("取消记录不得再被取消一次:%+v %v", out, err)
|
||||
}
|
||||
if cur, err := svc.Pending(key); err != nil || cur == nil || cur.Cancelled == nil {
|
||||
t.Fatalf("取消记录不得被当成挂起动作改写:%+v %v", cur, err)
|
||||
}
|
||||
// 记录短期留存:越过 CancelRecordTTL 后对任何新轮次都不存在。
|
||||
svc.clock = func() time.Time { return recorded.ExpiresAt.Add(time.Second) }
|
||||
if got, err := svc.Binding(key); err != nil || got != nil {
|
||||
t.Fatalf("过期记录仍可见:%+v %v", got, err)
|
||||
}
|
||||
if _, err := svc.Pending(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.st.GetIntentSession(key.Owner, key.Project, key.Conversation); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("过期行未被惰性删除:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺键或没有运行号时如实不记录(fail-closed,绝不写半条取消记录)。
|
||||
func TestRecordCancelIsFailClosed(t *testing.T) {
|
||||
svc := newService(t)
|
||||
key := cancelKey()
|
||||
if got, err := svc.RecordCancel(Key{Conversation: "cnv_1"}, Binding{RunID: "R-7"}); err != nil || got != nil {
|
||||
t.Fatalf("缺 owner 的键必须无操作:%+v %v", got, err)
|
||||
}
|
||||
if got, err := svc.RecordCancel(key, Binding{RunID: " "}); err != nil || got != nil {
|
||||
t.Fatalf("没有运行号必须无操作:%+v %v", got, err)
|
||||
}
|
||||
if got, err := svc.Binding(key); err != nil || got != nil {
|
||||
t.Fatalf("无操作不得留下任何行:%+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 两个用户的绑定互不可见:别人的运行号绝不会成为本会话的靶子。
|
||||
func TestBindingIsIsolatedByOwner(t *testing.T) {
|
||||
svc := newService(t)
|
||||
alice := Key{Owner: "alice", Project: "prj_1", Conversation: "cnv_1"}
|
||||
bob := Key{Owner: "bob", Project: "prj_1", Conversation: "cnv_1"}
|
||||
if _, err := svc.Suspend(alice, cancelExpectation(), map[string]string{"run_id": "R-alice"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.RecordCancel(bob, Binding{RunID: "R-bob"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := svc.Binding(bob); got == nil || got.RunID != "R-bob" {
|
||||
t.Fatalf("bob 的绑定:%+v", got)
|
||||
}
|
||||
if got, _ := svc.Binding(alice); got == nil || got.RunID != "R-alice" || got.Cancelled {
|
||||
t.Fatalf("alice 的绑定被污染:%+v", got)
|
||||
}
|
||||
}
|
||||
+122
-12
@@ -18,9 +18,18 @@
|
||||
// 当前版本的已知边界(如实记录,不静默):
|
||||
// - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项);
|
||||
// 本版本调用方按普通回合处理并保留 Decision 供审计。
|
||||
// - SetSlot/ChangeSlot/Cancel/Handoff 由库层产出,但 Backend 尚无会话槽位
|
||||
// 状态机与语义层:它们记入 Unsupported 并回落普通回合,绝不假装已支持
|
||||
// (例如用户说「取消」时不会把同一句话里的 Start 当成业务动作派发)。
|
||||
// - Handoff 仍由宿主不消费,记 Unsupported 并回落普通回合。
|
||||
// - Cancel 已是宿主可执行的既有动作(A1/U-A1c):本包只解出「取消哪一个
|
||||
// 运行」——原文点名的运行号优先,其次当前会话绑定动作的运行上下文
|
||||
// (Env.SessionRun,宿主从意图会话注入)。解不出唯一靶子时 Decision
|
||||
// 如实留空 RunID 并写明原因,由宿主澄清,绝不替用户猜一个。
|
||||
// 本包不派发取消、也不认识取消的实现(收件箱/命令总线的确认语义都在
|
||||
// 宿主侧),更不自动确认。
|
||||
// - SetSlot/ChangeSlot 已被宿主消费,但消费的语义不在本包:宿主 chat 入口
|
||||
// 把它们折叠进会话里的挂起预期(internal/intentsession 的会话状态机,
|
||||
// A1/U-A1b)——落槽与改槽是「同一动作的补充说明」,不是可派发的动作。
|
||||
// 本包仍不把它们当动作派发:命中即记 Unsupported 并回落普通回合,
|
||||
// 同时绝不把同一句话里的 Start 当作业务动作派发。
|
||||
package route
|
||||
|
||||
import (
|
||||
@@ -38,8 +47,44 @@ const (
|
||||
KindWorkflow Kind = "workflow"
|
||||
KindSquad Kind = "squad"
|
||||
KindTurn Kind = "turn"
|
||||
// KindCancel 是「取消」意图的裁决:取消是既有动作(取消一个运行),
|
||||
// 不是可以顺带启动的动作。靶子解出与否都在 Decision.Cancel 里,
|
||||
// 派发与确认由宿主按既有取消路径完成。
|
||||
KindCancel Kind = "cancel"
|
||||
)
|
||||
|
||||
// CancelSource 说明取消靶子的来源(宿主据此如实回话;不是授权凭据)。
|
||||
type CancelSource string
|
||||
|
||||
const (
|
||||
// CancelSourceExplicit:用户这一轮点名了运行(命令值或 run_id 槽位)。
|
||||
CancelSourceExplicit CancelSource = "explicit"
|
||||
// CancelSourceSession:当前会话绑定动作的运行上下文(意图会话)。
|
||||
CancelSourceSession CancelSource = "session"
|
||||
)
|
||||
|
||||
// CancelTarget 是「取消」的裁决靶子。
|
||||
//
|
||||
// 纪律:RunID 为空就是「没有唯一靶子」——宿主必须如实澄清,绝不猜一个
|
||||
// 「大概是它」;带靶子的裁决也只声明「要取消谁」,不代表已获授权。
|
||||
type CancelTarget struct {
|
||||
// RunID 是要取消的运行(空 = 待宿主澄清)。
|
||||
RunID string
|
||||
Source CancelSource
|
||||
// Intent/Action 是靶子的来源上下文(会话绑定的意图与动作;审计与回话用)。
|
||||
Intent string
|
||||
Action string
|
||||
// Candidates 是库层随取消命令给出的候选(如有):如实透出,不做取舍。
|
||||
Candidates []string
|
||||
}
|
||||
|
||||
// SessionRun 是会话(意图会话)里绑定的运行上下文,由宿主注入给 Env。
|
||||
type SessionRun struct {
|
||||
RunID string
|
||||
Intent string
|
||||
Action string
|
||||
}
|
||||
|
||||
// Clarification 是「业务动作未派发,需要向用户澄清」的诉求。
|
||||
// 槽位缺失与非法都带结构化原因(key + reason + question),
|
||||
// 宿主据此渲染澄清话术或转成回合提示,不再自行猜测。
|
||||
@@ -62,8 +107,10 @@ type Decision struct {
|
||||
Missing []intentgate.SlotQuestion
|
||||
Invalid []intentgate.SlotDefect
|
||||
Risk string // 权威意图声明的风险档(low/medium/high)
|
||||
Reason string // 回落 turn 的原因(显式声明不可解析、准入未过等)
|
||||
StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用)
|
||||
Reason string // 回落 turn 的原因(显式声明不可解析、准入未过、取消无唯一靶子等)
|
||||
StartFound bool // 本轮是否会启动动作(runmgr 转轨判定用;取消裁决下恒为 false——取消与启动互斥)
|
||||
// Cancel 非 nil 时本轮是「取消」意图的裁决(Kind=KindCancel)。
|
||||
Cancel *CancelTarget
|
||||
// RequiresConfirm 是意图声明的显式确认要求(与命令自身的 danger 分级正交,
|
||||
// 本包只透传,不替代 cmdbus 的高危确认收口)。
|
||||
RequiresConfirm bool
|
||||
@@ -78,25 +125,34 @@ type Env struct {
|
||||
HasCommand func(id string) bool
|
||||
WorkflowFor func(intentID string) (core.WorkflowDef, bool)
|
||||
SquadReady bool
|
||||
// SessionRun 返回当前会话绑定动作的运行上下文(取消靶子的会话来源;
|
||||
// 宿主从意图会话注入)。nil 或 false = 会话没有给出靶子——绝不臆造。
|
||||
SessionRun func() (SessionRun, bool)
|
||||
}
|
||||
|
||||
// unconsumedCommandKinds 是宿主 v1 不消费的库层命令种类(无会话槽位状态机)。
|
||||
// 命中即不派发业务动作:把「取消」当成「启动」是比不响更糟的错配。
|
||||
// unconsumedCommandKinds 是本包不派发的库层命令种类。
|
||||
//
|
||||
// 命中即不派发业务动作:把「改槽」当成「启动」是比不响更糟的错配。
|
||||
// set_slot/change_slot 已由宿主的会话状态机消费(internal/intentsession,
|
||||
// A1/U-A1b)——它们改的是会话里挂起动作的槽位;cancel 自 U-A1c 起由宿主
|
||||
// 按「取消既有动作」消费(解靶子 → 宿主派发既有取消路径),故不在本表。
|
||||
// chitchat/handoff 由宿主自行处理。
|
||||
var unconsumedCommandKinds = map[string]bool{
|
||||
string(intentgate.CommandSetSlot): true,
|
||||
string(intentgate.CommandChangeSlot): true,
|
||||
string(intentgate.CommandCancel): true,
|
||||
string(intentgate.CommandHandoff): true,
|
||||
string(intentgate.CommandChitchat): true,
|
||||
}
|
||||
|
||||
// Decide 裁决一次识别结果。规则:
|
||||
// 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。
|
||||
// 2. 出现宿主不消费的库层命令(cancel/set_slot/…)→ turn,并如实记录
|
||||
// 2. 出现 cancel 命令 → 取消裁决(KindCancel):解出唯一靶子,宿主据此派发
|
||||
// 既有取消动作;同句的 Start 一并不派发(取消与启动互斥)。
|
||||
// 3. 出现宿主不消费的库层命令(set_slot/handoff/…)→ turn,并如实记录
|
||||
// Unsupported 与 Reason(不假装支持,也不派发同句的 Start)。
|
||||
// 3. 找到首个 Start 命令;没有时按 TopID(或显式 action 声明)走 auto
|
||||
// 4. 找到首个 Start 命令;没有时按 TopID(或显式 action 声明)走 auto
|
||||
// (兼容兜底,与现网一致)。
|
||||
// 4. 按 ActionRef.Kind 解析:command→命令总线(先过槽位准入);
|
||||
// 5. 按 ActionRef.Kind 解析:command→命令总线(先过槽位准入);
|
||||
// workflow→工作流(草稿,缺槽照常启动);blueprint→squad 决策;
|
||||
// handler/auto→命令总线 > 工作流 > turn。
|
||||
func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
@@ -106,7 +162,7 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
return d
|
||||
}
|
||||
d.Unsupported = unconsumedKinds(res.Commands)
|
||||
var start, clarify *intentgate.Command
|
||||
var start, clarify, cancel *intentgate.Command
|
||||
for i := range res.Commands {
|
||||
switch res.Commands[i].Kind {
|
||||
case intentgate.CommandStart:
|
||||
@@ -117,8 +173,18 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
if clarify == nil {
|
||||
clarify = &res.Commands[i]
|
||||
}
|
||||
case intentgate.CommandCancel:
|
||||
if cancel == nil {
|
||||
cancel = &res.Commands[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel != nil {
|
||||
// 取消优先于同句的其它命令:用户这一轮要的是「取消」,不是「顺便
|
||||
// 启动」。StartFound 刻意不置位——本轮不会启动任何动作,runmgr 的
|
||||
// 转轨判定因此保持与取消前一致。
|
||||
return d.decideCancel(*cancel, res, env)
|
||||
}
|
||||
if len(d.Unsupported) > 0 {
|
||||
d.Reason = "宿主未消费库层命令:" + strings.Join(d.Unsupported, "、")
|
||||
if clarify != nil {
|
||||
@@ -168,6 +234,50 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
return d
|
||||
}
|
||||
|
||||
// decideCancel 裁决「取消」:解出唯一靶子,把「取消哪一个运行」交给宿主。
|
||||
//
|
||||
// 靶子优先级(确定性,用户点名优先于会话绑定):
|
||||
// 1. 取消命令自身的值 / 本轮槽位里的运行号——用户明确指认;
|
||||
// 2. 当前会话绑定动作的运行上下文(Env.SessionRun,宿主从意图会话读)。
|
||||
//
|
||||
// 解不出唯一靶子时 RunID 留空并写明原因:宿主据此如实澄清,绝不猜。
|
||||
// 本包不派发、不确认:执行授权始终由宿主的既有取消路径收口。
|
||||
// 同句里其余未消费的库层命令(如 set_slot)仍如实记在 Unsupported 里供审计,
|
||||
// 但既不派发也不参与靶子解算——取消与启动互斥。
|
||||
func (d Decision) decideCancel(cmd intentgate.Command, res intentgate.MatchResult, env Env) Decision {
|
||||
d.Kind = KindCancel
|
||||
t := &CancelTarget{Intent: res.ID, Candidates: append([]string(nil), cmd.Candidates...)}
|
||||
if runID := explicitRunID(res, cmd); runID != "" {
|
||||
t.RunID, t.Source = runID, CancelSourceExplicit
|
||||
} else if env.SessionRun != nil {
|
||||
if bound, ok := env.SessionRun(); ok && strings.TrimSpace(bound.RunID) != "" {
|
||||
t.RunID, t.Source, t.Action = strings.TrimSpace(bound.RunID), CancelSourceSession, bound.Action
|
||||
if t.Intent == "" {
|
||||
t.Intent = bound.Intent
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.RunID == "" {
|
||||
d.Reason = "取消没有唯一靶子:原文未指明运行,当前会话也没有绑定运行"
|
||||
}
|
||||
d.Cancel = t
|
||||
return d
|
||||
}
|
||||
|
||||
// explicitRunID 取消命令里点名的运行:命令自身的值优先,其次本轮槽位里的
|
||||
// 运行号(键序固定,结果与 map 迭代序无关)。
|
||||
func explicitRunID(res intentgate.MatchResult, cmd intentgate.Command) string {
|
||||
if v := strings.TrimSpace(cmd.Value); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, key := range []string{"run_id", "runId", "run"} {
|
||||
if v := strings.TrimSpace(res.Slots[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dispatchCommand 派发可执行业务动作前的最后一道闸:槽位必须齐全且通过权威校验。
|
||||
// 未就绪时不碰业务动作,改为带结构化原因的澄清诉求(Kind 留在 turn)。
|
||||
func (d Decision) dispatchCommand(id string, res intentgate.MatchResult, clarify *intentgate.Command) Decision {
|
||||
|
||||
@@ -153,21 +153,22 @@ func TestWorkflowDraftStartsWhileGatheringSlots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 显式的取消/改槽命令不得被静默当成已支持:回落普通回合,如实记录。
|
||||
// 显式的取消/改槽命令不得被静默当成已支持:改槽回落普通回合,如实记录;
|
||||
// 取消已经是宿主可执行的既有动作(U-A1c),但解不出靶子时绝不假装已取消。
|
||||
func TestUnsupportedLibraryCommandsAreNotClaimed(t *testing.T) {
|
||||
in := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{
|
||||
{Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}},
|
||||
{Kind: intentgate.CommandCancel, Target: "demo.brief"},
|
||||
}}
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindTurn || d.CommandID != "" {
|
||||
t.Fatalf("未消费的库层命令不得派发业务动作:%+v", d)
|
||||
if d.Kind != KindCancel || d.CommandID != "" {
|
||||
t.Fatalf("取消不得被当成新业务动作派发:%+v", d)
|
||||
}
|
||||
if len(d.Unsupported) != 1 || d.Unsupported[0] != string(intentgate.CommandCancel) {
|
||||
t.Fatalf("未消费命令种类未如实记录:%+v", d.Unsupported)
|
||||
if len(d.Unsupported) != 0 {
|
||||
t.Fatalf("取消已由宿主消费,不得再记为未消费:%+v", d.Unsupported)
|
||||
}
|
||||
if d.Reason == "" {
|
||||
t.Fatalf("回落原因缺失:%+v", d)
|
||||
if d.Cancel == nil || d.Cancel.RunID != "" || d.Reason == "" {
|
||||
t.Fatalf("没有唯一靶子时必须如实留空并说明原因:%+v", d)
|
||||
}
|
||||
// 只有改槽命令时同样回落,且不得声称支持。
|
||||
only := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{
|
||||
@@ -202,3 +203,161 @@ func TestLegacyAutoStillNeedsCompleteSlots(t *testing.T) {
|
||||
t.Fatalf("旧路径也必须过槽位准入:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// A1/U-A1b:set_slot/change_slot 由宿主的会话状态机消费(internal/intentsession),
|
||||
// 但本包的公开契约不变——落槽不是可派发的动作。
|
||||
func TestSessionCommandsStayUnsupportedForDispatch(t *testing.T) {
|
||||
start := intentgate.Command{Kind: intentgate.CommandStart,
|
||||
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"},
|
||||
Slots: map[string]string{"topic": "核能"}}
|
||||
sessionKinds := []struct {
|
||||
command intentgate.Command
|
||||
kind string
|
||||
}{
|
||||
{intentgate.Command{Kind: intentgate.CommandSetSlot, Name: "topic", Value: "电机"}, string(intentgate.CommandSetSlot)},
|
||||
{intentgate.Command{Kind: intentgate.CommandChangeSlot, Name: "topic", Value: "电机"}, string(intentgate.CommandChangeSlot)},
|
||||
}
|
||||
for _, item := range sessionKinds {
|
||||
in := intentgate.MatchResult{Branch: "matched", ID: "demo.brief",
|
||||
Commands: []intentgate.Command{start, item.command}}
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindTurn || d.CommandID != "" {
|
||||
t.Fatalf("%s 不得被当成动作派发:%+v", item.kind, d)
|
||||
}
|
||||
if len(d.Unsupported) != 1 || d.Unsupported[0] != item.kind || d.Reason == "" {
|
||||
t.Fatalf("%s 未如实记录为宿主已消费但不派发:%+v", item.kind, d)
|
||||
}
|
||||
}
|
||||
// 取消是既有动作(U-A1c):它派发的是「取消某个运行」,不是同句里的
|
||||
// demo.brief;靶子缺失时留给宿主澄清,绝不假装已取消。
|
||||
cancel := intentgate.MatchResult{Branch: "matched", ID: "demo.brief",
|
||||
Commands: []intentgate.Command{start, {Kind: intentgate.CommandCancel, Target: "demo.brief"}}}
|
||||
if d := Decide(cancel, hasCommandEnv()); d.Kind != KindCancel || d.CommandID != "" || d.Cancel == nil {
|
||||
t.Fatalf("取消必须走取消裁决,不得派发别的新动作:%+v", d)
|
||||
}
|
||||
if d := Decide(cancel, hasCommandEnv()); d.StartFound {
|
||||
t.Fatalf("取消轮不得声称会启动动作(runmgr 转轨判定依赖此位):%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- A1/U-A1c:取消路由——解出唯一靶子,否则如实留给宿主澄清 ----
|
||||
|
||||
// sessionEnv 是「会话里绑定了一个运行」的路由环境。
|
||||
func sessionEnv(runID string) Env {
|
||||
return Env{SessionRun: func() (SessionRun, bool) {
|
||||
return SessionRun{RunID: runID, Intent: "demo.run-cancel", Action: "demo.run-cancel"}, runID != ""
|
||||
}}
|
||||
}
|
||||
|
||||
func cancelResult(slots map[string]string, cancel intentgate.Command) intentgate.MatchResult {
|
||||
return intentgate.MatchResult{Branch: "matched", ID: "demo.task-cancel", Slots: slots,
|
||||
Commands: []intentgate.Command{cancel}}
|
||||
}
|
||||
|
||||
func TestCancelResolvesTargetFromTextAndSession(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in intentgate.MatchResult
|
||||
env Env
|
||||
runID string
|
||||
source CancelSource
|
||||
}{
|
||||
{
|
||||
name: "命令点名",
|
||||
in: cancelResult(nil, intentgate.Command{Kind: intentgate.CommandCancel, Value: "run_7"}),
|
||||
env: hasCommandEnv(),
|
||||
runID: "run_7",
|
||||
source: CancelSourceExplicit,
|
||||
},
|
||||
{
|
||||
name: "槽位里的运行号",
|
||||
in: cancelResult(map[string]string{"run_id": "run_8"}, intentgate.Command{Kind: intentgate.CommandCancel}),
|
||||
env: hasCommandEnv(),
|
||||
runID: "run_8",
|
||||
source: CancelSourceExplicit,
|
||||
},
|
||||
{
|
||||
name: "会话绑定的运行",
|
||||
in: cancelResult(nil, intentgate.Command{Kind: intentgate.CommandCancel}),
|
||||
env: sessionEnv("run_9"),
|
||||
runID: "run_9",
|
||||
source: CancelSourceSession,
|
||||
},
|
||||
{
|
||||
// 用户点名优先于会话绑定(确定性:显式意图高于隐式上下文)。
|
||||
name: "显式优先于会话",
|
||||
in: cancelResult(map[string]string{"run_id": "run_8"},
|
||||
intentgate.Command{Kind: intentgate.CommandCancel}),
|
||||
env: sessionEnv("run_9"),
|
||||
runID: "run_8",
|
||||
source: CancelSourceExplicit,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
d := Decide(c.in, c.env)
|
||||
if d.Kind != KindCancel || d.Cancel == nil {
|
||||
t.Fatalf("%s:取消未走取消裁决:%+v", c.name, d)
|
||||
}
|
||||
if d.Cancel.RunID != c.runID || d.Cancel.Source != c.source {
|
||||
t.Fatalf("%s:靶子/来源 = %q/%q;want %q/%q", c.name, d.Cancel.RunID, d.Cancel.Source, c.runID, c.source)
|
||||
}
|
||||
if d.CommandID != "" || d.Reason != "" || d.Clarification.Needed {
|
||||
t.Fatalf("%s:解出靶子后不得再回落/再澄清:%+v", c.name, d)
|
||||
}
|
||||
if d.Cancel.Intent != "demo.task-cancel" {
|
||||
t.Fatalf("%s:意图 id 未透传:%+v", c.name, d.Cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 没有唯一靶子(无绑定、无点名)时如实留空,绝不替用户猜一个。
|
||||
func TestCancelWithoutTargetAsksForClarification(t *testing.T) {
|
||||
envs := map[string]Env{
|
||||
"无环境": {},
|
||||
"无绑定": sessionEnv(""),
|
||||
"绑定为空运行": {SessionRun: func() (SessionRun, bool) { return SessionRun{}, true }},
|
||||
}
|
||||
for name, env := range envs {
|
||||
d := Decide(cancelResult(nil, intentgate.Command{Kind: intentgate.CommandCancel}), env)
|
||||
if d.Kind != KindCancel {
|
||||
t.Fatalf("%s:没有靶子也应给出取消裁决(宿主据此澄清):%+v", name, d)
|
||||
}
|
||||
if d.Cancel.RunID != "" || d.Reason == "" {
|
||||
t.Fatalf("%s:无靶子必须如实留空并写明原因:%+v", name, d)
|
||||
}
|
||||
if d.CommandID != "" {
|
||||
t.Fatalf("%s:取消轮不得派发别的新动作:%+v", name, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 库层随取消给出的候选如实透出(不在路由层做取舍)。
|
||||
func TestCancelKeepsLibraryCandidates(t *testing.T) {
|
||||
d := Decide(cancelResult(nil, intentgate.Command{Kind: intentgate.CommandCancel,
|
||||
Candidates: []string{"run_1", "run_2"}}), Env{})
|
||||
if d.Cancel == nil || len(d.Cancel.Candidates) != 2 {
|
||||
t.Fatalf("候选未透传:%+v", d.Cancel)
|
||||
}
|
||||
if d.Cancel.RunID != "" {
|
||||
t.Fatalf("多个候选时不得替用户选一个:%+v", d.Cancel)
|
||||
}
|
||||
}
|
||||
|
||||
// 非 matched 分支的取消不产生任何靶子(与既有回落逐字节一致)。
|
||||
func TestCancelIgnoredOnNonMatched(t *testing.T) {
|
||||
for _, branch := range []string{"fallback", "guard_hit", "ambiguous", "expectation_hit"} {
|
||||
d := Decide(intentgate.MatchResult{Branch: branch, ID: "demo.task-cancel",
|
||||
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Value: "run_7"}}}, sessionEnv("run_9"))
|
||||
if d.Kind != KindTurn || d.Cancel != nil {
|
||||
t.Fatalf("%s:非命中不得产生取消裁决:%+v", branch, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Env 未接线会话(nil hook)时行为与 U-A1b 一致:没有靶子,如实澄清。
|
||||
func TestCancelWithoutSessionHookKeepsLegacyContract(t *testing.T) {
|
||||
d := Decide(cancelResult(nil, intentgate.Command{Kind: intentgate.CommandCancel}), Env{})
|
||||
if d.Cancel == nil || d.Cancel.RunID != "" {
|
||||
t.Fatalf("无会话接线不得臆造靶子:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package server
|
||||
|
||||
// 取消路由(A1/U-A1c):把「取消刚才的任务」这类意图派发成**既有**取消动作。
|
||||
//
|
||||
// 纪律:
|
||||
// - 唯一入口:路由层解出唯一靶子(原文点名的运行号,或本会话绑定动作的
|
||||
// 运行上下文),本文件只负责把它交给既有取消路径,绝不自己发明取消
|
||||
// 语义、绝不绕过命令总线。
|
||||
// - 靶子不唯一就不猜:没有绑定也没有点名时如实澄清,并把发起人自己最近
|
||||
// 的运行如实列出来(别人的运行在构造上就不可见)。
|
||||
// - 确认语义不变:注册了取消命令的部署走 Bus.Dispatch(与任何高后果
|
||||
// 动作同款——审计信封、内联确认卡原样生效,绝不自动确认);未注册时
|
||||
// 回落到 cancelRun 已有的投递(PG 档且 run 不在本实例 → 收件箱持久命令
|
||||
// EnqueueCommandAccountChecked;本实例活跃 → 就地 CancelRun),与
|
||||
// POST /api/runs/{id}/cancel 逐字同款。
|
||||
// - 不谎报成功:投递失败、运行不可见或已终态都如实回话,不写 accepted。
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/intentsession"
|
||||
"agent-system-backend/internal/route"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// intentCancelCommandID 是「取消」意图派发时优先使用的命令总线 id:注册了
|
||||
// 该命令的部署走与任何其他业务动作同款的分发路径(准入、审计信封、高危
|
||||
// 确认卡全部原样生效);未注册时回落到既有的收件箱投递,语义不变。
|
||||
const intentCancelCommandID = "run.cancel"
|
||||
|
||||
// intentCancelParam 是取消命令声明里运行号的参数名(与既有取消用例一致)。
|
||||
const intentCancelParam = "run_id"
|
||||
|
||||
// cancelCandidateLimit 是澄清时如实列出的候选运行上限(防止回话过长)。
|
||||
const cancelCandidateLimit = 5
|
||||
|
||||
// intentCancelResult 是取消派发的如实回执(响应上的 cancel 字段;不新增 API 面)。
|
||||
type intentCancelResult struct {
|
||||
RunID string `json:"runId"`
|
||||
Source string `json:"source"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
// Accepted 表示取消请求已被既有路径受理(命令已投递);高危档还需用户
|
||||
// 显式确认,响应里的 card 就是那道门禁。未受理时 Reason 说明为什么。
|
||||
Accepted bool `json:"accepted"`
|
||||
Via string `json:"via"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// planIntentCancel 裁决要不要真的取消:
|
||||
// - 返回 runID != "":靶子唯一且对发起人可见,按既有路径派发;
|
||||
// - 返回澄清诉求(runID == ""):靶子缺失/不可见/候选不唯一——如实澄清,
|
||||
// 绝不猜一个。
|
||||
//
|
||||
// 可见性只认发起人自己的运行:GetRun(owner, id) 查不到就与「不存在」同一
|
||||
// 出口(不据此泄露别人的运行是否存在)。
|
||||
func (s *Server) planIntentCancel(owner string, conv core.Conversation,
|
||||
res intentgate.MatchResult, dec route.Decision) (string, *clarifyPayload) {
|
||||
t := dec.Cancel
|
||||
if t == nil || strings.TrimSpace(t.RunID) == "" {
|
||||
return "", s.cancelClarify(owner, conv, res, "没有可取消的运行:请点名运行号,或先发起一个任务")
|
||||
}
|
||||
if _, err := s.st.GetRun(owner, t.RunID); err != nil {
|
||||
return "", s.cancelClarify(owner, conv, res, "该运行不可见或不存在")
|
||||
}
|
||||
return t.RunID, nil
|
||||
}
|
||||
|
||||
// cancelClarify 组装取消的澄清诉求:问题问「取消哪一个」,候选如实列出
|
||||
// (发起人自己最近的未终态运行,按新到旧)。一个候选都没有时如实说明。
|
||||
func (s *Server) cancelClarify(owner string, conv core.Conversation,
|
||||
res intentgate.MatchResult, reason string) *clarifyPayload {
|
||||
c := &clarifyPayload{Intent: res.ID, Command: intentCancelCommandID, Question: "要取消哪一个任务?", Reason: reason}
|
||||
candidates := s.cancelCandidateRuns(owner, conv.ID)
|
||||
for _, r := range candidates {
|
||||
c.Missing = append(c.Missing, intentgate.SlotQuestion{
|
||||
Key: string(r.ID), Question: "取消运行 " + string(r.ID) + "(" + string(r.Status) + ")"})
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
c.Question = "现在没有可取消的任务。"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// cancelCandidateRuns 列出发起人自己最近的未终态运行(会话维度、按创建时间
|
||||
// 倒序、上限 cancelCandidateLimit)。别人的运行与已终态的运行一律不列。
|
||||
func (s *Server) cancelCandidateRuns(owner, convID string) []core.Run {
|
||||
runs, err := s.st.ListRunsByConversation(convID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := []core.Run{}
|
||||
for _, r := range runs {
|
||||
if string(r.Owner) != owner || runTerminal(r.Status) {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
if len(out) > cancelCandidateLimit {
|
||||
out = out[:cancelCandidateLimit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runTerminal(status core.RunStatus) bool {
|
||||
switch status {
|
||||
case core.RunCompleted, core.RunFailed, core.RunCancelled, core.RunInterrupted:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleIntentCancel 派发一次取消意图(靶子已唯一且可见)。响应形态与既有
|
||||
// 命令入口同源:消息落库后派发,回执/确认卡/如实回话都在响应里。
|
||||
func (s *Server) handleIntentCancel(w http.ResponseWriter, r *http.Request, conv core.Conversation,
|
||||
msg core.Message, target *route.CancelTarget, runID string) {
|
||||
owner := s.owner(r)
|
||||
result := intentCancelResult{RunID: runID, Source: string(target.Source),
|
||||
Intent: target.Intent, Action: target.Action}
|
||||
out := map[string]any{"message": msg}
|
||||
awaitingConfirm := false
|
||||
switch {
|
||||
case s.Bus != nil && s.Bus.HasCommand(intentCancelCommandID):
|
||||
// 已注册取消命令:走与任何其他业务动作完全相同的分发路径。高危档
|
||||
// 在这里会拿到内联确认卡并返回 accepted(awaiting explicit confirm),
|
||||
// 确认前处理器不执行——绝不自动确认。
|
||||
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
|
||||
ID: intentCancelCommandID,
|
||||
Params: map[string]any{intentCancelParam: runID, chatTextParam: strings.TrimSpace(messageText(msg))},
|
||||
// chat 入口的规范 Entrance;Actor 是发起人,Owner 是归属租户。
|
||||
Entrance: "chat", Actor: owner, Owner: owner, ConversationID: conv.ID,
|
||||
AnchorMessageID: msg.ID, HostProjectID: string(conv.ProjectID),
|
||||
})
|
||||
result.Via, result.Accepted = "bus", resp.Receipt.State != agentcommand.StateRejected
|
||||
awaitingConfirm = resp.Card != nil
|
||||
out["command"], out["receipt"] = intentCancelCommandID, resp.Receipt
|
||||
if resp.Card != nil {
|
||||
out["card"] = resp.Card
|
||||
}
|
||||
if m, ok := resp.Payload.(map[string]any); ok {
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 未注册取消命令:与 cancelRun 同款投递——PG 档且 run 不在本实例时
|
||||
// 写持久命令由执行实例消费,否则就地级联取消本实例的句柄。
|
||||
err := s.dispatchCancel(owner, runID)
|
||||
result.Via = "local"
|
||||
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
|
||||
result.Via = "command"
|
||||
}
|
||||
if err != nil {
|
||||
result.Reason = err.Error()
|
||||
} else {
|
||||
result.Accepted = true
|
||||
}
|
||||
}
|
||||
out["cancel"] = result
|
||||
switch {
|
||||
case !result.Accepted:
|
||||
// 没有真的取消:不写会话记录,也不声称已取消(原因随 cancel 字段透出)。
|
||||
case awaitingConfirm:
|
||||
// 已提交、等待显式确认:尚未生效,因此不记「已取消」;确认后的收尾
|
||||
// 走确认卡自带的路径。
|
||||
default:
|
||||
// 已派发的取消记回会话:清挂起预期,留短期「已取消某运行」记录。
|
||||
out["intent_session"] = s.recordIntentCancel(conv, owner, result, target, runID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// dispatchCancel 派发一次取消(与 cancelRun 同一条路径):跨实例投递走收件箱
|
||||
// 持久命令,本实例活跃时就地取消句柄。投递本身经 store 的账号围栏——别人的
|
||||
// 运行号在构造上不可投递。
|
||||
func (s *Server) dispatchCancel(owner, runID string) error {
|
||||
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
|
||||
return s.enqueueCancelCommand(owner, runID)
|
||||
}
|
||||
return s.mgr.CancelRun(runID)
|
||||
}
|
||||
|
||||
// enqueueCancelCommand 是 cancelRun 与取消意图共用的投递入口:写一条持久
|
||||
// cancel 命令,由目标 run 的当前执行实例消费。
|
||||
func (s *Server) enqueueCancelCommand(owner, runID string) error {
|
||||
return s.st.EnqueueCommandAccountChecked(owner, store.NewID("cmd"), "cancel", runID, 0,
|
||||
runmgr.CancelCommandPayload{Owner: owner, RunID: runID}, s.Auth == nil || !s.Auth.Enabled)
|
||||
}
|
||||
|
||||
// recordIntentCancel 把一次已派发的取消记回会话:清挂起预期,留一条短期
|
||||
// 「已取消某运行」记录,供紧接着的追问(如「确认取消」)如实回话。
|
||||
func (s *Server) recordIntentCancel(conv core.Conversation, owner string,
|
||||
result intentCancelResult, target *route.CancelTarget, runID string) *intentSessionNote {
|
||||
reply := "已取消运行 " + runID + "。"
|
||||
if result.Via == "command" {
|
||||
// 跨实例投递:取消由目标 run 的执行实例收尾,这里只如实说「已提交」。
|
||||
reply = "已提交取消运行 " + runID + "(由执行实例处理)。"
|
||||
}
|
||||
note := &intentSessionNote{Kind: "cancelled", Intent: target.Intent, Action: target.Action,
|
||||
RunID: runID, Reply: reply}
|
||||
svc := s.intentSessionService()
|
||||
key := intentSessionKey(owner, conv)
|
||||
if svc == nil || !keyUsable(key) {
|
||||
return note
|
||||
}
|
||||
if _, err := svc.RecordCancel(key, intentsession.Binding{
|
||||
RunID: runID, Intent: target.Intent, Action: target.Action}); err != nil {
|
||||
// 记录失败不改变已发生的取消;回话仍如实说明取消了哪个运行。
|
||||
note.Reply += "(会话记录未更新)"
|
||||
}
|
||||
return note
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package server
|
||||
|
||||
// 意图会话语义(A1/U-A1b):chat 入口消费库层的会话命令(set_slot/
|
||||
// change_slot/cancel/expectation),把「动作可执行但槽位未就绪」的那一轮
|
||||
// 记成会话里的挂起预期,等用户后续轮次把槽位补齐。
|
||||
//
|
||||
// 纪律:
|
||||
// - 补齐后走的是与一次完整识别完全相同的 route.Decide + admitIntentCommand
|
||||
// + Bus.Dispatch 路径;会话只携带槽位与预期,绝不携带授权——高危动作
|
||||
// 的确认仍由 cmdbus 收口。
|
||||
// - 没有挂起预期时本段整体空转:响应逐字节与既有行为一致。
|
||||
// - 取消分两件事,宿主如实区分,绝不混为一谈:
|
||||
// · 「放弃本次补充」(会话语义):cancel 命令 + 有挂起预期 + 解不出要
|
||||
// 取消的运行 → 清预期并如实确认,不派发任何业务动作;
|
||||
// · 「取消一个运行」(业务动作,A1/U-A1c):解出了唯一靶子 → 走既有
|
||||
// 取消路径派发,见 intent_cancel.go。
|
||||
// 两者都不会绕过 cmdbus 的确认收口。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/intentsession"
|
||||
"agent-system-backend/internal/route"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// IntentSessions 是意图会话的宿主消费面(intentsession.Service 的端口)。
|
||||
// 组合根/测试可注入替身;nil 时按存储自动装配。
|
||||
type IntentSessions interface {
|
||||
Pending(intentsession.Key) (*intentsession.Session, error)
|
||||
Suspend(intentsession.Key, intentsession.Expectation, map[string]string) (*intentsession.Session, error)
|
||||
ApplyAnswer(intentsession.Key, int, intentsession.Answer) (*intentsession.Outcome, error)
|
||||
ApplyCommand(intentsession.Key, int, string, string, string) (*intentsession.Outcome, error)
|
||||
Drop(intentsession.Key) error
|
||||
Binding(intentsession.Key) (*intentsession.Binding, error)
|
||||
RecordCancel(intentsession.Key, intentsession.Binding) (*intentsession.Session, error)
|
||||
}
|
||||
|
||||
// SetIntentSessionService 注入意图会话服务(组合根与测试用;nil 回落按存储
|
||||
// 自动装配)。
|
||||
func (s *Server) SetIntentSessionService(svc IntentSessions) {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
s.sessions = svc
|
||||
}
|
||||
|
||||
// intentSessionService 取会话服务:未注入时从存储惰性装配一次,并把槽位
|
||||
// 校验接到命令总线的 DECLARED schema 上。
|
||||
func (s *Server) intentSessionService() IntentSessions {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
if s.sessions == nil && s.st != nil {
|
||||
svc := intentsession.New(s.st)
|
||||
svc.SetSlotValidator(s.validateIntentSlot)
|
||||
s.sessions = svc
|
||||
}
|
||||
return s.sessions
|
||||
}
|
||||
|
||||
// intentSessionKey 是会话状态的键:owner + project + conversation(三元组
|
||||
// 缺一不可,两个用户/项目在构造上互不可见)。
|
||||
func intentSessionKey(owner string, conv core.Conversation) intentsession.Key {
|
||||
return intentsession.Key{Owner: owner, Project: string(conv.ProjectID), Conversation: string(conv.ID)}
|
||||
}
|
||||
|
||||
// validateIntentSlot 按动作声明的 schema 校验一个槽位值:声明不可解析
|
||||
// (命令不存在/未启用)时不校验,交由既有准入面 fail-closed。
|
||||
func (s *Server) validateIntentSlot(actionID, slot, value string) error {
|
||||
if s.Bus == nil || actionID == "" {
|
||||
return nil
|
||||
}
|
||||
spec, _, err := s.Bus.Registry().Resolve(actionID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
props, _, ok := objectSchema(spec.ParamsSchema)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
decl, declared := props[slot]
|
||||
if !declared {
|
||||
return fmt.Errorf("命令未声明参数:%s", slot)
|
||||
}
|
||||
if _, ok := castParam(decl, value); !ok {
|
||||
return fmt.Errorf("参数类型与声明不符:%s", slot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// intentSessionOutcome 是本轮会话语义的结果,供响应面如实回话。
|
||||
type intentSessionOutcome struct {
|
||||
// cancelled 表示用户取消了挂起的补充(不派发任何业务动作)。
|
||||
cancelled bool
|
||||
// clarify 是仍缺槽位时的澄清诉求(仍缺时才非空)。
|
||||
clarify *clarifyPayload
|
||||
// note 是取消这类「不需要派发」的如实回话。
|
||||
note *intentSessionNote
|
||||
}
|
||||
|
||||
// intentSessionNote 是会话语义的回话标记(既有响应上的附加字段,不新增 API 面)。
|
||||
type intentSessionNote struct {
|
||||
Kind string `json:"kind"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
Reply string `json:"reply"`
|
||||
Pending bool `json:"pending"`
|
||||
}
|
||||
|
||||
// fuseIntentSession 把本轮用户输入折叠进挂起预期:补齐即合成一条与完整
|
||||
// 识别同形的 MatchResult(下游照常 route → admit → dispatch);仍缺则
|
||||
// 如实重新澄清;取消则清理会话且不派发。
|
||||
// 返回 nil 结果表示本轮与会话语义无关(无挂起预期、或属于别的任务),
|
||||
// 调用方保持既有行为。
|
||||
func (s *Server) fuseIntentSession(key intentsession.Key,
|
||||
recognized intentgate.MatchResult) (intentgate.MatchResult, *intentSessionOutcome, error) {
|
||||
svc := s.intentSessionService()
|
||||
if svc == nil || !keyUsable(key) {
|
||||
return recognized, nil, nil
|
||||
}
|
||||
pending, err := svc.Pending(key)
|
||||
if err != nil {
|
||||
return recognized, nil, err
|
||||
}
|
||||
// 「已取消」记录不是待补齐的动作:它不再吸收本轮的槽位补充(那一轮由
|
||||
// 取消路由自己处理),只作为如实回话的依据。
|
||||
if pending == nil || pending.Expectation == nil || pending.Cancelled != nil {
|
||||
return recognized, nil, nil
|
||||
}
|
||||
answer := answerFromRecognition(pending, recognized)
|
||||
if answer.Cancel {
|
||||
return recognized, s.applyCancel(svc, key, pending, answer), nil
|
||||
}
|
||||
// 本轮对同一动作已是一次完整的新识别:它就是新的指令,按它派发;
|
||||
// 旧累计槽位不混进新任务(会话就此作废)。
|
||||
if recognized.Branch == "matched" && recognized.ID != "" &&
|
||||
recognized.ID == pending.Expectation.IntentID && recognized.Complete() {
|
||||
return recognized, nil, svc.Drop(key)
|
||||
}
|
||||
if len(answer.Slots) == 0 && len(answer.Invalid) == 0 {
|
||||
// 本轮没有补槽信息(含「命中的是别的意图」):保持既有行为,挂起预期
|
||||
// 原样留着,由续期或过期收尾——绝不拿别的任务的输入凑当前动作。
|
||||
return recognized, nil, nil
|
||||
}
|
||||
out, err := svc.ApplyAnswer(key, pending.Revision, answer)
|
||||
if errors.Is(err, intentsession.ErrStale) {
|
||||
// 会话已被别的轮次推进或取消:陈旧回答作废,绝不据此执行动作。
|
||||
return recognized, nil, nil
|
||||
}
|
||||
if err != nil || out == nil {
|
||||
return recognized, nil, err
|
||||
}
|
||||
if out.Complete {
|
||||
// 补齐:recognized 已是与完整识别同形的结果,直接走既有准入与
|
||||
// 派发路径(无需任何附加回话字段)。
|
||||
return completedRecognition(pending, out), &intentSessionOutcome{}, nil
|
||||
}
|
||||
return recognized, &intentSessionOutcome{clarify: sessionClarify(pending, out)}, nil
|
||||
}
|
||||
|
||||
// applyCancel 消费库层 cancel:清空挂起预期并如实确认取消(不派发业务动作)。
|
||||
func (s *Server) applyCancel(svc IntentSessions, key intentsession.Key,
|
||||
pending *intentsession.Session, answer intentsession.Answer) *intentSessionOutcome {
|
||||
out, err := svc.ApplyAnswer(key, pending.Revision, answer)
|
||||
if errors.Is(err, intentsession.ErrStale) || err != nil || out == nil || !out.Cancelled {
|
||||
return nil
|
||||
}
|
||||
action, intent := "", pending.Expectation.IntentID
|
||||
if pending.Expectation.Action.ID != "" {
|
||||
action = pending.Expectation.Action.ID
|
||||
}
|
||||
return &intentSessionOutcome{
|
||||
cancelled: true,
|
||||
note: &intentSessionNote{Kind: "cancelled", Action: action, Intent: intent, Pending: false,
|
||||
Reply: "已取消本次槽位补充,未执行任何动作。"},
|
||||
}
|
||||
}
|
||||
|
||||
// answerFromRecognition 把一次识别投影成会话语义的答案:库层会话命令
|
||||
// (cancel 优先)、挂起意图自身的槽位与非法原因。归属别的意图时一概不碰
|
||||
// 本会话(别的任务的输入绝不混进当前动作)。
|
||||
func answerFromRecognition(pending *intentsession.Session, recognized intentgate.MatchResult) intentsession.Answer {
|
||||
answer := intentsession.Answer{}
|
||||
sameIntent := recognized.ID != "" && recognized.ID == pending.Expectation.IntentID
|
||||
otherIntent := recognized.ID != "" && !sameIntent
|
||||
for _, cmd := range recognized.Commands {
|
||||
switch cmd.Kind {
|
||||
case intentgate.CommandCancel:
|
||||
if !otherIntent { // 取消指向别的意图时不碰本会话
|
||||
answer.Cancel = true
|
||||
return answer
|
||||
}
|
||||
case intentgate.CommandSetSlot, intentgate.CommandChangeSlot:
|
||||
if strings.TrimSpace(cmd.Name) == "" {
|
||||
continue
|
||||
}
|
||||
if answer.Slots == nil {
|
||||
answer.Slots = map[string]string{}
|
||||
}
|
||||
answer.Slots[cmd.Name] = cmd.Value // 显式落槽命令:不看意图归属
|
||||
}
|
||||
}
|
||||
if !sameIntent {
|
||||
return answer
|
||||
}
|
||||
if len(recognized.Slots) > 0 {
|
||||
if answer.Slots == nil {
|
||||
answer.Slots = make(map[string]string, len(recognized.Slots))
|
||||
}
|
||||
for k, v := range recognized.Slots {
|
||||
answer.Slots[k] = v
|
||||
}
|
||||
}
|
||||
for _, defect := range recognized.Invalid {
|
||||
if answer.Invalid == nil {
|
||||
answer.Invalid = map[string]string{}
|
||||
}
|
||||
answer.Invalid[defect.Key] = firstNonEmpty(defect.Reason, "value rejected")
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
// completedRecognition 合成一条与「一次完整识别」同形的 MatchResult:同一
|
||||
// 意图 ID、累计槽位、同一动作引用的 Start 命令。风险与确认声明刻意不搬运
|
||||
// ——会话不携带授权语义,确认始终由 cmdbus 按命令 danger 收口。
|
||||
func completedRecognition(pending *intentsession.Session, out *intentsession.Outcome) intentgate.MatchResult {
|
||||
action := pending.Expectation.Action
|
||||
res := intentgate.MatchResult{
|
||||
Branch: "matched",
|
||||
ID: pending.Expectation.IntentID,
|
||||
Slots: out.Slots,
|
||||
Commands: []intentgate.Command{{Kind: intentgate.CommandStart, Action: action, Slots: out.Slots}},
|
||||
}
|
||||
if action.ID != "" {
|
||||
ref := action
|
||||
res.Action = &ref
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// sessionClarify 仍缺槽位时的澄清诉求:只报还差什么(缺失/非法各带原因)。
|
||||
func sessionClarify(pending *intentsession.Session, out *intentsession.Outcome) *clarifyPayload {
|
||||
action, intent := "", pending.Expectation.IntentID
|
||||
if pending.Expectation.Action.ID != "" {
|
||||
action = pending.Expectation.Action.ID
|
||||
}
|
||||
// 提问优先问「还差的那个」:缺失在前、非法在后,都没有才回落挂起时的
|
||||
// 原始提问。
|
||||
question := ""
|
||||
if len(out.Remaining) > 0 {
|
||||
question = strings.TrimSpace(out.Remaining[0].Question)
|
||||
} else if len(out.Invalid) > 0 {
|
||||
question = strings.TrimSpace(out.Invalid[0].Question)
|
||||
}
|
||||
if question == "" {
|
||||
question = strings.TrimSpace(out.Question)
|
||||
}
|
||||
c := &clarifyPayload{Intent: intent, Command: action, Question: question,
|
||||
Missing: append([]intentgate.SlotQuestion(nil), out.Remaining...),
|
||||
Invalid: append([]intentgate.SlotDefect(nil), out.Invalid...)}
|
||||
if c.Question == "" {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// suspendIntentSession 把「动作可执行但槽位未就绪」的那一轮记成会话里的
|
||||
// 挂起预期(U6 准入面:声明的必填参数无法由原文+槽位满足;路由面:缺失或
|
||||
// 非法的必填槽位)。响应面不受影响——澄清话术仍由既有 clarify 字段给出。
|
||||
func (s *Server) suspendIntentSession(key intentsession.Key, recognized intentgate.MatchResult,
|
||||
dec route.Decision, adm *intentAdmission) error {
|
||||
svc := s.intentSessionService()
|
||||
if svc == nil || !keyUsable(key) {
|
||||
return nil
|
||||
}
|
||||
commandID := dec.Clarification.Command
|
||||
var missing []intentgate.SlotQuestion
|
||||
question := strings.TrimSpace(dec.Clarification.Question)
|
||||
switch {
|
||||
case dec.Kind == route.KindCommand && adm != nil && adm.Blocked:
|
||||
// 路由已认为动作可派发,但准入未过(声明的必填参数补不齐)。
|
||||
commandID = dec.CommandID
|
||||
for _, name := range adm.MissingParams {
|
||||
missing = append(missing, intentgate.SlotQuestion{Key: name, Question: name})
|
||||
}
|
||||
case dec.Clarification.Needed && commandID != "":
|
||||
// 路由层槽位缺口/非法:动作已解析到已存在的命令,只是槽位不齐。
|
||||
missing = append(missing, dec.Clarification.Missing...)
|
||||
for _, defect := range dec.Clarification.Invalid {
|
||||
missing = append(missing, intentgate.SlotQuestion{Key: defect.Key, Question: defect.Question})
|
||||
}
|
||||
default:
|
||||
return nil // 没有待补齐的可执行动作:不挂起
|
||||
}
|
||||
if commandID == "" {
|
||||
return nil
|
||||
}
|
||||
if question == "" && len(missing) > 0 {
|
||||
question = strings.TrimSpace(missing[0].Question)
|
||||
}
|
||||
_, err := svc.Suspend(key, intentsession.Expectation{
|
||||
IntentID: recognized.ID,
|
||||
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: commandID},
|
||||
Missing: missing, Question: question,
|
||||
}, dec.Slots)
|
||||
return err
|
||||
}
|
||||
|
||||
// keyUsable 拒绝不完整的键(无归属即不落任何会话状态)。
|
||||
func keyUsable(key intentsession.Key) bool { return key.Owner != "" && key.Conversation != "" }
|
||||
|
||||
// sessionRunTarget 把意图会话里绑定的运行上下文投影给路由器(取消靶子的
|
||||
// 会话来源)。没有绑定、绑定已取消、或读取失败一律返回 false:宿主随即
|
||||
// 如实澄清,绝不臆造一个靶子。
|
||||
func (s *Server) sessionRunTarget(key intentsession.Key) (route.SessionRun, bool) {
|
||||
svc := s.intentSessionService()
|
||||
if svc == nil || !keyUsable(key) {
|
||||
return route.SessionRun{}, false
|
||||
}
|
||||
b, err := svc.Binding(key)
|
||||
if err != nil || b == nil || b.Cancelled || strings.TrimSpace(b.RunID) == "" {
|
||||
return route.SessionRun{}, false
|
||||
}
|
||||
return route.SessionRun{RunID: b.RunID, Intent: b.Intent, Action: b.Action}, true
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
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
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
@@ -71,6 +72,11 @@ type Server struct {
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
// 会话语义的端口(A1/U-A1b):chat 入口折叠「动作可执行但槽位未就绪」的
|
||||
// 挂起预期。nil = 按存储惰性装配(intentsession.New);组合根/测试可
|
||||
// 注入替身,见 SetIntentSessionService。
|
||||
sessionsMu sync.Mutex
|
||||
sessions IntentSessions
|
||||
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
|
||||
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
|
||||
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
|
||||
@@ -1231,8 +1237,21 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var workflowDef core.WorkflowDef
|
||||
var admission *intentAdmission
|
||||
var clarify *clarifyPayload
|
||||
var sessionNote *intentSessionNote
|
||||
var cancelTarget *route.CancelTarget
|
||||
var cancelRunID string
|
||||
if s.Gate != nil && !strings.HasPrefix(text, "/") && req.TaskRef == nil {
|
||||
recognized = s.recognize(r.Context(), text)
|
||||
// 会话语义(A1/U-A1b):有挂起预期时先按它折叠本轮——补齐即按与
|
||||
// 一次完整识别完全相同的准入与派发路径执行,仍缺则续挂并如实重新
|
||||
// 澄清,取消则清理会话且不派发。无挂起预期时本段空转(行为不变)。
|
||||
sessionKey := intentSessionKey(owner, conv)
|
||||
fused, sessionOutcome, ferr := s.fuseIntentSession(sessionKey, recognized)
|
||||
if ferr != nil {
|
||||
writeStoreErr(w, ferr)
|
||||
return
|
||||
}
|
||||
recognized = fused
|
||||
dec := route.Decide(recognized, route.Env{
|
||||
HasCommand: func(id string) bool { return s.Bus != nil && s.Bus.HasCommand(id) },
|
||||
WorkflowFor: func(intentID string) (core.WorkflowDef, bool) {
|
||||
@@ -1242,8 +1261,22 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return s.Workflows.ByIntent(intentID)
|
||||
},
|
||||
// SquadReady 保持 false:per-run entry 覆盖未就位(见 route 包注释的已知边界)。
|
||||
// SessionRun:取消靶子的会话来源(意图会话绑定的运行);没有绑定
|
||||
// 时返回 false,路由层随即把「取消没有唯一靶子」交给宿主澄清。
|
||||
SessionRun: func() (route.SessionRun, bool) { return s.sessionRunTarget(sessionKey) },
|
||||
})
|
||||
switch dec.Kind {
|
||||
case route.KindCancel:
|
||||
// 取消是既有动作(带明确对象):靶子唯一且对发起人可见才派发,
|
||||
// 否则如实澄清(绝不猜一个)。本轮不启动新回合——取消与启动互斥。
|
||||
// 会话层已经消费掉本轮 cancel(放弃补槽)时不再二次规划。
|
||||
if sessionOutcome == nil || !sessionOutcome.cancelled {
|
||||
runID, need := s.planIntentCancel(owner, conv, recognized, dec)
|
||||
clarify = need
|
||||
if need == nil {
|
||||
routeKind, cancelTarget, cancelRunID = route.KindCancel, dec.Cancel, runID
|
||||
}
|
||||
}
|
||||
case route.KindCommand:
|
||||
// 接纳:按命令 DECLARED schema 把槽位映射成参数;准入未过
|
||||
// (必填参数无法由原文+槽位满足)就不派发,降为澄清回合(F09)。
|
||||
@@ -1263,6 +1296,22 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
default:
|
||||
clarify = newClarifyPayload(recognized, dec, nil)
|
||||
}
|
||||
// 动作可执行但槽位未就绪:把这一轮记成会话里的挂起预期,下一轮
|
||||
// 补齐即可按同一准入与派发路径执行(响应面不受影响)。
|
||||
if err := s.suspendIntentSession(sessionKey, recognized, dec, admission); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if sessionOutcome != nil {
|
||||
switch {
|
||||
case sessionOutcome.clarify != nil:
|
||||
// 仍缺槽位:如实按会话的累计状态重新澄清。
|
||||
clarify = sessionOutcome.clarify
|
||||
case sessionOutcome.cancelled:
|
||||
// 取消挂起的补充:如实确认,不派发任何业务动作。
|
||||
sessionNote = sessionOutcome.note
|
||||
}
|
||||
}
|
||||
}
|
||||
claimed := false
|
||||
if routeKind == route.KindTurn || routeKind == route.KindWorkflow {
|
||||
@@ -1294,6 +1343,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if routeKind == route.KindCancel {
|
||||
// 取消意图落到既有取消路径(与 POST /api/runs/{id}/cancel 同款):
|
||||
// 回执、确认卡与会话记录都在响应里如实给出;不启动新回合。
|
||||
s.handleIntentCancel(w, r, conv, msg, cancelTarget, cancelRunID)
|
||||
return
|
||||
}
|
||||
if routeKind == route.KindCommand {
|
||||
// 参数按命令 DECLARED schema 映射(槽位→声明键/声明类型);原文恒供。
|
||||
params := map[string]any{chatTextParam: text}
|
||||
@@ -1344,6 +1399,11 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// 澄清诉求(业务动作未派发):既有端点上的附加字段,缺失/非法各带结构化原因。
|
||||
out["clarify"] = clarify
|
||||
}
|
||||
if sessionNote != nil {
|
||||
// 会话语义的如实回话(取消挂起的补充):同样不新增 API 面,且从不
|
||||
// 声称已派发任何业务动作。
|
||||
out["intent_session"] = sessionNote
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -1516,8 +1576,7 @@ func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) {
|
||||
// conversation_id 必须回填真实值:消费侧按执行权 JOIN 匹配,空串永远
|
||||
// 无法送达(同批实测)。
|
||||
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
|
||||
if cerr := s.st.EnqueueCommandAccountChecked(s.owner(r), store.NewID("cmd"), "cancel", runID, 0,
|
||||
runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}, s.Auth == nil || !s.Auth.Enabled); cerr != nil {
|
||||
if cerr := s.enqueueCancelCommand(s.owner(r), runID); cerr != nil {
|
||||
writeStoreErr(w, cerr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package store
|
||||
|
||||
// 意图会话(A1/U-A1b):会话维度挂起一条「可执行动作预期」——动作引用、
|
||||
// 仍缺的槽位、已收集的槽位、单调 revision 与到期时间。宿主
|
||||
// (internal/intentsession)在澄清回合后写入它,下一轮把用户的补充折叠
|
||||
// 回去;槽位齐全时宿主按同一条准入与派发路径执行(授权与高危确认始终
|
||||
// 由 cmdbus 收口——本表不携带任何权限字段)。
|
||||
//
|
||||
// 键是 (owner, project_id, conversation_id):两个用户、两个项目的会话在
|
||||
// 构造上互不可见,读写一律带 owner 过滤。行是运行态(默认 30 分钟后
|
||||
// 过期、读侧惰性删除),不进 storage-migrate 的 copyOrder(同
|
||||
// admission_leases:进程/会话瞬态,迁移没有意义)。
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IntentSlotQuestion 是一个仍缺的必填槽位及其建议提问。
|
||||
type IntentSlotQuestion struct {
|
||||
Key string `json:"key"`
|
||||
Question string `json:"question,omitempty"`
|
||||
}
|
||||
|
||||
// IntentExpectation 是挂起的可执行动作预期:等后续轮次补齐槽位。
|
||||
// ActionKind/ActionID 是权威动作引用(command/workflow/blueprint/auto)。
|
||||
type IntentExpectation struct {
|
||||
IntentID string `json:"intent_id,omitempty"`
|
||||
ActionKind string `json:"action_kind,omitempty"`
|
||||
ActionID string `json:"action_id,omitempty"`
|
||||
Missing []IntentSlotQuestion `json:"missing,omitempty"`
|
||||
Question string `json:"question,omitempty"`
|
||||
}
|
||||
|
||||
// IntentSession 是一个会话当前的意图会话状态。Expectation=nil 表示当前
|
||||
// 没有挂起动作(例如用户已取消或动作已补齐),Slots 保留已收集值,
|
||||
// Revision 单调递增用于挡住陈旧回答。
|
||||
type IntentSession struct {
|
||||
Owner string
|
||||
ProjectID string
|
||||
ConversationID string
|
||||
Expectation *IntentExpectation
|
||||
Slots map[string]string
|
||||
Revision int
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// GetIntentSession 按 owner+project+conversation 读回意图会话。
|
||||
// 没有行(别的用户/项目/会话、或尚未挂起)一律返回 ErrNotFound:
|
||||
// 会话状态不跨用户可见,调用方不得据此猜测存在性。
|
||||
func (s *Store) GetIntentSession(owner, projectID, conversationID string) (IntentSession, error) {
|
||||
if owner == "" || conversationID == "" {
|
||||
return IntentSession{}, ErrNotFound
|
||||
}
|
||||
row := s.db.QueryRow(s.q(`SELECT expectation,slots,revision,expires_at,created_at,updated_at
|
||||
FROM intent_session WHERE owner=? AND project_id=? AND conversation_id=?`),
|
||||
owner, projectID, conversationID)
|
||||
return scanIntentSession(owner, projectID, conversationID, row)
|
||||
}
|
||||
|
||||
// PutIntentSession 挂起一个动作预期并累计槽位(upsert,revision 每次 +1、
|
||||
// 到期时间每次刷新)。expectation=nil 表示清空挂起动作(保留已收集槽位)。
|
||||
// 返回写入后的会话(含新的 revision),调用方据此推进自己的视图。
|
||||
func (s *Store) PutIntentSession(owner, projectID, conversationID string,
|
||||
expectation *IntentExpectation, slots map[string]string, ttl time.Duration) (IntentSession, error) {
|
||||
if owner == "" || conversationID == "" {
|
||||
return IntentSession{}, errors.New("store: intent session key incomplete")
|
||||
}
|
||||
var rawExpectation any
|
||||
if expectation != nil {
|
||||
rawExpectation = marshalJSON(expectation)
|
||||
}
|
||||
if slots == nil {
|
||||
slots = map[string]string{}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
expiresAt := ""
|
||||
if ttl > 0 {
|
||||
expiresAt = now.Add(ttl).Format(time.RFC3339Nano)
|
||||
}
|
||||
if _, err := s.db.Exec(s.q(`INSERT INTO intent_session
|
||||
(owner,project_id,conversation_id,expectation,slots,revision,expires_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,1,?,?,?)
|
||||
ON CONFLICT(owner,project_id,conversation_id) DO UPDATE SET
|
||||
expectation=excluded.expectation, slots=excluded.slots,
|
||||
revision=intent_session.revision+1, expires_at=excluded.expires_at,
|
||||
updated_at=excluded.updated_at`),
|
||||
owner, projectID, conversationID, rawExpectation, marshalJSON(slots),
|
||||
expiresAt, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
|
||||
return IntentSession{}, fmt.Errorf("put intent session: %w", err)
|
||||
}
|
||||
return s.GetIntentSession(owner, projectID, conversationID)
|
||||
}
|
||||
|
||||
// ClearIntentSession 删除整行会话状态(幂等;动作已派发或会话结束时用)。
|
||||
func (s *Store) ClearIntentSession(owner, projectID, conversationID string) error {
|
||||
if owner == "" || conversationID == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(s.q(`DELETE FROM intent_session WHERE owner=? AND project_id=? AND conversation_id=?`),
|
||||
owner, projectID, conversationID); err != nil {
|
||||
return fmt.Errorf("clear intent session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearIntentSessionExpectation 只清空挂起的动作预期(用户取消):行保留
|
||||
// 已收集槽位,revision +1 让任何在途的陈旧回答都作废。无行时返回
|
||||
// ErrNotFound。
|
||||
func (s *Store) ClearIntentSessionExpectation(owner, projectID, conversationID string) (IntentSession, error) {
|
||||
if owner == "" || conversationID == "" {
|
||||
return IntentSession{}, ErrNotFound
|
||||
}
|
||||
res, err := s.db.Exec(s.q(`UPDATE intent_session SET expectation=NULL, revision=revision+1, updated_at=?
|
||||
WHERE owner=? AND project_id=? AND conversation_id=?`), nowISO(), owner, projectID, conversationID)
|
||||
if err != nil {
|
||||
return IntentSession{}, fmt.Errorf("clear intent session expectation: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return IntentSession{}, ErrNotFound
|
||||
}
|
||||
return s.GetIntentSession(owner, projectID, conversationID)
|
||||
}
|
||||
|
||||
// scanIntentSession 组装一行会话状态;缺行一律 ErrNotFound。
|
||||
func scanIntentSession(owner, projectID, conversationID string, row *sql.Row) (IntentSession, error) {
|
||||
var rawExpectation sql.NullString
|
||||
var rawSlots string
|
||||
var revision int
|
||||
var expiresAt, createdAt, updatedAt string
|
||||
if err := row.Scan(&rawExpectation, &rawSlots, &revision, &expiresAt, &createdAt, &updatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return IntentSession{}, ErrNotFound
|
||||
}
|
||||
return IntentSession{}, err
|
||||
}
|
||||
out := IntentSession{
|
||||
Owner: owner, ProjectID: projectID, ConversationID: conversationID,
|
||||
Slots: map[string]string{}, Revision: revision,
|
||||
}
|
||||
if rawExpectation.Valid && strings.TrimSpace(rawExpectation.String) != "" {
|
||||
exp, err := unmarshalJSON[IntentExpectation](rawExpectation.String)
|
||||
if err != nil {
|
||||
return IntentSession{}, fmt.Errorf("intent session expectation: %w", err)
|
||||
}
|
||||
out.Expectation = &exp
|
||||
}
|
||||
if strings.TrimSpace(rawSlots) != "" {
|
||||
slots, err := unmarshalJSON[map[string]string](rawSlots)
|
||||
if err != nil {
|
||||
return IntentSession{}, fmt.Errorf("intent session slots: %w", err)
|
||||
}
|
||||
if slots != nil {
|
||||
out.Slots = slots
|
||||
}
|
||||
}
|
||||
out.ExpiresAt, _ = parseTime(expiresAt)
|
||||
out.CreatedAt, _ = parseTime(createdAt)
|
||||
out.UpdatedAt, _ = parseTime(updatedAt)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 意图会话表(v52 / A1-U-A1b):键含 owner+project+conversation、revision
|
||||
// 单调递增、到期时间随更新刷新;旧库(v51)是纯增量升级。
|
||||
|
||||
func cancelExpectation() *IntentExpectation {
|
||||
return &IntentExpectation{
|
||||
IntentID: "demo.run-cancel", ActionKind: "command", ActionID: "demo.run-cancel",
|
||||
Missing: []IntentSlotQuestion{{Key: "run_id", Question: "取消哪个运行?"}},
|
||||
Question: "取消哪个运行?",
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentSessionUpsertIncrementsRevisionAndRefreshesExpiry(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
exp := cancelExpectation()
|
||||
first, err := st.PutIntentSession("alice", "prj_1", "cnv_1", exp, map[string]string{"run_id": "R-1"}, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Revision != 1 || first.Expectation == nil || first.Expectation.ActionID != "demo.run-cancel" {
|
||||
t.Fatalf("首次挂起:%+v", first)
|
||||
}
|
||||
if first.Slots["run_id"] != "R-1" || len(first.Expectation.Missing) != 1 {
|
||||
t.Fatalf("槽位/缺失未落库:%+v", first)
|
||||
}
|
||||
if !first.ExpiresAt.After(time.Now()) {
|
||||
t.Fatalf("到期时间未设置:%+v", first)
|
||||
}
|
||||
|
||||
// 同一会话再挂一次:revision +1、槽位累计、到期时间刷新。
|
||||
second, err := st.PutIntentSession("alice", "prj_1", "cnv_1", exp,
|
||||
map[string]string{"run_id": "R-1", "reason": "误触发"}, 2*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Revision != 2 {
|
||||
t.Fatalf("revision 未递增:%+v", second)
|
||||
}
|
||||
if second.Slots["reason"] != "误触发" || second.CreatedAt != first.CreatedAt {
|
||||
t.Fatalf("槽位累计/创建时间被改写:%+v", second)
|
||||
}
|
||||
if !second.ExpiresAt.After(first.ExpiresAt) {
|
||||
t.Fatalf("到期时间未刷新:%v -> %v", first.ExpiresAt, second.ExpiresAt)
|
||||
}
|
||||
|
||||
got, err := st.GetIntentSession("alice", "prj_1", "cnv_1")
|
||||
if err != nil || got.Revision != 2 || got.Expectation.Question != "取消哪个运行?" {
|
||||
t.Fatalf("读回:%+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentSessionClearKeepsNothingAndClearExpectationKeepsSlots(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
exp := cancelExpectation()
|
||||
if _, err := st.PutIntentSession("alice", "prj_1", "cnv_1", exp, map[string]string{"run_id": "R-1"}, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 取消:清空预期、保留已收集槽位、revision +1(陈旧回答就此作废)。
|
||||
cleared, err := st.ClearIntentSessionExpectation("alice", "prj_1", "cnv_1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cleared.Expectation != nil || cleared.Revision != 2 || cleared.Slots["run_id"] != "R-1" {
|
||||
t.Fatalf("取消后的状态:%+v", cleared)
|
||||
}
|
||||
|
||||
// 动作补齐/会话结束:整行清掉(幂等)。
|
||||
if err := st.ClearIntentSession("alice", "prj_1", "cnv_1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ClearIntentSession("alice", "prj_1", "cnv_1"); err != nil {
|
||||
t.Fatalf("重复清理必须幂等:%v", err)
|
||||
}
|
||||
if _, err := st.GetIntentSession("alice", "prj_1", "cnv_1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("清理后仍可读:%v", err)
|
||||
}
|
||||
if _, err := st.ClearIntentSessionExpectation("alice", "prj_1", "cnv_1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("无行时清理预期必须报 not found:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 两个用户与两个项目在构造上互不可见:别人的挂起读不到、写不进。
|
||||
func TestIntentSessionIsolatesOwnersAndProjects(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
exp := cancelExpectation()
|
||||
if _, err := st.PutIntentSession("alice", "prj_1", "cnv_1", exp, map[string]string{"run_id": "R-alice"}, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, probe := range []struct{ owner, project, conversation string }{
|
||||
{"bob", "prj_1", "cnv_1"}, // 换用户
|
||||
{"alice", "prj_2", "cnv_1"}, // 换项目
|
||||
{"alice", "prj_1", "cnv_2"}, // 换会话
|
||||
{"", "prj_1", "cnv_1"}, // 无归属
|
||||
} {
|
||||
if _, err := st.GetIntentSession(probe.owner, probe.project, probe.conversation); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("%v 读到了别人的会话:%v", probe, err)
|
||||
}
|
||||
if err := st.ClearIntentSession(probe.owner, probe.project, probe.conversation); err != nil {
|
||||
t.Fatalf("清理越权键:%v", err)
|
||||
}
|
||||
}
|
||||
got, err := st.GetIntentSession("alice", "prj_1", "cnv_1")
|
||||
if err != nil || got.Slots["run_id"] != "R-alice" {
|
||||
t.Fatalf("越权清理影响了属主会话:%+v %v", got, err)
|
||||
}
|
||||
// 同 owner 换项目写入互不覆盖。
|
||||
if _, err := st.PutIntentSession("alice", "prj_2", "cnv_1", exp, nil, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a, _ := st.GetIntentSession("alice", "prj_1", "cnv_1"); a.Slots["run_id"] != "R-alice" {
|
||||
t.Fatalf("另一项目的写入串了会话:%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// intentSessionTableExists 双档目录探测(SQLite sqlite_master / PG information_schema)。
|
||||
func intentSessionTableExists(t *testing.T, st *Store) bool {
|
||||
t.Helper()
|
||||
query := `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='intent_session'`
|
||||
if st.Dialect() == "postgres" {
|
||||
query = `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=current_schema() AND table_name='intent_session'`
|
||||
}
|
||||
var n int
|
||||
if err := st.db.QueryRow(query).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// 旧库(v51,无 intent_session 表)重开即升级:无数据迁移,既有行不受影响。
|
||||
func TestIntentSessionUpgradesLegacyDatabase(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.MigrateDown(51); err != nil {
|
||||
t.Fatalf("回滚到 v51:%v", err)
|
||||
}
|
||||
if intentSessionTableExists(t, st) {
|
||||
t.Fatal("v51 库不应有 intent_session 表")
|
||||
}
|
||||
if err := st.migrate(); err != nil {
|
||||
t.Fatalf("升级 v51 -> v52:%v", err)
|
||||
}
|
||||
assertFullLedger(t, st)
|
||||
if _, err := st.PutIntentSession("alice", "prj_1", "cnv_1", cancelExpectation(), map[string]string{"run_id": "R-1"}, time.Minute); err != nil {
|
||||
t.Fatalf("升级后写入:%v", err)
|
||||
}
|
||||
got, err := st.GetIntentSession("alice", "prj_1", "cnv_1")
|
||||
if err != nil || got.Slots["run_id"] != "R-1" || got.Revision != 1 {
|
||||
t.Fatalf("升级后读回:%+v %v", got, err)
|
||||
}
|
||||
// 回滚可重复演练(down 语句齐备)。
|
||||
if err := st.MigrateDown(51); err != nil {
|
||||
t.Fatalf("回滚 v52:%v", err)
|
||||
}
|
||||
if err := st.migrate(); err != nil {
|
||||
t.Fatalf("再次升级:%v", err)
|
||||
}
|
||||
assertFullLedger(t, st)
|
||||
}
|
||||
|
||||
// 空值约定:没有挂起动作时 expectation 为 NULL,已收集槽位仍是合法 JSON。
|
||||
func TestIntentSessionNullableExpectationAndEmptySlots(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if _, err := st.PutIntentSession("alice", "", "cnv_1", nil, nil, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetIntentSession("alice", "", "cnv_1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Expectation != nil || got.Slots == nil || len(got.Slots) != 0 {
|
||||
t.Fatalf("空预期/空槽位:%+v", got)
|
||||
}
|
||||
if !got.ExpiresAt.IsZero() {
|
||||
t.Fatalf("ttl<=0 时不应有过期时间:%+v", got)
|
||||
}
|
||||
if _, err := st.PutIntentSession("", "prj_1", "cnv_1", nil, nil, time.Minute); err == nil {
|
||||
t.Fatal("无 owner 的写入必须被拒")
|
||||
}
|
||||
}
|
||||
@@ -1225,9 +1225,38 @@ func init() {
|
||||
"postgres": {`DROP INDEX IF EXISTS idx_users_single_super_admin`, `ALTER TABLE users DROP COLUMN is_super_admin`},
|
||||
},
|
||||
},
|
||||
migration{version: 52, name: "intent_session",
|
||||
// 意图会话(A1/U-A1b):会话维度挂起一条可执行动作预期 + 已收集
|
||||
// 槽位 + 单调 revision + 到期时间。纯新表,既有库是空操作(新表
|
||||
// 增量升级,无需数据迁移);运行态行随到期惰性清理。
|
||||
up: map[string][]string{"sqlite": intentSessionUp, "postgres": intentSessionUp},
|
||||
down: map[string][]string{"sqlite": intentSessionDown, "postgres": intentSessionDown},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// v52 意图会话表:键含 owner+project+conversation,两个用户的会话在构造上
|
||||
// 互不可见。expectation 可空(NULL = 没有挂起动作,只剩已收集槽位),
|
||||
// slots/revision/expires_at 是折叠与陈旧回答防护的宿主状态。
|
||||
var intentSessionUp = []string{
|
||||
`CREATE TABLE IF NOT EXISTS intent_session (
|
||||
owner TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL DEFAULT '',
|
||||
conversation_id TEXT NOT NULL,
|
||||
expectation TEXT,
|
||||
slots TEXT NOT NULL DEFAULT '{}',
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (owner, project_id, conversation_id)
|
||||
)`,
|
||||
}
|
||||
|
||||
var intentSessionDown = []string{
|
||||
`DROP TABLE IF EXISTS intent_session`,
|
||||
}
|
||||
|
||||
// v35 分层 Python 环境:扩展包申请与会话绑定。层目录本身在环境根,不入库。
|
||||
var execEnvUp = []string{
|
||||
`CREATE TABLE IF NOT EXISTS exec_env_requests (
|
||||
|
||||
@@ -58,6 +58,7 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
49: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏
|
||||
50: false, // 角色导入 staging 为独立表;正式角色读取在激活前不变
|
||||
51: true, // 超级管理员标记是账号权威边界;不认识它的旧账号写者必须排除
|
||||
52: false, // intent_session 是新表;旧制品不读取会话内的意图状态
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
Reference in New Issue
Block a user