18c9354095
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
564 lines
26 KiB
Go
564 lines
26 KiB
Go
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)
|
|
}
|
|
}
|