2026-04-13 17:17:13 +08:00
package runtime
import (
2026-08-28 22:23:13 +08:00
"errors"
"strings"
2026-04-13 17:17:13 +08:00
"testing"
"time"
2026-08-21 00:41:07 +08:00
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"
2026-04-13 17:17:13 +08:00
)
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" )
}
}
2026-07-25 12:04:06 +08:00
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" )
}
}
2026-04-13 17:17:13 +08:00
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 )
}
}
2026-08-28 22:23:13 +08:00
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 )
}
})
}
}
2026-04-13 17:17:13 +08:00
func TestResolveInterruptPrompt ( t * testing . T ) {
2026-07-27 23:29:02 +08:00
summary := & applicationruntime . RunResult {
2026-04-14 12:13:33 +08:00
Interrupts : [] applicationruntime . InterruptContextSummary {
2026-04-13 17:17:13 +08:00
{
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 )
}
2026-07-28 11:33:15 +08:00
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 )
}
2026-04-13 17:17:13 +08:00
}
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
}