2026-07-27 23:29:02 +08:00
package runtime
import (
"context"
"encoding/json"
2026-08-28 22:23:13 +08:00
"errors"
2026-07-27 23:29:02 +08:00
"strings"
"testing"
2026-08-28 22:23:13 +08:00
"code.tczkiot.com/wlw/ai-agent/contract"
2026-08-21 00:41:07 +08:00
ai "code.tczkiot.com/wlw/ai-agent/internal/ai"
2026-08-28 22:23:13 +08:00
aitooling "code.tczkiot.com/wlw/ai-agent/internal/ai/tooling"
2026-08-21 00:41:07 +08:00
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
svc "code.tczkiot.com/wlw/ai-agent/internal/services"
2026-07-27 23:29:02 +08:00
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
2026-08-28 22:23:13 +08:00
"gorm.io/gorm/schema"
2026-07-27 23:29:02 +08:00
)
2026-08-28 22:23:13 +08:00
func TestAgentLoopRegistersFixedCapabilityAliases ( t * testing . T ) {
2026-07-29 11:36:14 +08:00
turn := agentLoopTurn { AllowedTools : [] string {
"builtin/conversation_context" ,
"graph/triage_service_request" ,
"graph/triage_service_request" ,
}}
definitions := agentLoopToolDefinitions ( turn )
names := make ( map [ string ] bool , len ( definitions ))
for _ , definition := range definitions {
2026-08-28 22:23:13 +08:00
if names [ definition . Name ] {
t . Fatalf ( "duplicate registered function alias %q: %#v" , definition . Name , definitions )
}
2026-07-29 11:36:14 +08:00
names [ definition . Name ] = true
}
for _ , expected := range [] string {
"tool_search" ,
2026-08-28 22:23:13 +08:00
"conversation_decision" ,
2026-07-29 11:36:14 +08:00
"builtin/conversation_context" ,
"graph/triage_service_request" ,
} {
if ! names [ expected ] {
t . Fatalf ( "missing registered function alias %q: %#v" , expected , definitions )
}
}
}
2026-07-29 17:02:40 +08:00
func TestConversationDecisionIsStructuredAndValidated ( t * testing . T ) {
2026-08-28 22:23:13 +08:00
decision , err := parseConversationDecision ( `{"action":"handoff","reason":"customer requested a human","reply":"","handoff_initiator":"customer","handoff_confirmed":true}` )
2026-07-29 17:02:40 +08:00
if err != nil {
t . Fatalf ( "parse handoff decision: %v" , err )
}
if decision . Action != ConversationActionHandoff || decision . Reason != "customer requested a human" {
t . Fatalf ( "unexpected handoff decision: %#v" , decision )
}
for _ , raw := range [] string {
2026-08-28 22:23:13 +08:00
`{"action":"unknown","reason":"x","reply":"x","handoff_initiator":"none","handoff_confirmed":false}` ,
`{"action":"reply","reason":"x","reply":"","handoff_initiator":"none","handoff_confirmed":false}` ,
`{"action":"ask_handoff_confirmation","reason":"x","reply":"confirm?","handoff_initiator":"customer","handoff_confirmed":false}` ,
2026-07-29 17:02:40 +08:00
} {
if _ , err := parseConversationDecision ( raw ); err == nil {
t . Fatalf ( "expected invalid decision to fail: %s" , raw )
}
}
}
func TestAgentLoopRecordsConversationDecision ( t * testing . T ) {
state := agentLoopExecutionState {}
var calls [] svc . AgentLoopToolCallInput
execute := NewAgentLoopEngine (). toolSearchExecutor ( RunInput {}, agentLoopTurn {}, & state , & calls )
if _ , err := execute ( context . Background (), ai . ToolCall {
2026-08-28 22:23:13 +08:00
Name : "conversation_decision" , Arguments : `{"action":"handoff","reason":"customer requested a human","reply":"","handoff_initiator":"customer","handoff_confirmed":true}` ,
2026-07-29 17:02:40 +08:00
}); err != nil {
t . Fatalf ( "record decision: %v" , err )
}
if state . Decision == nil || state . Decision . Action != ConversationActionHandoff {
t . Fatalf ( "decision was not stored: %#v" , state . Decision )
}
if len ( calls ) != 1 || calls [ 0 ]. ToolCode != "conversation_decision" || calls [ 0 ]. Status != "completed" {
t . Fatalf ( "decision audit missing: %#v" , calls )
}
}
func TestResolveAgentLoopReplyKeepsNormalModelReplyWithoutDecision ( t * testing . T ) {
reply , handoff , reason , err := resolveAgentLoopReply ( "你好,有什么可以帮你?" , nil )
if err != nil || handoff || reason != "" || reply != "你好,有什么可以帮你?" {
t . Fatalf ( "unexpected normal reply resolution: reply=%q handoff=%t reason=%q err=%v" , reply , handoff , reason , err )
}
}
2026-08-28 22:23:13 +08:00
func TestPlatformImageRequiresVisionCapabilityAndSelectsVisionModel ( t * testing . T ) {
config := models . AIConfig { Platform : true , ModelName : "qwen-plus" }
if err := validatePlatformVisionCapability ( config , enums . IMMessageTypeImage ); err == nil || ! strings . Contains ( err . Error (), "图片理解模型未启用" ) {
t . Fatalf ( "expected disabled platform vision error, got %v" , err )
}
config . VisionEnabled = true
if err := validatePlatformVisionCapability ( config , enums . IMMessageTypeImage ); err == nil || ! strings . Contains ( err . Error (), "图片理解模型未配置" ) {
t . Fatalf ( "expected missing platform vision model error, got %v" , err )
}
config . VisionModel = "qwen3-vl-plus"
if err := validatePlatformVisionCapability ( config , enums . IMMessageTypeImage ); err != nil {
t . Fatalf ( "configured platform vision was rejected: %v" , err )
}
selectPlatformVisionModel ( & config , 1 )
if config . ModelName != "qwen3-vl-plus" {
t . Fatalf ( "platform image model = %q, want qwen3-vl-plus" , config . ModelName )
}
textConfig := models . AIConfig { Platform : true , ModelName : "qwen-plus" , VisionEnabled : false }
if err := validatePlatformVisionCapability ( textConfig , enums . IMMessageTypeText ); err != nil {
t . Fatalf ( "ordinary text must not depend on vision capability: %v" , err )
}
selectPlatformVisionModel ( & textConfig , 0 )
if textConfig . ModelName != "qwen-plus" {
t . Fatalf ( "ordinary text model changed to %q" , textConfig . ModelName )
}
}
2026-07-29 17:02:40 +08:00
func TestResolveAgentLoopReplyUsesStructuredHandoffDecision ( t * testing . T ) {
reply , handoff , reason , err := resolveAgentLoopReply ( "模型自由文本不应生效" , & ConversationDecision {
Action : ConversationActionHandoff , Reason : "customer requested human support" , HandoffInitiator : HandoffInitiatorCustomer , HandoffConfirmed : true ,
})
if err != nil || ! handoff || reply != "" || reason != "customer requested human support" {
t . Fatalf ( "unexpected handoff resolution: reply=%q handoff=%t reason=%q err=%v" , reply , handoff , reason , err )
}
}
func TestNormalizeAgentLoopReplyAllowsEmptyInternalHandoff ( t * testing . T ) {
reply , err := normalizeAgentLoopReply ( "" , true )
if err != nil || reply != "" {
t . Fatalf ( "handoff must bypass customer reply normalization: reply=%q err=%v" , reply , err )
}
if _ , err := normalizeAgentLoopReply ( "" , false ); err == nil {
t . Fatal ( "ordinary empty model replies must still be rejected" )
}
}
2026-08-28 22:23:13 +08:00
func TestAgentLoopKnowledgeFallbackCanRequestHandoff ( t * testing . T ) {
agent := models . AIAgent {
KnowledgeIDs : "1" , FallbackMode : enums . AIAgentFallbackModeHandoff ,
FallbackMessage : "我暂时无法核实,马上为你转人工。" ,
2026-07-29 11:36:14 +08:00
}
2026-08-28 22:23:13 +08:00
policy := evaluateAgentLoopResponsePolicy ( agent , "" , nil )
prompt := buildAgentLoopSystemPrompt ( agent , true , "" , nil )
if ! policy . RequestHandoff || ! strings . Contains ( prompt , agent . FallbackMessage ) {
t . Fatalf ( "knowledge fallback was not applied: policy=%#v prompt=%q" , policy , prompt )
2026-07-29 11:36:14 +08:00
}
2026-08-28 22:23:13 +08:00
}
2026-07-29 11:36:14 +08:00
2026-08-28 22:23:13 +08:00
func TestAgentLoopPromptAvoidsRepeatingWelcomeMessage ( t * testing . T ) {
prompt := buildAgentLoopSystemPrompt ( models . AIAgent {}, false , "" , nil )
if ! strings . Contains ( prompt , "without repeating the welcome wording" ) {
t . Fatalf ( "conversation continuity instruction missing: %q" , prompt )
2026-07-29 11:36:14 +08:00
}
2026-08-28 22:23:13 +08:00
}
func TestAgentLoopHistoryExcludesOperationalFailureNotices ( t * testing . T ) {
engine := NewAgentLoopEngine ()
engine . history = func ( int64 , int ) [] models . Message {
return [] models . Message {
{ ID : 1 , SenderType : enums . IMSenderTypeCustomer , MessageType : enums . IMMessageTypeText , Content : "设备没有网络" },
{ ID : 2 , SenderType : enums . IMSenderTypeAI , MessageType : enums . IMMessageTypeText , ClientMsgID : "ai_error_1" , Content : "系统内置 AI 网关内部异常,请稍后重试。" },
{ ID : 3 , SenderType : enums . IMSenderTypeAI , MessageType : enums . IMMessageTypeText , ClientMsgID : "ai_reply_1" , Content : "已收到您的消息,但本次智能处理没有完成。请稍后重试,或回复“人工客服”继续处理。" },
{ ID : 4 , SenderType : enums . IMSenderTypeAI , MessageType : enums . IMMessageTypeText , ClientMsgID : "ai_reply_2" , Content : "请确认设备电源指示灯是否亮起。" },
}
}
prompt , count := engine . buildUserPrompt ( RunInput {
Conversation : models . Conversation { ID : 7 },
UserMessage : models . Message { ID : 5 , MessageType : enums . IMMessageTypeText , Content : "还是没有网络" },
AIAgent : models . AIAgent { ContextWindow : 20 },
})
if count != 2 {
t . Fatalf ( "history count = %d, want only customer and valid assistant messages" , count )
}
for _ , forbidden := range [] string { "智能处理没有完成" , "网关内部异常" } {
if strings . Contains ( prompt , forbidden ) {
t . Fatalf ( "operational failure notice leaked into model history: %s" , prompt )
}
}
for _ , expected := range [] string { "Customer: 设备没有网络" , "Assistant: 请确认设备电源指示灯是否亮起。" , "Current customer message:\n还是没有网络" } {
if ! strings . Contains ( prompt , expected ) {
t . Fatalf ( "valid conversation context %q missing: %s" , expected , prompt )
}
}
}
func TestAgentLoopPromptKeepsInternalNetworkPolicyConfidentialWithoutBlockingSafeDiagnosis ( t * testing . T ) {
prompt := buildAgentLoopSystemPrompt ( models . AIAgent {}, false , "" , nil )
turn := newAgentLoopEngineWithLoop ( nil ). prepareTurn ( context . Background (), RunInput { AIAgent : models . AIAgent {}}, nil )
if strings . Contains ( prompt , "traffic-shaping thresholds" ) {
t . Fatal ( "base prompt must not own host-specific confidentiality rules" )
}
for _ , expected := range [] string {
"Exact traffic-shaping thresholds" ,
"must not hide customer-facing symptoms" ,
"network service is temporarily unavailable" ,
"safe actionable troubleshooting" ,
} {
if ! strings . Contains ( turn . SystemPrompt , expected ) {
t . Fatalf ( "missing network confidentiality rule %q: %s" , expected , turn . SystemPrompt )
}
}
for _ , forbidden := range [] string {
"Never tell a customer whether throttling exists or does not exist" ,
"do not repeat the sensitive term" ,
} {
if strings . Contains ( turn . SystemPrompt , forbidden ) {
t . Fatalf ( "overbroad network restriction remains %q: %s" , forbidden , turn . SystemPrompt )
}
2026-07-29 11:36:14 +08:00
}
2026-08-28 22:23:13 +08:00
}
func TestAgentTurnPublishesOnlyFixedCapabilities ( t * testing . T ) {
engine := NewAgentLoopEngine ()
engine . retrieve = nil
engine . history = nil
turn := engine . prepareTurn ( context . Background (), RunInput { AIAgent : models . AIAgent {}}, nil )
for _ , code := range agentLoopSafeBuiltinCodes () {
if ! strings . Contains ( turn . SystemPrompt , code ) {
t . Fatalf ( "fixed capability %q missing from prompt:\n%s" , code , turn . SystemPrompt )
}
}
for _ , removedPrefix := range [] string { "skill/" , "workflow/" , "mcp/" } {
if strings . Contains ( turn . SystemPrompt , removedPrefix ) {
t . Fatalf ( "removed configurable capability %q leaked into prompt:\n%s" , removedPrefix , turn . SystemPrompt )
}
2026-07-29 11:36:14 +08:00
}
}
2026-08-28 22:23:13 +08:00
func TestAgentTurnPublishesAndExecutesMatchingBusinessReadTool ( t * testing . T ) {
t . Cleanup ( func () { _ = svc . SetBusinessReadTools ( nil ) })
var received contract . BusinessReadContext
err := svc . SetBusinessReadTools ([] contract . BusinessReadTool {{
Code : "business/card_diagnosis" ,
Description : "查询当前卡板的状态和流量" ,
CustomerTypes : [] string { "card" },
InputSchema : map [ string ] any { "type" : "object" , "properties" : map [ string ] any {}},
Execute : func ( _ context . Context , businessContext contract . BusinessReadContext , _ map [ string ] any ) ( any , error ) {
received = businessContext
return map [ string ] any { "status" : "normal" }, nil
},
2026-07-27 23:29:02 +08:00
}})
if err != nil {
2026-08-28 22:23:13 +08:00
t . Fatalf ( "register business tool: %v" , err )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
conversation := models . Conversation {
ID : 7 , CustomerType : "card" , CustomerID : 9 ,
CustomerExternalID : "card:9" , CustomerName : "卡号 50506783" ,
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
engine := NewAgentLoopEngine ()
engine . retrieve = nil
engine . history = nil
turn := engine . prepareTurn ( context . Background (), RunInput { Conversation : conversation , AIAgent : models . AIAgent {}}, nil )
if ! strings . Contains ( turn . SystemPrompt , "business/card_diagnosis" ) {
t . Fatalf ( "business capability missing from prompt: %s" , turn . SystemPrompt )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
definition , raw , err := executeAgentLoopReadTool ( context . Background (), conversation , models . AIAgent {}, "business/card_diagnosis" , nil , aitooling . Policy {
AllowedToolCodes : turn . AllowedTools ,
MaxTotalCalls : 3 ,
MaxArgumentBytes : 1024 ,
2026-07-27 23:29:02 +08:00
})
2026-08-28 22:23:13 +08:00
if err != nil {
t . Fatalf ( "execute business tool: %v" , err )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
if definition . Code != "business/card_diagnosis" || received . CustomerID != 9 || received . ConversationID != 7 {
t . Fatalf ( "unexpected business tool execution: definition=%#v context=%#v" , definition , received )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
var result map [ string ] any
if err := json . Unmarshal ([] byte ( raw ), & result ); err != nil || result [ "status" ] != "normal" {
t . Fatalf ( "unexpected business tool result: raw=%q err=%v" , raw , err )
2026-07-28 11:33:15 +08:00
}
2026-08-28 22:23:13 +08:00
nonCardTurn := engine . prepareTurn ( context . Background (), RunInput { Conversation : models . Conversation { CustomerType : "mall_user" }}, nil )
if strings . Contains ( nonCardTurn . SystemPrompt , "business/card_diagnosis" ) {
t . Fatalf ( "card capability leaked into a mall-user conversation: %s" , nonCardTurn . SystemPrompt )
2026-07-27 23:29:02 +08:00
}
}
2026-08-28 22:23:13 +08:00
func TestAgentTurnPrefetchesMatchedBusinessDataAndRecallsToolMemory ( t * testing . T ) {
t . Cleanup ( func () { _ = svc . SetBusinessReadTools ( nil ) })
executions := 0
if err := svc . SetBusinessReadTools ([] contract . BusinessReadTool {{
Code : "business/card_package_catalog" , Description : "package catalog" , CustomerTypes : [] string { "card" },
MatchIntent : func ( message string ) bool { return strings . Contains ( message , "订购套餐" ) },
Execute : func ( context . Context , contract . BusinessReadContext , map [ string ] any ) ( any , error ) {
executions ++
return [] map [ string ] any {{
"sequence" : 2 , "name" : "100G" , "current_start_at" : "2026-08-22 16:00:00" , "current_end_at" : "2026-08-31 23:59:59" ,
}}, nil
},
}}); err != nil {
t . Fatalf ( "register business tool: %v" , err )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
engine := NewAgentLoopEngine ()
engine . retrieve = nil
engine . history = func ( int64 , int ) [] models . Message { return nil }
engine . businessMemory = func ( int64 , int ) [] svc . BusinessToolMemory {
return [] svc . BusinessToolMemory {{ ToolCode : "business/card_package_catalog" , Result : `[{"sequence":2,"next_start_at":"2026-09-01 00:00:00"}]` }}
}
turn := engine . prepareTurn ( context . Background (), RunInput {
Conversation : models . Conversation { ID : 7 , CustomerType : "card" , CustomerID : 9 },
UserMessage : models . Message { Content : "订购套餐" },
}, nil )
for _ , want := range [] string { "Recent verified business tool memory" , "2026-09-01 00:00:00" , "Fresh required business data" , "2026-08-31 23:59:59" } {
if ! strings . Contains ( turn . UserPrompt , want ) {
t . Fatalf ( "turn prompt does not contain %q: %s" , want , turn . UserPrompt )
}
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
if len ( turn . PrefetchedToolCalls ) != 1 || turn . PrefetchedToolCalls [ 0 ]. ToolCode != "business/card_package_catalog" || turn . PrefetchedToolCalls [ 0 ]. Status != "completed" {
t . Fatalf ( "unexpected prefetched calls: %#v" , turn . PrefetchedToolCalls )
}
state := agentLoopExecutionState { VerifiedToolResults : cloneVerifiedToolResults ( turn . VerifiedToolResults )}
records := append ([] svc . AgentLoopToolCallInput ( nil ), turn . PrefetchedToolCalls ... )
raw , err := engine . toolSearchExecutor ( RunInput { Conversation : models . Conversation { ID : 7 , CustomerType : "card" , CustomerID : 9 }}, turn , & state , & records )( context . Background (), ai . ToolCall {
Name : "business/card_package_catalog" , Arguments : `{}` ,
2026-07-27 23:29:02 +08:00
})
2026-08-28 22:23:13 +08:00
if err != nil || ! strings . Contains ( raw , "2026-08-31 23:59:59" ) {
t . Fatalf ( "reuse prefetched result: raw=%q err=%v" , raw , err )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
if executions != 1 {
t . Fatalf ( "prefetched business lookup was executed again: %d" , executions )
2026-07-27 23:29:02 +08:00
}
}
2026-08-28 22:23:13 +08:00
func TestAgentTurnDoesNotRetryFailedPrefetch ( t * testing . T ) {
t . Cleanup ( func () { _ = svc . SetBusinessReadTools ( nil ) })
executions := 0
if err := svc . SetBusinessReadTools ([] contract . BusinessReadTool {{
Code : "business/device_diagnosis" , Description : "device diagnosis" , CustomerTypes : [] string { "device" },
MatchIntent : func ( message string ) bool { return strings . Contains ( message , "没网" ) },
Execute : func ( context . Context , contract . BusinessReadContext , map [ string ] any ) ( any , error ) {
executions ++
return nil , errors . New ( "database password secret must not leak" )
2026-07-27 23:29:02 +08:00
},
2026-08-28 22:23:13 +08:00
}}); err != nil {
t . Fatalf ( "register business tool: %v" , err )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
engine := NewAgentLoopEngine ()
engine . retrieve = nil
engine . history = nil
conversation := models . Conversation { ID : 8 , CustomerType : "device" , CustomerID : 10 }
turn := engine . prepareTurn ( context . Background (), RunInput {
Conversation : conversation , UserMessage : models . Message { Content : "设备没网" },
}, nil )
state := agentLoopExecutionState { VerifiedToolResults : cloneVerifiedToolResults ( turn . VerifiedToolResults )}
records := append ([] svc . AgentLoopToolCallInput ( nil ), turn . PrefetchedToolCalls ... )
_ , err := engine . toolSearchExecutor ( RunInput { Conversation : conversation }, turn , & state , & records )( context . Background (), ai . ToolCall {
Name : "business/device_diagnosis" , Arguments : `{}` ,
2026-07-27 23:29:02 +08:00
})
2026-08-28 22:23:13 +08:00
if err == nil || strings . Contains ( err . Error (), "password" ) || strings . Contains ( err . Error (), "secret" ) {
t . Fatalf ( "failed prefetch must return a safe non-retryable turn error: %v" , err )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
if executions != 1 {
t . Fatalf ( "failed prefetched business lookup was retried: %d" , executions )
2026-07-27 23:29:02 +08:00
}
}
2026-08-28 22:23:13 +08:00
func TestPrefetchedResultIsImmutableAcrossDifferentArguments ( t * testing . T ) {
t . Cleanup ( func () { _ = svc . SetBusinessReadTools ( nil ) })
executions := 0
if err := svc . SetBusinessReadTools ([] contract . BusinessReadTool {{
Code : "business/card_package_catalog" , Description : "package catalog" , CustomerTypes : [] string { "card" },
MatchIntent : func ( message string ) bool { return strings . Contains ( message , "套餐" ) },
Execute : func ( _ context . Context , _ contract . BusinessReadContext , arguments map [ string ] any ) ( any , error ) {
executions ++
if len ( arguments ) == 0 {
return map [ string ] any { "scope" : "prefetched-default" }, nil
}
return map [ string ] any { "scope" : arguments [ "scope" ]}, nil
},
}}); err != nil {
t . Fatalf ( "register business tool: %v" , err )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
engine := NewAgentLoopEngine ()
engine . retrieve = nil
engine . history = nil
conversation := models . Conversation { ID : 81 , CustomerType : "card" , CustomerID : 82 }
turn := engine . prepareTurn ( context . Background (), RunInput { Conversation : conversation , UserMessage : models . Message { Content : "查套餐" }}, nil )
state := agentLoopExecutionState { VerifiedToolResults : cloneVerifiedToolResults ( turn . VerifiedToolResults )}
records := append ([] svc . AgentLoopToolCallInput ( nil ), turn . PrefetchedToolCalls ... )
execute := engine . toolSearchExecutor ( RunInput { Conversation : conversation }, turn , & state , & records )
filtered , err := execute ( context . Background (), ai . ToolCall { Name : "business/card_package_catalog" , Arguments : `{"scope":"filtered"}` })
if err != nil || ! strings . Contains ( filtered , "filtered" ) {
t . Fatalf ( "filtered lookup: raw=%q err=%v" , filtered , err )
}
defaultResult , err := execute ( context . Background (), ai . ToolCall { Name : "business/card_package_catalog" , Arguments : `{}` })
if err != nil || ! strings . Contains ( defaultResult , "prefetched-default" ) || strings . Contains ( defaultResult , "filtered" ) {
t . Fatalf ( "prefetched lookup was overwritten: raw=%q err=%v" , defaultResult , err )
}
if executions != 2 {
t . Fatalf ( "unexpected executions: %d" , executions )
2026-07-27 23:29:02 +08:00
}
}
2026-08-28 22:23:13 +08:00
func TestAgentLoopReturnsFullBusinessReadResultWhileAuditPreviewIsBounded ( t * testing . T ) {
t . Cleanup ( func () { _ = svc . SetBusinessReadTools ( nil ) })
longValue := strings . Repeat ( "x" , 6000 ) + "tail-marker"
if err := svc . SetBusinessReadTools ([] contract . BusinessReadTool {{
Code : "business/card_package_timeline" , Description : "package timeline" , CustomerTypes : [] string { "card" },
Execute : func ( context . Context , contract . BusinessReadContext , map [ string ] any ) ( any , error ) {
return map [ string ] any { "timeline" : longValue }, nil
},
}}); err != nil {
t . Fatalf ( "register business tool: %v" , err )
2026-07-28 11:33:15 +08:00
}
2026-08-28 22:23:13 +08:00
engine := NewAgentLoopEngine ()
engine . retrieve = nil
engine . history = nil
conversation := models . Conversation { ID : 9 , CustomerType : "card" , CustomerID : 11 }
turn := engine . prepareTurn ( context . Background (), RunInput { Conversation : conversation }, nil )
state := agentLoopExecutionState {}
var records [] svc . AgentLoopToolCallInput
raw , err := engine . toolSearchExecutor ( RunInput { Conversation : conversation }, turn , & state , & records )( context . Background (), ai . ToolCall {
Name : "business/card_package_timeline" , Arguments : `{}` ,
})
if err != nil || ! strings . Contains ( raw , "tail-marker" ) {
t . Fatalf ( "full business result was truncated: len=%d err=%v" , len ( raw ), err )
2026-07-28 11:33:15 +08:00
}
2026-08-28 22:23:13 +08:00
if len ( records ) != 1 || len ( records [ 0 ]. ResultPreview ) >= len ( raw ) {
t . Fatalf ( "audit preview was not independently bounded: raw=%d records=%#v" , len ( raw ), records )
2026-07-28 11:33:15 +08:00
}
}
2026-08-28 22:23:13 +08:00
func TestPrefetchDoesNotConsumeExplicitToolBudget ( t * testing . T ) {
t . Cleanup ( func () { _ = svc . SetBusinessReadTools ( nil ) })
firstCalls , secondCalls := 0 , 0
if err := svc . SetBusinessReadTools ([] contract . BusinessReadTool {
{
Code : "business/card_diagnosis" , Description : "diagnosis" , CustomerTypes : [] string { "card" },
MatchIntent : func ( message string ) bool { return strings . Contains ( message , "没网" ) },
Execute : func ( context . Context , contract . BusinessReadContext , map [ string ] any ) ( any , error ) {
firstCalls ++
return map [ string ] any { "status" : "offline" }, nil
},
},
{
Code : "business/card_package_catalog" , Description : "catalog" , CustomerTypes : [] string { "card" },
Execute : func ( context . Context , contract . BusinessReadContext , map [ string ] any ) ( any , error ) {
secondCalls ++
return [] map [ string ] any {{ "package_type" : "addon" }}, nil
},
},
}); err != nil {
t . Fatalf ( "register business tools: %v" , err )
2026-07-28 11:33:15 +08:00
}
2026-08-28 22:23:13 +08:00
engine := NewAgentLoopEngine ()
engine . retrieve = nil
engine . history = nil
conversation := models . Conversation { ID : 10 , CustomerType : "card" , CustomerID : 12 }
turn := engine . prepareTurn ( context . Background (), RunInput {
Conversation : conversation , UserMessage : models . Message { Content : "没网" },
}, nil )
turn . ToolPolicy . MaxTotalCalls = 1
state := agentLoopExecutionState { VerifiedToolResults : cloneVerifiedToolResults ( turn . VerifiedToolResults )}
records := append ([] svc . AgentLoopToolCallInput ( nil ), turn . PrefetchedToolCalls ... )
_ , err := engine . toolSearchExecutor ( RunInput { Conversation : conversation }, turn , & state , & records )( context . Background (), ai . ToolCall {
Name : "business/card_package_catalog" , Arguments : `{}` ,
})
if err != nil {
t . Fatalf ( "one explicit call should remain available after prefetch: %v" , err )
2026-07-28 11:33:15 +08:00
}
2026-08-28 22:23:13 +08:00
if firstCalls != 1 || secondCalls != 1 {
t . Fatalf ( "unexpected execution counts: prefetched=%d explicit=%d" , firstCalls , secondCalls )
2026-07-28 11:33:15 +08:00
}
}
2026-08-28 22:23:13 +08:00
func TestBusinessActionIsPreparedThenExecutedOnlyAfterExplicitConfirmation ( t * testing . T ) {
t . Cleanup ( func () { _ = svc . SetBusinessActionTools ( nil ) })
database , err := gorm . Open ( sqlite . Open ( "file:" + strings . ReplaceAll ( t . Name (), "/" , "_" ) + "?mode=memory&cache=shared" ), & gorm . Config {
NamingStrategy : schema . NamingStrategy { TablePrefix : "t_" , SingularTable : true },
})
2026-07-28 12:16:16 +08:00
if err != nil {
2026-08-28 22:23:13 +08:00
t . Fatalf ( "open sqlite: %v" , err )
2026-07-28 12:16:16 +08:00
}
2026-08-28 22:23:13 +08:00
if err := database . AutoMigrate ( & models . ConversationInterrupt {}, & models . AgentToolInvocation {}); err != nil {
t . Fatalf ( "migrate: %v" , err )
2026-07-28 12:16:16 +08:00
}
2026-08-28 22:23:13 +08:00
sqls . SetDB ( database )
executions := 0
if err := svc . SetBusinessActionTools ([] contract . BusinessActionTool {{
Code : "business/card_resume" , Description : "resume" , CustomerTypes : [] string { "card" },
Preview : func ( context . Context , contract . BusinessReadContext , map [ string ] any ) ( string , error ) {
return "确认复机吗?" , nil
},
Execute : func ( context . Context , contract . BusinessReadContext , map [ string ] any ) ( * contract . BusinessActionResult , error ) {
executions ++
return & contract . BusinessActionResult { Message : "复机已提交" }, nil
},
}}); err != nil {
t . Fatalf ( "register action: %v" , err )
2026-07-28 12:16:16 +08:00
}
2026-08-28 22:23:13 +08:00
conversation := models . Conversation { ID : 7 , CustomerType : "card" , CustomerID : 9 }
turn := NewAgentLoopEngine (). prepareTurn ( context . Background (), RunInput { Conversation : conversation }, nil )
state := agentLoopExecutionState {}
var records [] svc . AgentLoopToolCallInput
executor := NewAgentLoopEngine (). toolSearchExecutor ( RunInput { Conversation : conversation }, turn , & state , & records )
if _ , err := executor ( context . Background (), ai . ToolCall { Name : "business/card_resume" , Arguments : `{}` }); err != nil {
t . Fatalf ( "prepare action: %v" , err )
2026-07-28 12:16:16 +08:00
}
2026-08-28 22:23:13 +08:00
if executions != 0 || state . PendingAction == nil || records [ 0 ]. Status != "pending_confirmation" {
t . Fatalf ( "action executed before confirmation: executions=%d state=%#v records=%#v" , executions , state . PendingAction , records )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
requestData , _ := json . Marshal ( state . PendingAction )
interrupt := & models . ConversationInterrupt {
ConversationID : conversation . ID , CheckPointID : "confirm-1" , RequestData : string ( requestData ), Status : "pending" ,
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
if err := database . Create ( interrupt ). Error ; err != nil {
t . Fatalf ( "create interrupt: %v" , err )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
result , err := NewAgentLoopEngine (). Resume ( context . Background (), ResumeInput {
Conversation : conversation , AIAgent : models . AIAgent { ID : 3 }, CheckPointID : "confirm-1" ,
ResumeData : map [ string ] string { "business_action_confirmation" : "提交" },
})
if err != nil || result == nil || result . ReplyText != "复机已提交" || executions != 1 {
t . Fatalf ( "confirmed result=%#v executions=%d err=%v" , result , executions , err )
2026-07-27 23:29:02 +08:00
}
2026-08-28 22:23:13 +08:00
if _ , err := NewAgentLoopEngine (). Resume ( context . Background (), ResumeInput {
Conversation : conversation , AIAgent : models . AIAgent { ID : 3 }, CheckPointID : "confirm-1" ,
ResumeData : map [ string ] string { "business_action_confirmation" : "确定" },
}); err != nil || executions != 1 {
t . Fatalf ( "idempotent confirmation executions=%d err=%v" , executions , err )
2026-07-27 23:29:02 +08:00
}
}
2026-08-28 22:23:13 +08:00
func TestExplicitBusinessCommandIsPreparedWithoutCallingModel ( t * testing . T ) {
t . Cleanup ( func () { _ = svc . SetBusinessActionTools ( nil ) })
previewCalls := 0
if err := svc . SetBusinessActionTools ([] contract . BusinessActionTool {{
Code : "business/card_resume" , Description : "resume" , CustomerTypes : [] string { "card" },
MatchIntent : func ( message string ) bool { return strings . TrimSpace ( message ) == "复机" },
Preview : func ( context . Context , contract . BusinessReadContext , map [ string ] any ) ( string , error ) {
previewCalls ++
return "已检查可用套餐,确认复机吗?" , nil
},
Execute : func ( context . Context , contract . BusinessReadContext , map [ string ] any ) ( * contract . BusinessActionResult , error ) {
t . Fatal ( "explicit command must not execute before confirmation" )
return nil , nil
},
}}); err != nil {
t . Fatalf ( "register action: %v" , err )
}
conversation := models . Conversation { ID : 7 , CustomerType : "card" , CustomerID : 9 }
turn := NewAgentLoopEngine (). prepareTurn ( context . Background (), RunInput { Conversation : conversation }, nil )
var records [] svc . AgentLoopToolCallInput
pending , matched , err := NewAgentLoopEngine (). prepareMatchedBusinessAction ( context . Background (), RunInput {
Conversation : conversation , UserMessage : models . Message { Content : "复机" },
}, turn , & records )
if err != nil || ! matched || pending == nil {
t . Fatalf ( "explicit action was not prepared: matched=%v pending=%#v err=%v" , matched , pending , err )
}
if previewCalls != 1 || pending . ToolCode != "business/card_resume" || len ( records ) != 1 || records [ 0 ]. Status != "pending_confirmation" {
t . Fatalf ( "unexpected deterministic preparation: calls=%d pending=%#v records=%#v" , previewCalls , pending , records )
}
if pending . PromptText != "已检查可用套餐,确认复机吗?" {
t . Fatalf ( "unexpected confirmation prompt: %q" , pending . PromptText )
}
2026-07-27 23:29:02 +08:00
}