package runtime import ( "context" "testing" "time" "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" ) func TestExplicitHumanHandoffRequest(t *testing.T) { for _, content := range []string{"转人工", "人工客服", "人工", "请转人工客服", "我要转人工", "麻烦帮我转接一下人工客服!", " 转 人工。 "} { t.Run(content, func(t *testing.T) { message := models.Message{Content: content, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText} if !isExplicitHumanHandoffRequest(message) { t.Fatal("explicit command was not recognized") } message.SenderType = enums.IMSenderTypeAI if isExplicitHumanHandoffRequest(message) { t.Fatal("AI message must not trigger handoff") } message.SenderType = enums.IMSenderTypeCustomer message.MessageType = enums.IMMessageTypeImage if isExplicitHumanHandoffRequest(message) { t.Fatal("image content must not trigger handoff") } }) } for _, content := range []string{"", "不要转人工", "不用人工客服", "转人工是什么意思?", "怎么转人工", "有人在吗?", "他说转人工", "转人工失败怎么办", "如果不行再转人工", "请不要转人工", "“转人工”"} { t.Run(content, func(t *testing.T) { if isExplicitHumanHandoffRequest(models.Message{Content: content, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText}) { t.Fatal("non-command must remain available for normal AI processing") } }) } } func TestTriggerReplyExplicitHandoffWithoutModel(t *testing.T) { for _, scheduled := range []bool{true, false} { name := "off_hours" if scheduled { name = "waiting_queue" } t.Run(name, func(t *testing.T) { db := setupReplyCommitTestDB(t) if err := db.AutoMigrate(&models.AgentTeam{}, &models.AgentTeamSchedule{}, &models.AgentProfile{}, &models.ConversationAssignment{}); err != nil { t.Fatal(err) } agent := createReplyCommitTestAIAgent(t, db) agent.TeamIDs = "1" agent.ServiceMode = enums.IMConversationServiceModeAIFirst if err := db.Save(agent).Error; err != nil { t.Fatal(err) } if err := db.Create(&models.AgentTeam{ID: 1, Name: "客服组", Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) } if scheduled { now := time.Now() if err := db.Create(&models.AgentTeamSchedule{TeamID: 1, StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) } } conversation := createReplyCommitTestConversation(t, db, agent.ID) message := models.Message{ConversationID: conversation.ID, Content: "转人工", SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, RequestID: "handoff-request"} // No model, identity prompt, or interrupt service is available: the // command must execute the real dispatch path before any of them. service := &aiReplyService{eligibility: newReplyEligibility()} if err := service.TriggerReply(context.Background(), *conversation, message, *agent); err != nil { t.Fatal(err) } current := svc.ConversationService.Get(conversation.ID) if current == nil { t.Fatal("conversation disappeared") } wantReply := svc.HandoffOffHoursMessage if scheduled { wantReply = svc.HandoffWaitingMessage if current.Status != enums.IMConversationStatusPending || current.CurrentTeamID != 1 || current.CurrentAssigneeID != 0 || current.HandoffAt == nil || current.QueueEnteredAt == nil { t.Fatalf("conversation did not enter waiting queue: %+v", current) } if current.HandoffReason != "转人工" { t.Fatalf("handoff reason = %q", current.HandoffReason) } // Further messages in the waiting queue must not restart the AI. message.Content = "有人在吗?" if err := service.TriggerReply(context.Background(), *current, message, *agent); err != nil { t.Fatal(err) } } else if current.Status != enums.IMConversationStatusAIServing || current.HandoffAt != nil { t.Fatalf("off-hours request must preserve AI service: %+v", current) } var replies []models.Message if err := db.Where("conversation_id = ?", conversation.ID).Find(&replies).Error; err != nil { t.Fatal(err) } if len(replies) != 1 || replies[0].Content != wantReply || replies[0].RequestID != message.RequestID { t.Fatalf("expected one real dispatch notice, got %+v", replies) } }) } }