diff --git a/internal/ai/runtime/executor/event_consumer.go b/internal/ai/runtime/executor/event_consumer.go index 2ec4a67..d5f57f9 100644 --- a/internal/ai/runtime/executor/event_consumer.go +++ b/internal/ai/runtime/executor/event_consumer.go @@ -18,6 +18,7 @@ func consumeAgentEvents(events *adk.AsyncIterator[*adk.AgentEvent], summary *Run if collector == nil { collector = callbacks.NewRuntimeTraceCollector() } + suppressAssistantReply := false for { event, ok := events.Next() if !ok { @@ -44,6 +45,9 @@ func consumeAgentEvents(events *adk.AsyncIterator[*adk.AgentEvent], summary *Run messageOutput := event.Output.MessageOutput switch messageOutput.Role { case schema.Assistant: + if suppressAssistantReply { + continue + } replyText := strings.TrimSpace(messageOutput.Message.Content) if replyText != "" { summary.ReplyText = replyText @@ -62,6 +66,8 @@ func consumeAgentEvents(events *adk.AsyncIterator[*adk.AgentEvent], summary *Run toolReplyText := strings.TrimSpace(messageOutput.Message.Content) if toolReplyText != "" { summary.ReplyText = toolReplyText + } else if toolCode == toolx.GraphHandoffConversation.Code { + suppressAssistantReply = true } } } diff --git a/internal/ai/runtime/executor/event_consumer_test.go b/internal/ai/runtime/executor/event_consumer_test.go index 59bfcdb..0450639 100644 --- a/internal/ai/runtime/executor/event_consumer_test.go +++ b/internal/ai/runtime/executor/event_consumer_test.go @@ -70,3 +70,44 @@ func TestConsumeAgentEventsCompletesGraphToolWithNoVisibleReply(t *testing.T) { t.Fatalf("unexpected summary status: %q", summary.Status) } } + +func TestConsumeAgentEventsSuppressesAssistantReplyAfterSilentHandoffTool(t *testing.T) { + summary := &RunResult{ + Status: "started", + InvokedToolCodes: make([]string, 0), + } + events, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() + gen.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{ + MessageOutput: &adk.MessageVariant{ + Role: schema.Tool, + ToolName: toolx.GraphHandoffConversation.Name, + Message: &schema.Message{ + Content: "", + }, + }, + }, + }) + gen.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{ + MessageOutput: &adk.MessageVariant{ + Role: schema.Assistant, + Message: &schema.Message{ + Content: "好的,已为您发起转接人工客服的请求。系统正在为您确认,请稍候。", + }, + }, + }, + }) + gen.Close() + + consumeAgentEvents(events, summary, nil, map[string]string{ + toolx.GraphHandoffConversation.Name: toolx.GraphHandoffConversation.Code, + }) + + if summary.ReplyText != "" { + t.Fatalf("expected assistant reply after silent handoff to be suppressed, got %q", summary.ReplyText) + } + if summary.Status != "completed" { + t.Fatalf("unexpected summary status: %q", summary.Status) + } +} diff --git a/internal/ai/runtime/graphs/handoff_graph.go b/internal/ai/runtime/graphs/handoff_graph.go index 3ba353d..289f791 100644 --- a/internal/ai/runtime/graphs/handoff_graph.go +++ b/internal/ai/runtime/graphs/handoff_graph.go @@ -50,6 +50,10 @@ func (g *HandoffGraph) Run(ctx context.Context, argumentsInJSON string) (string, if err != nil { return "", err } + handled, err := services.ConversationService.TryOffHoursHandoffByAI(g.conversation.ID, g.aiAgent, reason) + if err != nil || handled { + return "", err + } info := HandoffGraphInterruptInfo{ Type: InterruptTypeHandoffConfirmation, Message: g.buildConfirmationPrompt(reason), diff --git a/internal/ai/runtime/graphs/handoff_graph_test.go b/internal/ai/runtime/graphs/handoff_graph_test.go new file mode 100644 index 0000000..0315a35 --- /dev/null +++ b/internal/ai/runtime/graphs/handoff_graph_test.go @@ -0,0 +1,169 @@ +package graphs + +import ( + "context" + "strings" + "testing" + "time" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/services" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestHandoffGraphOffHoursSendsNoticeWithoutConfirmation(t *testing.T) { + db := setupHandoffGraphTestDB(t) + aiAgent := createHandoffGraphAIAgent(t, db, "1") + conversation := createHandoffGraphConversation(t, db, aiAgent.ID) + + reply, err := NewHandoffGraph(conversation, aiAgent).Run(context.Background(), `{"reason":"用户要求转人工"}`) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if reply != "" { + t.Fatalf("expected no graph reply, got %q", reply) + } + + message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id")) + if message == nil { + t.Fatalf("expected off-hours notice message") + } + if message.Content != services.HandoffOffHoursMessage { + t.Fatalf("expected off-hours notice, got %q", message.Content) + } + + current := services.ConversationService.Get(conversation.ID) + if current == nil { + t.Fatalf("expected conversation") + } + if current.Status != enums.IMConversationStatusAIServing { + t.Fatalf("expected conversation to stay ai-serving, got %d", current.Status) + } + if current.HandoffAt != nil { + t.Fatalf("expected handoff_at to remain nil, got %v", current.HandoffAt) + } +} + +func TestHandoffGraphWithActiveScheduleStillRequestsConfirmation(t *testing.T) { + db := setupHandoffGraphTestDB(t) + aiAgent := createHandoffGraphAIAgent(t, db, "1") + createHandoffGraphTeam(t, db, 1) + createHandoffGraphActiveSchedule(t, db, 1) + conversation := createHandoffGraphConversation(t, db, aiAgent.ID) + + reply, err := NewHandoffGraph(conversation, aiAgent).Run(context.Background(), `{"reason":"用户要求转人工"}`) + if err == nil { + t.Fatalf("expected confirmation interrupt") + } + if !strings.Contains(err.Error(), InterruptTypeHandoffConfirmation) { + t.Fatalf("expected handoff confirmation interrupt, got %v", err) + } + if reply != "" { + t.Fatalf("expected no reply before confirmation, got %q", reply) + } + + var count int64 + if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil { + t.Fatalf("count messages error = %v", err) + } + if count != 0 { + t.Fatalf("expected no notice before confirmation, got %d messages", count) + } +} + +func setupHandoffGraphTestDB(t *testing.T) *gorm.DB { + t.Helper() + + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate( + &models.AIAgent{}, + &models.AgentTeam{}, + &models.AgentTeamSchedule{}, + &models.Conversation{}, + &models.ConversationEventLog{}, + &models.ConversationReadState{}, + &models.Message{}, + &models.ChannelMessageOutbox{}, + ); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} + +func createHandoffGraphAIAgent(t *testing.T, db *gorm.DB, teamIDs string) models.AIAgent { + t.Helper() + + item := models.AIAgent{ + Name: "测试AI", + ServiceMode: enums.IMConversationServiceModeAIFirst, + TeamIDs: teamIDs, + Status: enums.StatusOk, + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create ai agent error = %v", err) + } + return item +} + +func createHandoffGraphTeam(t *testing.T, db *gorm.DB, id int64) { + t.Helper() + + if err := db.Create(&models.AgentTeam{ID: id, Name: "售后支持组", Status: enums.StatusOk}).Error; err != nil { + t.Fatalf("create team error = %v", err) + } +} + +func createHandoffGraphActiveSchedule(t *testing.T, db *gorm.DB, teamID int64) { + t.Helper() + + now := time.Now() + if err := db.Create(&models.AgentTeamSchedule{ + TeamID: teamID, + StartAt: now.Add(-time.Hour), + EndAt: now.Add(time.Hour), + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create schedule error = %v", err) + } +} + +func createHandoffGraphConversation(t *testing.T, db *gorm.DB, aiAgentID int64) models.Conversation { + t.Helper() + + now := time.Now() + item := models.Conversation{ + AIAgentID: aiAgentID, + ChannelID: 1, + CustomerID: 1, + CustomerName: "测试访客", + Status: enums.IMConversationStatusAIServing, + ServiceMode: enums.IMConversationServiceModeAIFirst, + LastMessageAt: now, + LastActiveAt: now, + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create conversation error = %v", err) + } + return item +} diff --git a/internal/services/conversation_human_dispatch_service.go b/internal/services/conversation_human_dispatch_service.go index 2910483..74e7a22 100644 --- a/internal/services/conversation_human_dispatch_service.go +++ b/internal/services/conversation_human_dispatch_service.go @@ -45,6 +45,25 @@ func newConversationHumanDispatchService() *conversationHumanDispatchService { return &conversationHumanDispatchService{} } +func (s *conversationHumanDispatchService) TryOffHoursHandoffByAI(conversationID int64, aiAgent models.AIAgent, reason string) (bool, error) { + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return false, errorsx.InvalidParam("会话不存在") + } + teamIDs := orderedPositiveIDs(aiAgent.TeamIDs) + activeTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(teamIDs, time.Now()) + if len(activeTeamIDs) > 0 { + return false, nil + } + if err := s.createEvent(conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "转人工失败:非服务时间", strings.TrimSpace(reason)); err != nil { + return true, err + } + if err := s.sendAIText(conversationID, aiAgent.ID, HandoffOffHoursMessage); err != nil { + return true, err + } + return true, nil +} + func (s *conversationHumanDispatchService) HandoffByAI(conversationID int64, aiAgent models.AIAgent, reason string) (*HandoffDecisionResult, error) { conversation := ConversationService.Get(conversationID) if conversation == nil { @@ -53,10 +72,7 @@ func (s *conversationHumanDispatchService) HandoffByAI(conversationID int64, aiA teamIDs := orderedPositiveIDs(aiAgent.TeamIDs) activeTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(teamIDs, time.Now()) if len(activeTeamIDs) == 0 { - if err := s.createEvent(conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "转人工失败:非服务时间", strings.TrimSpace(reason)); err != nil { - return nil, err - } - if err := s.sendAIText(conversationID, aiAgent.ID, HandoffOffHoursMessage); err != nil { + if _, err := s.TryOffHoursHandoffByAI(conversationID, aiAgent, reason); err != nil { return nil, err } return &HandoffDecisionResult{Decision: HandoffDecisionOffHours, Message: HandoffOffHoursMessage}, nil diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index 11cbd8e..3627a98 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -360,6 +360,20 @@ func (s *conversationService) HandoffByAI(conversationID int64, aiAgent models.A return err } +func (s *conversationService) TryOffHoursHandoffByAI(conversationID int64, aiAgent models.AIAgent, reason string) (bool, error) { + if conversationID <= 0 { + return false, errorsx.InvalidParam("会话不存在") + } + handled, err := ConversationHumanDispatchService.TryOffHoursHandoffByAI(conversationID, aiAgent, reason) + if err != nil { + slog.Warn("off-hours ai handoff failed", + "conversation_id", conversationID, + "ai_agent_id", aiAgent.ID, + "error", err) + } + return handled, err +} + func (s *conversationService) CloseConversation(conversationID int64, closeReason string, operator *dto.AuthPrincipal) error { if operator == nil { return errorsx.Unauthorized("未登录或登录已过期")