package runtime import ( "errors" "strings" "testing" "time" applicationruntime "code.tczkiot.com/wlw/ai-agent/internal/ai/application/runtime" "code.tczkiot.com/wlw/ai-agent/internal/models" "code.tczkiot.com/wlw/ai-agent/internal/pkg/enums" ) func TestReplyEligibilityCanReply(t *testing.T) { eligibility := newReplyEligibility() conversation := newConversationFixture() message := newCustomerMessageFixture("hello") aiAgent := newAIAgentFixture() if !eligibility.CanReply(conversation, message, aiAgent) { t.Fatalf("expected customer message to be replyable") } message.SenderType = enums.IMSenderTypeAgent if eligibility.CanReply(conversation, message, aiAgent) { t.Fatalf("expected non-customer message to be rejected") } message = newCustomerMessageFixture("hello") conversation.HandoffAt = ptrTime(time.Now()) if eligibility.CanReply(conversation, message, aiAgent) { t.Fatalf("expected handed-off conversation to be rejected") } conversation = newConversationFixture() conversation.CurrentAssigneeID = 1 if eligibility.CanReply(conversation, message, aiAgent) { t.Fatalf("expected assigned conversation to be rejected") } conversation = newConversationFixture() aiAgent.ServiceMode = enums.IMConversationServiceModeHumanOnly if eligibility.CanReply(conversation, message, aiAgent) { t.Fatalf("expected human-only agent to be rejected") } aiAgent = newAIAgentFixture() message.Content = " " if eligibility.CanReply(conversation, message, aiAgent) { t.Fatalf("expected blank message to be rejected") } } func TestAIAgentRolloutUsesStableConversationBucket(t *testing.T) { conversation := models.Conversation{ID: 101, ChannelID: 7} agent := models.AIAgent{ID: 9, RolloutPercent: 50} first := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100}) for range 20 { if got := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100}); got != first { t.Fatalf("rollout bucket changed within one conversation: first=%t got=%t", first, got) } } if normalizedRolloutPercent(0) != 100 || normalizedRolloutPercent(101) != 100 || normalizedRolloutPercent(25) != 25 { t.Fatal("unexpected rollout percent normalization") } if !IsAIAgentRolloutEligible(conversation, models.AIAgent{ID: 9, RolloutPercent: 0}, &models.Channel{}) { t.Fatal("legacy zero rollout values must preserve full rollout") } } func TestResolveReplyTimeout(t *testing.T) { service := newAIReplyService() aiAgent := newAIAgentFixture() if got := service.resolveReplyTimeout(aiAgent); got != 180*time.Second { t.Fatalf("expected default timeout, got %v", got) } aiAgent.ReplyTimeoutSeconds = 30 if got := service.resolveReplyTimeout(aiAgent); got != 30*time.Second { t.Fatalf("expected exact timeout, got %v", got) } aiAgent.ReplyTimeoutSeconds = 999 if got := service.resolveReplyTimeout(aiAgent); got != 600*time.Second { t.Fatalf("expected clamped timeout, got %v", got) } } func TestAIReplyFailureTextShowsSafeActionableErrors(t *testing.T) { tests := []struct { name string err error contains string }{ {name: "request id", err: errors.New("AI 请求标识未设置"), contains: "AI 请求标识无效"}, {name: "balance", err: errors.New("insufficient_ai_balance: AI 额度不足"), contains: "AI 额度不足"}, {name: "key", err: errors.New("invalid_ai_key"), contains: "AI Key 无效或已撤销"}, {name: "model", err: errors.New("ai_gateway_not_configured"), contains: "AI 模型尚未配置"}, {name: "timeout", err: errors.New("context deadline exceeded"), contains: "AI 请求超时"}, {name: "gateway internal", err: errors.New("internal_error: 网关内部异常 (request_id: req-qwen-123)"), contains: "排查编号:req-qwen-123"}, {name: "upstream model", err: errors.New(`ai_upstream_failed: deepseek returned 400: {"message":"Model Not Exist"}`), contains: "模型不存在或暂不可用"}, {name: "upstream key", err: errors.New("ai_upstream_failed: Authentication Fails, invalid api key"), contains: "API Key 无效或无权限"}, {name: "upstream unknown", err: errors.New("ai_upstream_failed: provider returned 502"), contains: "上游模型服务返回错误"}, {name: "wrapped qwen parameter", err: errors.New(`failed to generate: status code: 502, message: qwen 返回 400: {"code":"InvalidParameter","message":"The parameter temperature is invalid"}`), contains: "千问请求参数不兼容"}, {name: "wrapped qwen tool unsupported", err: errors.New(`status code: 502, message: qwen 返回 400: {"code":"InvalidParameter","message":"The model does not support tools"}`), contains: "不支持客服工具调用"}, {name: "qwen arrearage", err: errors.New(`qwen 返回 400: {"code":"Arrearage","message":"Access denied due to owing balance"}`), contains: "额度不足或已欠费"}, {name: "qwen unknown", err: errors.New(`qwen 返回 500: {"code":"InternalError","message":"Temporary upstream failure"}`), contains: "千问服务返回错误"}, {name: "unknown", err: errors.New("database password leaked"), contains: aiReplyFailedReply}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if got := aiReplyFailureText(test.err); !strings.Contains(got, test.contains) { t.Fatalf("aiReplyFailureText() = %q, want it to contain %q", got, test.contains) } }) } } func TestResolveInterruptPrompt(t *testing.T) { summary := &applicationruntime.RunResult{ Interrupts: []applicationruntime.InterruptContextSummary{ { ID: "interrupt-1", Type: "question", InfoPreview: `{"message":"请补充订单号"}`, }, }, } if got := resolveInterruptPrompt(summary); got != "请补充订单号" { t.Fatalf("unexpected interrupt prompt: %q", got) } summary.Interrupts[0].InfoPreview = "直接补充手机号" if got := resolveInterruptPrompt(summary); got != "直接补充手机号" { t.Fatalf("unexpected raw interrupt prompt: %q", got) } summary.ReplyText = "" summary.Interrupts[0] = applicationruntime.InterruptContextSummary{ ID: "system/server_time", Type: "tool_confirmation", DisplayName: "获取当前时间", InfoPreview: "system/server_time", } if got := resolveInterruptPrompt(summary); got != "即将执行“获取当前时间”,是否确认继续?" { t.Fatalf("tool code leaked into customer prompt: %q", got) } summary.Interrupts[0].PromptText = "请确认是否更新客户资料。" if got := resolveInterruptPrompt(summary); got != "请确认是否更新客户资料。" { t.Fatalf("explicit customer prompt was not preferred: %q", got) } } func newConversationFixture() models.Conversation { return models.Conversation{} } func newCustomerMessageFixture(content string) models.Message { return models.Message{ SenderType: enums.IMSenderTypeCustomer, Content: content, } } func newAIAgentFixture() models.AIAgent { return models.AIAgent{} } func ptrTime(v time.Time) *time.Time { return &v }