refactor: 将客服后端重构为宿主可嵌入模块

- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
t
2026-08-28 22:23:13 +08:00
parent 6845c728f8
commit 18c9354095
377 changed files with 13199 additions and 22881 deletions
@@ -3,65 +3,42 @@ 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/dto/request"
"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 TestAgentLoopActivatesSkillInsideSameToolLoop(t *testing.T) {
skill := models.SkillDefinition{
ID: 7, Name: "退款说明", Instruction: "只根据退款政策回答。",
ToolWhitelist: `["builtin/knowledge_retrieve"]`, Status: enums.StatusOk,
}
turn := agentLoopTurn{
AllowedTools: []string{"skill/7"},
ToolPolicy: parseAgentLoopToolPolicy(""),
Skills: map[int64]models.SkillDefinition{skill.ID: skill},
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls)
result, err := execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"skill/7","arguments":{}}`,
})
if err != nil {
t.Fatalf("activate Skill: %v", err)
}
if state.SkillContext.SkillID() != skill.ID || !strings.Contains(result, skill.Instruction) {
t.Fatalf("Skill was not activated in the Agent Loop: state=%#v result=%q", state, result)
}
if len(calls) != 1 || calls[0].ToolCode != "skill/7" || calls[0].Status != "completed" {
t.Fatalf("unexpected Skill audit: %#v", calls)
}
}
func TestAgentLoopRegistersDirectCapabilityAliases(t *testing.T) {
func TestAgentLoopRegistersFixedCapabilityAliases(t *testing.T) {
turn := agentLoopTurn{AllowedTools: []string{
"builtin/conversation_context",
"graph/triage_service_request",
"graph/triage_service_request",
"workflow/47",
}}
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",
"workflow/47",
} {
if !names[expected] {
t.Fatalf("missing registered function alias %q: %#v", expected, definitions)
@@ -70,7 +47,7 @@ func TestAgentLoopRegistersDirectCapabilityAliases(t *testing.T) {
}
func TestConversationDecisionIsStructuredAndValidated(t *testing.T) {
decision, err := parseConversationDecision(`{"action":"handoff","reason":"customer requested a human","reply":"","handoffInitiator":"customer","handoffConfirmed":true}`)
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)
}
@@ -78,9 +55,9 @@ func TestConversationDecisionIsStructuredAndValidated(t *testing.T) {
t.Fatalf("unexpected handoff decision: %#v", decision)
}
for _, raw := range []string{
`{"action":"unknown","reason":"x","reply":"x","handoffInitiator":"none","handoffConfirmed":false}`,
`{"action":"reply","reason":"x","reply":"","handoffInitiator":"none","handoffConfirmed":false}`,
`{"action":"ask_handoff_confirmation","reason":"x","reply":"confirm?","handoffInitiator":"customer","handoffConfirmed":false}`,
`{"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)
@@ -93,7 +70,7 @@ func TestAgentLoopRecordsConversationDecision(t *testing.T) {
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":"","handoffInitiator":"customer","handoffConfirmed":true}`,
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)
}
@@ -112,6 +89,36 @@ func TestResolveAgentLoopReplyKeepsNormalModelReplyWithoutDecision(t *testing.T)
}
}
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,
@@ -131,121 +138,6 @@ func TestNormalizeAgentLoopReplyAllowsEmptyInternalHandoff(t *testing.T) {
}
}
func TestAgentLoopDirectCapabilityAliasUsesSamePolicyBoundary(t *testing.T) {
skill := models.SkillDefinition{
ID: 7, Name: "售后升级处理", Instruction: "先确认升级诉求。", Status: enums.StatusOk,
}
turn := agentLoopTurn{
AllowedTools: []string{"skill/7"},
ToolPolicy: parseAgentLoopToolPolicy(""),
Skills: map[int64]models.SkillDefinition{skill.ID: skill},
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls)
result, err := execute(context.Background(), ai.ToolCall{Name: "skill/7", Arguments: `{}`})
if err != nil {
t.Fatalf("execute direct capability alias: %v", err)
}
if state.SkillContext.SkillID() != skill.ID || !strings.Contains(result, skill.Instruction) {
t.Fatalf("direct capability was not routed through Skill activation: state=%#v result=%q", state, result)
}
if len(calls) != 1 || calls[0].ToolCode != "skill/7" || calls[0].Status != "completed" {
t.Fatalf("unexpected direct capability audit: %#v", calls)
}
}
func TestAgentLoopInterruptsBeforeWriteMCPTool(t *testing.T) {
configured, err := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "crm/update_customer", ServerCode: "crm", ToolName: "update_customer",
Title: "更新客户", RiskLevel: "write", RequireConfirmation: true,
}})
if err != nil {
t.Fatalf("marshal MCP configuration: %v", err)
}
runInput := RunInput{
Conversation: models.Conversation{ID: 9},
AIAgent: models.AIAgent{AllowedMCPTools: string(configured)},
}
turn := agentLoopTurn{
AllowedTools: []string{"crm/update_customer"},
ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read","write"]}`),
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(runInput, turn, &state, &calls)
_, err = execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"crm/update_customer","arguments":{"name":"Ada"}}`,
})
if err == nil {
t.Fatal("expected write MCP Tool to interrupt")
}
if state.Interrupted == nil || !state.Interrupted.Interrupted || !strings.HasPrefix(state.Interrupted.CheckPointID, "tool:9:") {
t.Fatalf("missing MCP confirmation checkpoint: %#v", state.Interrupted)
}
if state.Interrupted.ReplyText != "即将执行“更新客户”,是否确认继续?" ||
len(state.Interrupted.Interrupts) != 1 ||
state.Interrupted.Interrupts[0].PromptText != state.Interrupted.ReplyText {
t.Fatalf("unexpected customer confirmation prompt: %#v", state.Interrupted)
}
if len(calls) != 1 || calls[0].RiskLevel != "write" || !calls[0].RequireConfirm || calls[0].Status != "interrupted" {
t.Fatalf("unexpected MCP safety audit: %#v", calls)
}
}
func TestAgentLoopRejectsWriteMCPBeforeConfirmationWhenRiskIsNotAllowed(t *testing.T) {
configured, _ := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "crm/update_customer", ServerCode: "crm", ToolName: "update_customer",
Title: "更新客户", RiskLevel: "write", RequireConfirmation: true,
}})
runInput := RunInput{
Conversation: models.Conversation{ID: 9},
AIAgent: models.AIAgent{AllowedMCPTools: string(configured)},
}
turn := agentLoopTurn{
AllowedTools: []string{"crm/update_customer"},
ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read"]}`),
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(runInput, turn, &state, &calls)
_, err := execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"crm/update_customer","arguments":{"name":"Ada"}}`,
})
if err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected MCP risk policy rejection, got %v", err)
}
if state.Interrupted != nil || len(calls) != 1 || calls[0].Status != "failed" {
t.Fatalf("disallowed MCP call should fail without a checkpoint: state=%#v calls=%#v", state, calls)
}
}
func TestAgentLoopRejectsWorkflowWhenWriteRiskIsNotAllowed(t *testing.T) {
turn := agentLoopTurn{
AllowedTools: []string{"workflow/23"},
ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read"]}`),
Workflows: map[int64]svc.AgentRevisionWorkflowBinding{
23: {WorkflowVersionID: 23, ToolName: "创建工单"},
},
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls)
_, err := execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"workflow/23","arguments":{}}`,
})
if err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected Workflow risk policy rejection, got %v", err)
}
if len(calls) != 1 || calls[0].Status != "failed" || calls[0].RiskLevel != "write" {
t.Fatalf("unexpected Workflow policy audit: %#v", calls)
}
}
func TestAgentLoopKnowledgeFallbackCanRequestHandoff(t *testing.T) {
agent := models.AIAgent{
KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff,
@@ -258,39 +150,6 @@ func TestAgentLoopKnowledgeFallbackCanRequestHandoff(t *testing.T) {
}
}
func TestAgentLoopConfirmationNormalizesHTMLAndKeepsUnknownPending(t *testing.T) {
data := normalizeAgentLoopResumeData(enums.IMMessageTypeHTML, map[string]string{
"message": "<p>确认。</p>",
})
if got := parseAgentLoopConfirmation(firstAgentLoopResumeText(data)); got != agentLoopConfirmationConfirmed {
t.Fatalf("expected HTML confirmation, got %v from %#v", got, data)
}
if got := parseAgentLoopConfirmation("取消!"); got != agentLoopConfirmationCancelled {
t.Fatalf("expected cancellation, got %v", got)
}
if got := parseAgentLoopConfirmation("稍后再说"); got != agentLoopConfirmationUnknown {
t.Fatalf("ambiguous input must stay pending, got %v", got)
}
}
func TestConfiguredMCPToolAppliesTrustedSystemPolicy(t *testing.T) {
configured, _ := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "system/server_time",
ServerCode: "system",
ToolName: "server_time",
Title: "server_time",
RiskLevel: "write",
RequireConfirmation: true,
}})
tool, err := configuredMCPTool(string(configured), "system/server_time")
if err != nil {
t.Fatalf("resolve configured system tool: %v", err)
}
if tool.Title != "获取当前时间" || tool.RiskLevel != "read" || tool.RequireConfirmation {
t.Fatalf("trusted policy was not applied at runtime: %#v", tool)
}
}
func TestAgentLoopPromptAvoidsRepeatingWelcomeMessage(t *testing.T) {
prompt := buildAgentLoopSystemPrompt(models.AIAgent{}, false, "", nil)
if !strings.Contains(prompt, "without repeating the welcome wording") {
@@ -298,91 +157,407 @@ func TestAgentLoopPromptAvoidsRepeatingWelcomeMessage(t *testing.T) {
}
}
func TestCompleteConfirmedMCPReplyGeneratesCustomerFacingAnswerWithoutTools(t *testing.T) {
func TestAgentLoopHistoryExcludesOperationalFailureNotices(t *testing.T) {
engine := NewAgentLoopEngine()
var systemPrompt string
var userPrompt string
engine.complete = func(_ context.Context, _ models.AIConfig, system, user string) (*ai.ChatCompletionResult, error) {
systemPrompt = system
userPrompt = user
return &ai.ChatCompletionResult{
Content: "当前服务端时间是 2026-07-28 11:51:52。",
ModelName: "test-model",
PromptTokens: 20,
CompletionTokens: 10,
}, nil
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: "请确认设备电源指示灯是否亮起。"},
}
}
result, err := engine.completeConfirmedMCPReply(
context.Background(),
models.AIAgent{},
models.AIConfig{ModelName: "test-model"},
"获取当前时间",
"现在几点钟?",
`{"timestamp":"2026-07-28 11:51:52","timezone":"Local"}`,
)
if err != nil {
t.Fatalf("complete confirmed MCP reply: %v", err)
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)
}
if result.Content != "当前服务端时间是 2026-07-28 11:51:52。" {
t.Fatalf("unexpected customer reply: %#v", result)
for _, forbidden := range []string{"智能处理没有完成", "网关内部异常"} {
if strings.Contains(prompt, forbidden) {
t.Fatalf("operational failure notice leaked into model history: %s", prompt)
}
}
for _, expected := range []string{
"Do not request or invoke another tool",
"现在几点钟?",
"获取当前时间",
`"timestamp":"2026-07-28 11:51:52"`,
} {
if !strings.Contains(systemPrompt+"\n"+userPrompt, expected) {
t.Fatalf("post-tool completion context missing %q: system=%q user=%q", expected, systemPrompt, userPrompt)
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 TestConfirmedMCPReplyFallbackDoesNotExposeRawResult(t *testing.T) {
got := buildAgentLoopConfirmedMCPFallback("获取当前时间")
if got != "“获取当前时间”已成功执行。" || strings.Contains(got, "{") {
t.Fatalf("unexpected confirmed MCP fallback: %q", got)
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 TestAgentTurnPublishesAllConfiguredCapabilityKinds(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
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)
}
}
if err := db.AutoMigrate(&models.SkillDefinition{}); err != nil {
t.Fatalf("migrate Skill: %v", err)
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)
}
}
sqls.SetDB(db)
skill := models.SkillDefinition{Name: "订单查询", Description: "查询订单状态", Status: enums.StatusOk}
if err := db.Create(&skill).Error; err != nil {
t.Fatalf("create Skill: %v", err)
}
mcp, _ := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "crm/get_customer", ServerCode: "crm", ToolName: "get_customer",
RiskLevel: "read",
}
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
},
}})
agent := models.AIAgent{SkillIDs: jsonInt64List(skill.ID), AllowedMCPTools: string(mcp)}
snapshot := &svc.AgentRevisionSnapshot{
Agent: agent,
WorkflowBindings: []svc.AgentRevisionWorkflowBinding{{
WorkflowVersionID: 23, ToolName: "创建工单", TriggerInstruction: "用户要求创建工单",
}},
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{AIAgent: agent}, snapshot)
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)
}
for _, code := range []string{"skill/" + jsonInt64List(skill.ID), "workflow/23", "crm/get_customer"} {
if !strings.Contains(turn.SystemPrompt, code) {
t.Fatalf("capability %q missing from prompt:\n%s", code, 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 jsonInt64List(id int64) string {
data, _ := json.Marshal([]int64{id})
return strings.Trim(string(data), "[]")
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)
}
}