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) } } } func TestConversationDecisionIsStructuredAndValidated(t *testing.T) { decision, err := parseConversationDecision(`{"action":"handoff","reason":"customer requested a human","reply":"","handoff_initiator":"customer","handoff_confirmed":true}`) 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}`, } { 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}`, }); 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) } } 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) } }