diff --git a/cmd/gateway.go b/cmd/gateway.go index 7bd49d55..af3d7e37 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -542,15 +542,18 @@ func runGateway() { if !ok || payload.TeamID == "" || payload.Channel == "" { return } - // Only forward assigned/failed/progress events (completed handled by announce-back). var notifyType string switch evt.Name { - case protocol.EventTeamTaskAssigned: + case protocol.EventTeamTaskDispatched: notifyType = "dispatched" + case protocol.EventTeamTaskAssigned: + notifyType = "dispatched" // same config flag — human assign also notifies case protocol.EventTeamTaskFailed: notifyType = "failed" case protocol.EventTeamTaskProgress: notifyType = "progress" + case protocol.EventTeamTaskCompleted: + notifyType = "completed" default: return } @@ -579,6 +582,10 @@ func runGateway() { if !cfg.Progress { return } + case "completed": + if !cfg.Completed { + return + } } // Skip internal channels. @@ -586,22 +593,44 @@ func runGateway() { return } + // Resolve lead agent key (needed for leader mode routing + completed-by-leader skip). + var leadAgentKey string + if notifyAgentStore != nil { + if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil { + leadAgentKey = la.AgentKey + } + } + + // Skip completed notification if task was completed by the leader + // (leader is already talking to the user, notification would be redundant). + if notifyType == "completed" && payload.OwnerAgentKey == leadAgentKey { + return + } + // Build notification message. var content string agentName := payload.OwnerAgentKey if payload.OwnerDisplayName != "" { agentName = payload.OwnerDisplayName } - switch notifyType { - case "dispatched": + switch evt.Name { + case protocol.EventTeamTaskDispatched: + if payload.ActorID == "dispatch_unblocked" { + content = fmt.Sprintf("▶️ Task #%d \"%s\" → unblocked, dispatched to %s", payload.TaskNumber, payload.Subject, agentName) + } else { + content = fmt.Sprintf("📋 Task #%d \"%s\" → dispatched to %s", payload.TaskNumber, payload.Subject, agentName) + } + case protocol.EventTeamTaskAssigned: content = fmt.Sprintf("📋 Task #%d \"%s\" → assigned to %s", payload.TaskNumber, payload.Subject, agentName) - case "progress": + case protocol.EventTeamTaskCompleted: + content = fmt.Sprintf("✅ Task #%d \"%s\" completed", payload.TaskNumber, payload.Subject) + case protocol.EventTeamTaskProgress: if payload.ProgressStep != "" { content = fmt.Sprintf("⏳ Task #%d \"%s\": %d%% — %s", payload.TaskNumber, payload.Subject, payload.ProgressPercent, payload.ProgressStep) } else { content = fmt.Sprintf("⏳ Task #%d \"%s\": %d%%", payload.TaskNumber, payload.Subject, payload.ProgressPercent) } - case "failed": + case protocol.EventTeamTaskFailed: reason := payload.Reason if len(reason) > 200 { reason = reason[:200] + "..." @@ -609,17 +638,9 @@ func runGateway() { content = fmt.Sprintf("❌ Task #%d \"%s\" failed: %s", payload.TaskNumber, payload.Subject, reason) } - // Resolve leader agent for leader mode routing. - var leadAgent string - if cfg.Mode == "leader" { - if notifyAgentStore != nil { - if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil { - leadAgent = la.AgentKey - } - } - if leadAgent == "" { - return - } + // In leader mode, require resolved agent key for routing. + if cfg.Mode == "leader" && leadAgentKey == "" { + return } batchKey := payload.TeamID + ":" + payload.ChatID @@ -628,7 +649,7 @@ func runGateway() { Channel: payload.Channel, ChatID: payload.ChatID, UserID: payload.UserID, - LeadAgent: leadAgent, + LeadAgent: leadAgentKey, }) }) slog.Info("team progress notification subscriber registered") @@ -889,6 +910,8 @@ func teamTaskEventType(eventName string) string { return "claimed" case protocol.EventTeamTaskAssigned: return "assigned" + case protocol.EventTeamTaskDispatched: + return "dispatched" case protocol.EventTeamTaskCompleted: return "completed" case protocol.EventTeamTaskFailed: diff --git a/internal/tools/team_notify_config.go b/internal/tools/team_notify_config.go index 29921a26..0bae7806 100644 --- a/internal/tools/team_notify_config.go +++ b/internal/tools/team_notify_config.go @@ -4,9 +4,10 @@ import "encoding/json" // TeamNotifyConfig controls which team task events are forwarded to chat channels. type TeamNotifyConfig struct { - Dispatched bool `json:"dispatched"` // task assigned to member + Dispatched bool `json:"dispatched"` // task dispatched to member Progress bool `json:"progress"` // member updates progress Failed bool `json:"failed"` // task failed + Completed bool `json:"completed"` // task completed Mode string `json:"mode"` // "direct" (outbound) or "leader" (through leader agent) } @@ -16,6 +17,7 @@ func DefaultTeamNotifyConfig() TeamNotifyConfig { Dispatched: true, Progress: true, Failed: true, + Completed: true, Mode: "direct", } } @@ -28,15 +30,30 @@ func ParseTeamNotifyConfig(settings json.RawMessage) TeamNotifyConfig { return cfg } var s struct { - Notifications *TeamNotifyConfig `json:"notifications"` + Notifications *struct { + Dispatched *bool `json:"dispatched"` + Progress *bool `json:"progress"` + Failed *bool `json:"failed"` + Completed *bool `json:"completed"` + Mode string `json:"mode"` + } `json:"notifications"` } if json.Unmarshal(settings, &s) != nil || s.Notifications == nil { return cfg } n := s.Notifications - cfg.Dispatched = n.Dispatched - cfg.Progress = n.Progress - cfg.Failed = n.Failed + if n.Dispatched != nil { + cfg.Dispatched = *n.Dispatched + } + if n.Progress != nil { + cfg.Progress = *n.Progress + } + if n.Failed != nil { + cfg.Failed = *n.Failed + } + if n.Completed != nil { + cfg.Completed = *n.Completed + } if n.Mode == "leader" { cfg.Mode = "leader" } diff --git a/internal/tools/team_tasks_followup.go b/internal/tools/team_tasks_followup.go index 422600f0..59dea9ef 100644 --- a/internal/tools/team_tasks_followup.go +++ b/internal/tools/team_tasks_followup.go @@ -133,7 +133,7 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) * return ErrorResult("failed to retry task: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), TaskNumber: task.TaskNumber, diff --git a/internal/tools/team_tasks_lifecycle.go b/internal/tools/team_tasks_lifecycle.go index 76260591..536804f8 100644 --- a/internal/tools/team_tasks_lifecycle.go +++ b/internal/tools/team_tasks_lifecycle.go @@ -73,9 +73,19 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any } ownerKey := t.manager.agentKeyFromID(ctx, agentID) + // Fetch task for TaskNumber/Subject needed by notification subscriber. + completedTask, _ := t.manager.teamStore.GetTask(ctx, taskID) + var taskNumber int + var taskSubject string + if completedTask != nil { + taskNumber = completedTask.TaskNumber + taskSubject = completedTask.Subject + } t.manager.broadcastTeamEvent(protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), + TaskNumber: taskNumber, + Subject: taskSubject, Status: store.TeamTaskStatusCompleted, OwnerAgentKey: ownerKey, OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey), diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index 74fe461f..a9791b4b 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -234,7 +234,7 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui slog.Warn("DispatchUnblockedTasks: assign failed", "task_id", task.ID, "error", err) continue } - m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: task.ID.String(), TaskNumber: task.TaskNumber, diff --git a/internal/tools/team_tool_validation.go b/internal/tools/team_tool_validation.go index 9943ff51..89fe7e99 100644 --- a/internal/tools/team_tool_validation.go +++ b/internal/tools/team_tool_validation.go @@ -91,7 +91,7 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U slog.Warn("post_turn: assign failed", "task_id", task.ID, "error", err) continue } - m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: task.ID.String(), TaskNumber: task.TaskNumber,