@@ -3,6 +3,7 @@ package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
@@ -15,6 +16,7 @@ import (
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsession"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// A1/U-A1b 会话语义的 chat 端到端(合成数据 + 现有 fakes):缺槽位动作先
@@ -113,6 +115,105 @@ 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 )
@@ -122,7 +223,7 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
gate . script ( "取消运行" , cancelStart ( nil , [ ] intentgate . SlotQuestion {
{ Key : "run_id" , Question : runIDQ } , { Key : "reason" , Question : "取消原因?" } } ) )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "取消运行" } )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行" )
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
@@ -145,7 +246,7 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
// 下一轮:仅补齐槽位(库层挂起预期命中分支的同形结果)。
answerText := "编号:R-7 原因:误触发"
gate . script ( answerText , cancelAnswer ( map [ string ] string { "run_id" : "R-7" , "reason" : "误触发" } , nil ) )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : answerText } )
code , out = postConversationTurn ( t , srv , h , st , convID , answerText )
if code != 200 || out [ "command" ] != "demo.run-cancel" {
t . Fatalf ( "补齐后应派发同一动作:%d %v" , code , out )
}
@@ -167,12 +268,12 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
// 两轮部分填充:先 set_slot 落一个槽位(仍缺 → 重新澄清),再由回答补齐。
func TestIntentSessionSetSlotThenAnswerCompletes ( t * testing . T ) {
srv , _ , gate , log := cancelServer ( 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 := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "取消运行" } )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行" )
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
@@ -181,7 +282,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 = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "编号 R-7" } )
code , out = postConversationTurn ( t , srv , h , st , convID , "编号 R-7" )
if code != 200 {
t . Fatalf ( "set_slot 轮:%d %v" , code , out )
}
@@ -203,7 +304,7 @@ func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
// 第三轮:补上原因 → 派发,两个槽位都来自会话累计。
gate . script ( "原因:误触发" , cancelAnswer ( map [ string ] string { "reason" : "误触发" } , nil ) )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "原因:误触发" } )
code , out = postConversationTurn ( t , srv , h , st , convID , "原因:误触发" )
if code != 200 || out [ "command" ] != "demo.run-cancel" {
t . Fatalf ( "补齐后应派发:%d %v" , code , out )
}
@@ -220,7 +321,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 := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "取消运行" } )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行" )
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
@@ -228,7 +329,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 = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "算了不取消了" } )
code , out = postConversationTurn ( t , srv , h , st , convID , "算了不取消了" )
if code != 200 {
t . Fatalf ( "取消轮:%d %v" , code , out )
}
@@ -253,7 +354,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 = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "编号:R-7" } )
code , out = postConversationTurn ( t , srv , h , st , convID , "编号:R-7" )
if code != 200 {
t . Fatalf ( "取消后回答轮:%d %v" , code , out )
}
@@ -275,8 +376,7 @@ 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 := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages ",
map [ string ] any { "text" : "取消运行 编号:R-7" } )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行 编号:R-7 ")
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
@@ -297,7 +397,7 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
// 第二轮只补了原因:过期的 run_id 不得被借来凑齐动作。
answerText := "原因:误触发"
gate . script ( answerText , cancelStart ( map [ string ] string { "reason" : "误触发" } , nil ) )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : answerText } )
code , out = postConversationTurn ( t , srv , h , st , convID , answerText )
if code != 200 {
t . Fatalf ( "过期后的回答轮:%d %v" , code , out )
}
@@ -339,7 +439,7 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
t . Fatal ( err )
}
gate . script ( "清理缓存" , otherIntentStart ( "demo.purge" ) )
code , out = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "清理缓存" } )
code , out = postConversationTurn ( t , srv , h , st , convID , "清理缓存" )
if code != 200 || out [ "command" ] != "demo.purge" {
t . Fatalf ( "新任务应照常派发:%d %v" , code , out )
}
@@ -363,7 +463,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 := doJSON ( t , h , "POST" , "/api/conversations/" + convA + "/messages" , map [ string ] any { "text" : "取消运行" } )
code , out := postConversationTurn ( t , srv , h , st , convA , "取消运行" )
if code != 200 {
t . Fatalf ( "A 会话第一轮:%d %v" , code , out )
}
@@ -378,7 +478,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 = doJSON ( t , h , "POST" , "/api/conversations/" + convB + "/messages" , map [ string ] any { "text" : answer } )
code , out = postConversationTurn ( t , srv , h , st , convB , answer )
if code != 200 {
t . Fatalf ( "B 会话回答轮:%d %v" , code , out )
}
@@ -395,19 +495,19 @@ func TestIntentSessionIsolatesConversations(t *testing.T) {
func TestIntentSessionStillRequiresHighRiskConfirmation ( t * testing . T ) {
risky := cancelSpec
risky . Danger = agentcommand . DangerHigh
srv , _ , gate , log := cancelServerWith ( t , risky )
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 := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "取消运行" } )
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 = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : answer } )
code , out = postConversationTurn ( t , srv , h , st , convID , answer )
if code != 200 || out [ "command" ] != "demo.run-cancel" {
t . Fatalf ( "补齐后应派发到总线:%d %v" , code , out )
}
@@ -426,7 +526,7 @@ func TestIntentSessionInvalidAnswerKeepsClarifying(t *testing.T) {
_ , convID := setupConversation ( t , h )
gate . script ( "取消运行" , cancelStart ( nil , [ ] intentgate . SlotQuestion {
{ Key : "run_id" , Question : "取消哪个运行?" } } ) )
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "取消运行" } )
code , out := postConversationTurn ( t , srv , h , st , convID , "取消运行" )
if code != 200 {
t . Fatalf ( "第一轮:%d %v" , code , out )
}
@@ -435,7 +535,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 = doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : "编号:R-9" } )
code , out = postConversationTurn ( t , srv , h , st , convID , "编号:R-9" )
if code != 200 {
t . Fatalf ( "非法补充轮:%d %v" , code , out )
}
@@ -495,7 +595,7 @@ func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
}
run := func ( t * testing . T , sessions IntentSessions ) [ ] string {
t . Helper ( )
srv , _ , gate , log := cancelServer ( t )
srv , st , gate , log := cancelServer ( t )
if sessions != nil {
srv . SetIntentSessionService ( sessions )
}
@@ -504,7 +604,7 @@ func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
_ , convID := setupConversation ( t , h )
var summaries [ ] string
for _ , text := range [ ] string { "取消运行 编号:R-3 原因:误触发" , "今天天气如何" , "算了" , "取消运行" } {
code , out := doJSON ( t , h , "POST" , "/api/conversations/" + convID + "/messages" , map [ string ] any { "text" : text } )
code , out := postConversationTurn ( t , srv , h , st , convID , text )
if code != 200 {
t . Fatalf ( "%s: %d %v" , text , code , out )
}