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