@@ -3,7 +3,7 @@ package server
import (
"context"
"encoding/json"
"fmt "
"errors "
"net/http"
"sort"
"strings"
@@ -115,105 +115,6 @@ func settle(t *testing.T, h http.Handler, out map[string]any) map[string]any {
return out
}
// 会话忙(409)是既有语义,不改:占槽位的回合(普通回合、澄清回合)在忙时如
// 实 409,意图命中的命令则不占槽位、会话忙时也能用。测试侧要解决的是「等发得
// 出去」,而不是放宽断言:上一轮的 run 行在结算提交那一刻就终态了,槽位要到收
// 尾(补锚点、发终态事件、摘执行句柄)之后才释放,机器繁忙时这段窗口会被放大,
// 于是「run 一终态就立刻发下一轮」会偶发撞上忙 409。
// postConversationTurn 发一轮消息:先等会话空闲(见 waitConversationIdle),
// 发车时若仍撞上忙(409)则在「忙拒没有留下痕迹」的前提下按 deadline 重发。
// 忙不写消息与运行,但抢槽位发生在意图会话折叠之后——被忙拒掉的轮次仍可能已经
// 消费或挂起了会话,再发一次走的就是另一条路径了;所以每次重发前先比对意图会
// 话状态:变过了就如实失败,没变过才重发。
func postConversationTurn ( t * testing . T , srv * Server , h http . Handler , st * store . Store ,
convID , text string ) ( int , map [ string ] any ) {
t . Helper ( )
waitConversationIdle ( t , srv , h , convID )
deadline := time . Now ( ) . Add ( testRunTerminalTimeout )
for {
before := intentSessionTrace ( t , st , convID )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : text } )
if code != http . StatusConflict {
return code , out
}
if after := intentSessionTrace ( t , st , convID ) ; after != before {
t . Fatalf ( "%s:忙(409)之前已改动意图会话,重发会走另一条路径:%s → %s" , text , before , after )
}
if ! time . Now ( ) . Before ( deadline ) {
t . Fatalf ( "%s:会话持续忙(409)超过 %v: %v" , text , testRunTerminalTimeout , out )
}
time . Sleep ( 20 * time . Millisecond )
}
}
// waitConversationIdle 等会话真正让出槽位:既没有未终态运行,也没有仍在本地
// 执行的 run——执行句柄在槽位释放之后才摘除(runmgr 的 execute 收尾),所以
// 「没有活跃句柄」就是槽位已空的可观测证据,不用赌收尾窗口有多宽。
func waitConversationIdle ( t * testing . T , srv * Server , h http . Handler , convID string ) {
t . Helper ( )
deadline := time . Now ( ) . Add ( testRunTerminalTimeout )
for time . Now ( ) . Before ( deadline ) {
if code , out := doJSON ( t , h , "GET" , "/api/conversations/" + convID , nil ) ; code == 200 &&
! conversationHasOpenRun ( out ) && ! conversationHasActiveRun ( srv , out ) {
return
}
time . Sleep ( 5 * time . Millisecond )
}
t . Fatalf ( "会话 %s 在 %v 内未空闲" , convID , testRunTerminalTimeout )
}
// conversationHasOpenRun 看会话里是否还有未终态运行(结算尚未提交)。
func conversationHasOpenRun ( detail map [ string ] any ) bool {
for _ , run := range conversationRuns ( detail ) {
switch run [ "status" ] {
case string ( core . RunCompleted ) , string ( core . RunFailed ) ,
string ( core . RunCancelled ) , string ( core . RunInterrupted ) :
default :
return true
}
}
return false
}
// conversationHasActiveRun 看会话里是否还有本地在执行的 run(槽位未让出)。
func conversationHasActiveRun ( srv * Server , detail map [ string ] any ) bool {
for _ , run := range conversationRuns ( detail ) {
if id , _ := run [ "id" ] . ( string ) ; id != "" && srv . mgr . RunActiveLocally ( id ) {
return true
}
}
return false
}
func conversationRuns ( detail map [ string ] any ) [ ] map [ string ] any {
raw , _ := detail [ "runs" ] . ( [ ] any )
runs := make ( [ ] map [ string ] any , 0 , len ( raw ) )
for _ , item := range raw {
if run , ok := item . ( map [ string ] any ) ; ok {
runs = append ( runs , run )
}
}
return runs
}
// intentSessionTrace 描述会话上当前的意图会话状态(忙拒前后各取一次比对)。
func intentSessionTrace ( t * testing . T , st * store . Store , convID string ) string {
t . Helper ( )
conv , err := st . GetConversation ( store . OwnerDefault , convID )
if err != nil {
t . Fatal ( err )
}
sess , err := st . GetIntentSession ( store . OwnerDefault , string ( conv . ProjectID ) , convID )
if err == store . ErrNotFound {
return "none"
}
if err != nil {
t . Fatal ( err )
}
return fmt . Sprintf ( "rev=%d exp=%v slots=%v" , sess . Revision , sess . Expectation , sess . Slots )
}
// 缺必填槽位 → 澄清并挂起;下一轮补齐 → 按同一条准入与派发路径执行。
func TestIntentSessionAnswerCompletesPendingAction ( t * testing . T ) {
srv , st , gate , log := cancelServer ( t )
@@ -223,7 +124,7 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
gate . script ( "取消运行" , cancelStart ( nil , [ ] intentgate . SlotQuestion {
{ Key : "run_id" , Question : runIDQ } , { Key : "reason" , Question : "取消原因?" } } ) )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行" )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "取消运行" } )
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
@@ -246,7 +147,7 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
// 下一轮:仅补齐槽位(库层挂起预期命中分支的同形结果)。
answerText := "编号:R-7 原因:误触发"
gate . script ( answerText , cancelAnswer ( map [ string ] string { "run_id" : "R-7" , "reason" : "误触发" } , nil ) )
code , out = postConversationTurn ( t , srv , h , st , convID , answerText )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : answerText } )
if code != 200 || out [ "command" ] != "demo.run-cancel" {
t . Fatalf ( "补齐后应派发同一动作:%d %v" , code , out )
}
@@ -268,12 +169,12 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
// 两轮部分填充:先 set_slot 落一个槽位(仍缺 → 重新澄清),再由回答补齐。
func TestIntentSessionSetSlotThenAnswerCompletes ( t * testing . T ) {
srv , st , gate , log := cancelServer ( t )
srv , _ , gate , log := cancelServer ( t )
h := srv . Handler ( )
_ , convID := setupConversation ( t , h )
gate . script ( "取消运行" , cancelStart ( nil , [ ] intentgate . SlotQuestion {
{ Key : "run_id" , Question : "取消哪个运行?" } , { Key : "reason" , Question : "取消原因?" } } ) )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行" )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "取消运行" } )
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
@@ -282,7 +183,7 @@ func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
// 第二轮:库层 set_slot(只带一个槽位),仍缺原因 → 如实重新澄清。
gate . script ( "编号 R-7" , intentgate . MatchResult { Branch : "expectation_hit" , ID : "demo.run-cancel" ,
Commands : [ ] intentgate . Command { { Kind : intentgate . CommandSetSlot , Name : "run_id" , Value : "R-7" } } } )
code , out = postConversationTurn ( t , srv , h , st , convID , "编号 R-7" )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "编号 R-7" } )
if code != 200 {
t . Fatalf ( "set_slot 轮:%d %v" , code , out )
}
@@ -304,7 +205,7 @@ func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
// 第三轮:补上原因 → 派发,两个槽位都来自会话累计。
gate . script ( "原因:误触发" , cancelAnswer ( map [ string ] string { "reason" : "误触发" } , nil ) )
code , out = postConversationTurn ( t , srv , h , st , convID , "原因:误触发" )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "原因:误触发" } )
if code != 200 || out [ "command" ] != "demo.run-cancel" {
t . Fatalf ( "补齐后应派发:%d %v" , code , out )
}
@@ -321,7 +222,7 @@ func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
_ , convID := setupConversation ( t , h )
gate . script ( "取消运行" , cancelStart ( nil , [ ] intentgate . SlotQuestion {
{ Key : "run_id" , Question : "取消哪个运行?" } , { Key : "reason" , Question : "取消原因?" } } ) )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行" )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "取消运行" } )
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
@@ -329,7 +230,7 @@ func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
gate . script ( "算了不取消了" , intentgate . MatchResult { Branch : "matched" , ID : "demo.run-cancel" ,
Commands : [ ] intentgate . Command { { Kind : intentgate . CommandCancel , Target : "demo.run-cancel" } } } )
code , out = postConversationTurn ( t , srv , h , st , convID , "算了不取消了" )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "算了不取消了" } )
if code != 200 {
t . Fatalf ( "取消轮:%d %v" , code , out )
}
@@ -354,7 +255,7 @@ func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
// 取消之后的回答不得补齐旧动作(这里识别仍不完整 → 只澄清,不派发)。
gate . script ( "编号:R-7" , cancelAnswer ( map [ string ] string { "run_id" : "R-7" } ,
[ ] intentgate . SlotQuestion { { Key : "reason" , Question : "取消原因?" } } ) )
code , out = postConversationTurn ( t , srv , h , st , convID , "编号:R-7" )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "编号:R-7" } )
if code != 200 {
t . Fatalf ( "取消后回答轮:%d %v" , code , out )
}
@@ -376,7 +277,8 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
// 第一轮:抽到 run_id,仍缺原因 → 挂起(累计槽位含 run_id)。
gate . script ( "取消运行 编号:R-7" , cancelStart ( map [ string ] string { "run_id" : "R-7" } ,
[ ] intentgate . SlotQuestion { { Key : "reason" , Question : "取消原因?" } } ) )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行 编号:R-7 ")
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages ",
map [ string ] any { "text" : "取消运行 编号:R-7" } )
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
@@ -397,7 +299,7 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
// 第二轮只补了原因:过期的 run_id 不得被借来凑齐动作。
answerText := "原因:误触发"
gate . script ( answerText , cancelStart ( map [ string ] string { "reason" : "误触发" } , nil ) )
code , out = postConversationTurn ( t , srv , h , st , convID , answerText )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : answerText } )
if code != 200 {
t . Fatalf ( "过期后的回答轮:%d %v" , code , out )
}
@@ -439,7 +341,7 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
t . Fatal ( err )
}
gate . script ( "清理缓存" , otherIntentStart ( "demo.purge" ) )
code , out = postConversationTurn ( t , srv , h , st , convID , "清理缓存" )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "清理缓存" } )
if code != 200 || out [ "command" ] != "demo.purge" {
t . Fatalf ( "新任务应照常派发:%d %v" , code , out )
}
@@ -448,6 +350,105 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
}
}
// reportSpec 是第二个动作(生成报告):必填 scope,并声明了可选 run_id。
// 声明过的键即准入会映射的键,所以上一轮继承来的运行号会在派发参数里
// 现形——这正是回归要断言的那条泄漏路径。
var reportSpec = agentcommand . CommandSpec { ID : "demo.report" , Title : "生成报告" ,
ParamsSchema : json . RawMessage ( ` { "type":"object","required":["text","scope"],
"properties": { "scope": { "type":"string"},"run_id": { "type":"string"}}} ` ) }
// reportStart 是第二个动作的一次识别(缺 scope → 路由层澄清 + 挂起)。
func reportStart ( slots map [ string ] string , missing [ ] intentgate . SlotQuestion ) intentgate . MatchResult {
return intentgate . MatchResult { Branch : "matched" , ID : "demo.report" , Slots : slots , Missing : missing ,
Commands : [ ] intentgate . Command { { Kind : intentgate . CommandStart ,
Action : intentgate . ActionRef { Kind : intentgate . ActionAuto , ID : "demo.report" } , Slots : slots } } }
}
// reportAnswer 是对第二个动作挂起预期的回答(库层 expectation_hit 同形)。
func reportAnswer ( slots map [ string ] string ) intentgate . MatchResult {
return intentgate . MatchResult { Branch : "expectation_hit" , ID : "demo.report" , Slots : slots }
}
// 换动作即换任务(跨动作槽位继承的回归序列):A 抽到运行号后仍缺原因而
// 挂起 → 用户改口做另一个动作 B(B 缺 scope)→ 用户补齐 B 的 scope →
// B 派发时只带 B 自己的槽位,A 的运行号绝不跟着过去。
func TestIntentSessionNewActionDoesNotInheritOldSlots ( t * testing . T ) {
srv , st , gate , log := cancelServer ( t )
if err := srv . Bus . RegisterBuiltin ( reportSpec , "" , func ( _ context . Context , req cmdbus . Request ) ( any , error ) {
log . record ( req )
return map [ string ] any { "ok" : true } , nil
} ) ; err != nil {
t . Fatal ( err )
}
h := srv . Handler ( )
projectID , convID := setupConversation ( t , h )
// T1:动作 A( demo.run-cancel)抽到 run_id,仍缺原因 → 挂起。
gate . script ( "取消运行 编号:R-7" , cancelStart ( map [ string ] string { "run_id" : "R-7" } ,
[ ] intentgate . SlotQuestion { { Key : "reason" , Question : "取消原因?" } } ) )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" ,
map [ string ] any { "text" : "取消运行 编号:R-7" } )
if code != 200 {
t . Fatalf ( "A 轮:%d %v" , code , out )
}
settle ( t , h , out )
pending := pendingIntent ( t , st , projectID , convID )
if pending == nil || pending . Expectation . Action . ID != "demo.run-cancel" || pending . Slots [ "run_id" ] != "R-7" {
t . Fatalf ( "A 未挂起/未累计槽位:%+v" , pending )
}
// T2:用户改口做动作 B( demo.report,缺 scope)→ 新预期,不继承 A 的槽位。
scopeQ := "哪个范围?"
gate . script ( "生成报告" , reportStart ( nil , [ ] intentgate . SlotQuestion { { Key : "scope" , Question : scopeQ } } ) )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" ,
map [ string ] any { "text" : "生成报告" } )
if code != 200 {
t . Fatalf ( "B 轮:%d %v" , code , out )
}
settle ( t , h , out )
if _ , executed := out [ "command" ] ; executed {
t . Fatalf ( "槽位未就绪不得派发:%v" , out )
}
if c := clarifyOf ( t , out ) ; c [ "command" ] != "demo.report" || c [ "question" ] != scopeQ {
t . Fatalf ( "B 的澄清诉求:%v" , c )
}
if len ( log . snapshot ( ) ) != 0 {
t . Fatalf ( "澄清轮不得派发:%v" , log . snapshot ( ) )
}
pending = pendingIntent ( t , st , projectID , convID )
if pending == nil || pending . Expectation == nil || pending . Expectation . Action . ID != "demo.report" {
t . Fatalf ( "B 未挂起预期:%+v" , pending )
}
if _ , leaked := pending . Slots [ "run_id" ] ; leaked {
t . Fatalf ( "A 的槽位被 B 继承:%+v" , pending . Slots )
}
// T3:用户补齐 B 的 scope → B 派发,参数里只有 B 的槽位(没有 A 的 run_id)。
answerText := "范围:华东"
gate . script ( answerText , reportAnswer ( map [ string ] string { "scope" : "华东" } ) )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" ,
map [ string ] any { "text" : answerText } )
if code != 200 || out [ "command" ] != "demo.report" {
t . Fatalf ( "补齐后应派发 B: %d %v" , code , out )
}
if _ , asked := out [ "clarify" ] ; asked {
t . Fatalf ( "补齐后不应再澄清:%v" , out )
}
calls := log . snapshot ( )
if len ( calls ) != 1 {
t . Fatalf ( "派发次数:%d" , len ( calls ) )
}
if calls [ 0 ] [ "scope" ] != "华东" || calls [ 0 ] [ "text" ] != answerText {
t . Fatalf ( "B 的派发参数:%v" , calls [ 0 ] )
}
if _ , leaked := calls [ 0 ] [ "run_id" ] ; leaked {
t . Fatalf ( "A 的运行号漏进了 B 的派发参数:%v" , calls [ 0 ] )
}
if pendingIntent ( t , st , projectID , convID ) != nil {
t . Fatal ( "补齐后会话应被消费" )
}
}
// otherIntentStart 是另一个动作的完整识别(换任务)。
func otherIntentStart ( id string ) intentgate . MatchResult {
return intentgate . MatchResult { Branch : "matched" , ID : id ,
@@ -463,7 +464,7 @@ func TestIntentSessionIsolatesConversations(t *testing.T) {
projectB , convB := setupConversation ( t , h )
gate . script ( "取消运行" , cancelStart ( nil , [ ] intentgate . SlotQuestion {
{ Key : "run_id" , Question : "取消哪个运行?" } , { Key : "reason" , Question : "取消原因?" } } ) )
code , out := postConversationTurn ( t , srv , h , st , convA , "取消运行" )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convA + "/messages" , map [ string ] any { "text" : "取消运行" } )
if code != 200 {
t . Fatalf ( "A 会话第一轮:%d %v" , code , out )
}
@@ -478,7 +479,7 @@ func TestIntentSessionIsolatesConversations(t *testing.T) {
// B 会话收到同样的回答:A 的槽位不得被借走(没有 A 的累计值)。
answer := "编号:R-7 原因:误触发"
gate . script ( answer , cancelAnswer ( map [ string ] string { "run_id" : "R-7" , "reason" : "误触发" } , nil ) )
code , out = postConversationTurn ( t , srv , h , st , convB , answer )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convB + "/messages" , map [ string ] any { "text" : answer } )
if code != 200 {
t . Fatalf ( "B 会话回答轮:%d %v" , code , out )
}
@@ -495,19 +496,19 @@ func TestIntentSessionIsolatesConversations(t *testing.T) {
func TestIntentSessionStillRequiresHighRiskConfirmation ( t * testing . T ) {
risky := cancelSpec
risky . Danger = agentcommand . DangerHigh
srv , st , gate , log := cancelServerWith ( t , risky )
srv , _ , gate , log := cancelServerWith ( t , risky )
h := srv . Handler ( )
_ , convID := setupConversation ( t , h )
gate . script ( "取消运行" , cancelStart ( nil , [ ] intentgate . SlotQuestion {
{ Key : "run_id" , Question : "取消哪个运行?" } , { Key : "reason" , Question : "取消原因?" } } ) )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行" )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "取消运行" } )
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
settle ( t , h , out )
answer := "编号:R-7 原因:误触发"
gate . script ( answer , cancelAnswer ( map [ string ] string { "run_id" : "R-7" , "reason" : "误触发" } , nil ) )
code , out = postConversationTurn ( t , srv , h , st , convID , answer )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : answer } )
if code != 200 || out [ "command" ] != "demo.run-cancel" {
t . Fatalf ( "补齐后应派发到总线:%d %v" , code , out )
}
@@ -526,7 +527,7 @@ func TestIntentSessionInvalidAnswerKeepsClarifying(t *testing.T) {
_ , convID := setupConversation ( t , h )
gate . script ( "取消运行" , cancelStart ( nil , [ ] intentgate . SlotQuestion {
{ Key : "run_id" , Question : "取消哪个运行?" } } ) )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行" )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "取消运行" } )
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
@@ -535,7 +536,7 @@ func TestIntentSessionInvalidAnswerKeepsClarifying(t *testing.T) {
gate . script ( "编号:R-9" , intentgate . MatchResult { Branch : "expectation_hit" , ID : "demo.run-cancel" ,
Slots : map [ string ] string { "run_id" : "R-9" } ,
Invalid : [ ] intentgate . SlotDefect { { Key : "run_id" , Reason : "unknown run" , Question : "取消哪个运行?" , Invalid : true } } } )
code , out = postConversationTurn ( t , srv , h , st , convID , "编号:R-9" )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "编号:R-9" } )
if code != 200 {
t . Fatalf ( "非法补充轮:%d %v" , code , out )
}
@@ -595,7 +596,7 @@ func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
}
run := func ( t * testing . T , sessions IntentSessions ) [ ] string {
t . Helper ( )
srv , st , gate , log := cancelServer ( t )
srv , _ , gate , log := cancelServer ( t )
if sessions != nil {
srv . SetIntentSessionService ( sessions )
}
@@ -604,7 +605,7 @@ func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
_ , convID := setupConversation ( t , h )
var summaries [ ] string
for _ , text := range [ ] string { "取消运行 编号:R-3 原因:误触发" , "今天天气如何" , "算了" , "取消运行" } {
code , out := postConversationTurn ( t , srv , h , st , convID , text )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : text } )
if code != 200 {
t . Fatalf ( "%s: %d %v" , text , code , out )
}
@@ -636,6 +637,62 @@ func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
}
}
// brokenCancelStore 是「放弃补充时写库失败」的替身:其余方法照常委派给
// 真实会话服务,只有 cancel 的折叠返回非 ErrStale 的存储错误(真实写库
// 故障的注入点)。
type brokenCancelStore struct { IntentSessions }
func ( b brokenCancelStore ) ApplyAnswer ( key intentsession . Key , revision int ,
answer intentsession . Answer ) ( * intentsession . Outcome , error ) {
if answer . Cancel {
return nil , errors . New ( "clear intent session: injected store failure" )
}
return b . IntentSessions . ApplyAnswer ( key , revision , answer )
}
// 放弃补充时写库失败 → fail-closed:本轮报错,绝不降级成「没有会话语义」
// 去规划/派发一次取消(挂起行里的运行号仍在,会被解成取消靶子)。
func TestIntentSessionCancelFailsClosedOnStoreError ( t * testing . T ) {
srv , st , gate , log := cancelServer ( t )
h := srv . Handler ( )
projectID , convID := setupConversation ( t , h )
runID := liveRun ( t , h , convID )
seedBoundRun ( t , st , projectID , convID , runID )
srv . SetIntentSessionService ( brokenCancelStore { IntentSessions : intentsession . New ( st ) } )
// 放弃本次槽位补充(库层 cancel,指向正在挂起的 A)——写库故障就在此刻。
gate . script ( "算了不取消了" , intentgate . MatchResult { Branch : "matched" , ID : "demo.run-cancel" ,
Commands : [ ] intentgate . Command { { Kind : intentgate . CommandCancel , Target : "demo.run-cancel" } } } )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" ,
map [ string ] any { "text" : "算了不取消了" } )
if code == 200 {
t . Fatalf ( "写库失败的一轮不得照常应答:%d %v" , code , out )
}
if _ , asked := out [ "cancel" ] ; asked {
t . Fatalf ( "写库失败时不得回执一次取消:%v" , out )
}
if _ , ran := out [ "run" ] ; ran {
t . Fatalf ( "写库失败时不得起新回合:%v" , out )
}
if len ( log . snapshot ( ) ) != 0 {
t . Fatalf ( "写库失败时不得派发:%v" , log . snapshot ( ) )
}
// 挂起行确实没被清掉(故障没被伪装成成功),这一轮因此更不能取消它。
if pendingIntent ( t , st , projectID , convID ) == nil {
t . Fatal ( "注入的写库失败不应真的清空挂起预期" )
}
// 绝不能真的取消那个运行:给可能的误派发一点时间,再如实断言。
deadline := time . Now ( ) . Add ( 500 * time . Millisecond )
for time . Now ( ) . Before ( deadline ) {
if code , d := doJSON ( t , h , "GET" , "/api/runs/" + runID , nil ) ; code == 200 {
if r , _ := d [ "run" ] . ( map [ string ] any ) ; r [ "status" ] == string ( core . RunCancelled ) {
t . Fatalf ( "写库失败的一轮仍然取消了运行 %s:%v" , runID , r )
}
}
time . Sleep ( 20 * time . Millisecond )
}
}
// inertSessions 是「关闭会话语义」的替身:任何调用都如实报告没有会话。
type inertSessions struct { }