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