diff --git a/internal/ai/runtime/graphs/handoff_graph.go b/internal/ai/runtime/graphs/handoff_graph.go index 289f791..e3538e8 100644 --- a/internal/ai/runtime/graphs/handoff_graph.go +++ b/internal/ai/runtime/graphs/handoff_graph.go @@ -52,6 +52,9 @@ func (g *HandoffGraph) Run(ctx context.Context, argumentsInJSON string) (string, } handled, err := services.ConversationService.TryOffHoursHandoffByAI(g.conversation.ID, g.aiAgent, reason) if err != nil || handled { + if handled && err == nil { + return services.HandoffOffHoursMessage, nil + } return "", err } info := HandoffGraphInterruptInfo{ diff --git a/internal/ai/runtime/graphs/handoff_graph_test.go b/internal/ai/runtime/graphs/handoff_graph_test.go index 0315a35..254e20e 100644 --- a/internal/ai/runtime/graphs/handoff_graph_test.go +++ b/internal/ai/runtime/graphs/handoff_graph_test.go @@ -25,8 +25,8 @@ func TestHandoffGraphOffHoursSendsNoticeWithoutConfirmation(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - if reply != "" { - t.Fatalf("expected no graph reply, got %q", reply) + if reply != services.HandoffOffHoursMessage { + t.Fatalf("expected off-hours graph reply, got %q", reply) } message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id")) diff --git a/internal/services/conversation_human_dispatch_service.go b/internal/services/conversation_human_dispatch_service.go index 93fb87f..5c15d77 100644 --- a/internal/services/conversation_human_dispatch_service.go +++ b/internal/services/conversation_human_dispatch_service.go @@ -58,6 +58,9 @@ func (s *conversationHumanDispatchService) TryOffHoursHandoffByAI(conversationID if err := s.createEvent(conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "转人工失败:非服务时间", strings.TrimSpace(reason)); err != nil { return true, err } + if s.hasLatestAIText(conversationID, HandoffOffHoursMessage) { + return true, nil + } if err := s.sendAIText(conversationID, aiAgent.ID, HandoffOffHoursMessage); err != nil { return true, err } @@ -288,6 +291,14 @@ func (s *conversationHumanDispatchService) sendAIText(conversationID, aiAgentID return err } +func (s *conversationHumanDispatchService) hasLatestAIText(conversationID int64, content string) bool { + latest, err := MessageService.GetConversationReadTarget(conversationID, 0) + if err != nil || latest == nil { + return false + } + return latest.SenderType == enums.IMSenderTypeAI && strings.TrimSpace(latest.Content) == strings.TrimSpace(content) +} + func orderedPositiveIDs(value string) []int64 { return uniquePositiveInt64sFromStrings(strings.Split(value, ",")) } diff --git a/internal/services/conversation_human_dispatch_service_test.go b/internal/services/conversation_human_dispatch_service_test.go index f44a387..b3b1497 100644 --- a/internal/services/conversation_human_dispatch_service_test.go +++ b/internal/services/conversation_human_dispatch_service_test.go @@ -47,6 +47,32 @@ func TestConversationHumanDispatchAIHandoffOffHoursKeepsAIServingAndSendsNotice( } } +func TestConversationHumanDispatchAIHandoffOffHoursDoesNotDuplicateNotice(t *testing.T) { + db := setupConversationHumanDispatchTestDB(t) + aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") + conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) + + for i := 0; i < 3; i++ { + result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工") + if err != nil { + t.Fatalf("HandoffByAI() round %d error = %v", i+1, err) + } + if result == nil || result.Decision != services.HandoffDecisionOffHours { + t.Fatalf("expected off_hours decision on round %d, got %+v", i+1, result) + } + } + + var count int64 + if err := db.Model(&models.Message{}). + Where("conversation_id = ? AND sender_type = ? AND content = ?", conversation.ID, enums.IMSenderTypeAI, services.HandoffOffHoursMessage). + Count(&count).Error; err != nil { + t.Fatalf("count off-hours messages error = %v", err) + } + if count != 1 { + t.Fatalf("expected one off-hours notice, got %d", count) + } +} + func TestConversationHumanDispatchAIHandoffAssignsAvailableAgent(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1")