Files
ai-agent/internal/ai/application/runtime/agent_loop_engine_test.go
T

564 lines
26 KiB
Go
Raw Normal View History

package runtime
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"code.tczkiot.com/wlw/ai-agent/contract"
ai "code.tczkiot.com/wlw/ai-agent/internal/ai"
aitooling "code.tczkiot.com/wlw/ai-agent/internal/ai/tooling"
"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"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestAgentLoopRegistersFixedCapabilityAliases(t *testing.T) {
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 {
if names[definition.Name] {
t.Fatalf("duplicate registered function alias %q: %#v", definition.Name, definitions)
}
names[definition.Name] = true
}
for _, expected := range []string{
"tool_search",
"conversation_decision",
"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) {
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{
`{"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{
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)
}
}
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")
}
}
func TestAgentLoopKnowledgeFallbackCanRequestHandoff(t *testing.T) {
agent := models.AIAgent{
KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff,
FallbackMessage: "我暂时无法核实,马上为你转人工。",
}
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)
}
}
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)
}
}
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)
}
}
}
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)
}
}
}
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
},
}})
if err != nil {
t.Fatalf("register business tool: %v", err)
}
conversation := models.Conversation{
ID: 7, CustomerType: "card", CustomerID: 9,
CustomerExternalID: "card:9", CustomerName: "卡号 50506783",
}
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)
}
definition, raw, err := executeAgentLoopReadTool(context.Background(), conversation, models.AIAgent{}, "business/card_diagnosis", nil, aitooling.Policy{
AllowedToolCodes: turn.AllowedTools,
MaxTotalCalls: 3,
MaxArgumentBytes: 1024,
})
if err != nil {
t.Fatalf("execute business tool: %v", err)
}
if definition.Code != "business/card_diagnosis" || received.CustomerID != 9 || received.ConversationID != 7 {
t.Fatalf("unexpected business tool execution: definition=%#v context=%#v", definition, received)
}
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)
}
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)
}
}
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)
}
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)
}
}
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: `{}`,
})
if err != nil || !strings.Contains(raw, "2026-08-31 23:59:59") {
t.Fatalf("reuse prefetched result: raw=%q err=%v", raw, err)
}
if executions != 1 {
t.Fatalf("prefetched business lookup was executed again: %d", executions)
}
}
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")
},
}}); err != nil {
t.Fatalf("register business tool: %v", err)
}
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: `{}`,
})
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)
}
if executions != 1 {
t.Fatalf("failed prefetched business lookup was retried: %d", executions)
}
}
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)
}
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)
}
}
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)
}
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)
}
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)
}
}
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)
}
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)
}
if firstCalls != 1 || secondCalls != 1 {
t.Fatalf("unexpected execution counts: prefetched=%d explicit=%d", firstCalls, secondCalls)
}
}
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},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := database.AutoMigrate(&models.ConversationInterrupt{}, &models.AgentToolInvocation{}); err != nil {
t.Fatalf("migrate: %v", err)
}
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)
}
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)
}
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)
}
requestData, _ := json.Marshal(state.PendingAction)
interrupt := &models.ConversationInterrupt{
ConversationID: conversation.ID, CheckPointID: "confirm-1", RequestData: string(requestData), Status: "pending",
}
if err := database.Create(interrupt).Error; err != nil {
t.Fatalf("create interrupt: %v", err)
}
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)
}
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)
}
}
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)
}
}