From eee79d111ec23c32b718e63d077a416150d5e071 Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 16 Mar 2026 22:46:51 +0700 Subject: [PATCH] feat(teams): granular progress notifications with direct/leader mode - Replace progress_notifications toggle with granular config: dispatched (on), progress (on), failed (on) + delivery mode - Direct mode: outbound to channel, no AI processing - Leader mode: inject into leader session with NO-ACTION instructions - Add consumer.team-notify subscriber for event forwarding - Enrich TeamTaskEventPayload with TaskNumber, ProgressPercent/Step - Add auto-status system prompt section - UI: card-select for delivery mode (Zap/Bot icons), 3 toggles --- cmd/gateway.go | 106 +++++++++ internal/agent/systemprompt_sections.go | 5 + internal/tools/team_access_policy.go | 4 +- internal/tools/team_notify_config.go | 44 ++++ internal/tools/team_tool_dispatch.go | 4 + pkg/protocol/team_events.go | 5 + ui/web/src/pages/teams/team-settings-tab.tsx | 216 ++++++++++++------- ui/web/src/types/team.ts | 9 +- 8 files changed, 311 insertions(+), 82 deletions(-) create mode 100644 internal/tools/team_notify_config.go diff --git a/cmd/gateway.go b/cmd/gateway.go index e5374aed..b6a3ef8d 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -510,6 +510,112 @@ func runGateway() { slog.Info("team task event subscriber registered") } + // Team progress notification subscriber — forwards task events to chat channels. + // Reads team.settings.notifications config; direct mode sends outbound, leader mode + // injects into leader agent session. + if pgStores.Teams != nil { + notifyTeamStore := pgStores.Teams + notifyAgentStore := pgStores.Agents + msgBus.Subscribe("consumer.team-notify", func(evt bus.Event) { + payload, ok := evt.Payload.(protocol.TeamTaskEventPayload) + if !ok || payload.TeamID == "" || payload.Channel == "" { + return + } + // Only forward assigned/failed events (completed handled by announce-back). + var notifyType string + switch evt.Name { + case protocol.EventTeamTaskAssigned: + notifyType = "dispatched" + case protocol.EventTeamTaskFailed: + notifyType = "failed" + case protocol.EventTeamTaskProgress: + notifyType = "progress" + default: + return + } + + teamUUID, err := uuid.Parse(payload.TeamID) + if err != nil { + return + } + team, err := notifyTeamStore.GetTeam(context.Background(), teamUUID) + if err != nil || team == nil { + return + } + cfg := tools.ParseTeamNotifyConfig(team.Settings) + + // Check if this notification type is enabled. + switch notifyType { + case "dispatched": + if !cfg.Dispatched { + return + } + case "failed": + if !cfg.Failed { + return + } + case "progress": + if !cfg.Progress { + return + } + } + + // Skip internal channels. + if payload.Channel == tools.ChannelSystem || payload.Channel == tools.ChannelDelegate { + return + } + + // Build notification message. + var content string + agentName := payload.OwnerAgentKey + if payload.OwnerDisplayName != "" { + agentName = payload.OwnerDisplayName + } + switch notifyType { + case "dispatched": + content = fmt.Sprintf("📋 Task #%d \"%s\" → assigned to %s", payload.TaskNumber, payload.Subject, agentName) + case "progress": + content = fmt.Sprintf("⏳ Task #%d: %d%% — %s", payload.TaskNumber, payload.ProgressPercent, payload.ProgressStep) + case "failed": + reason := payload.Reason + if len(reason) > 200 { + reason = reason[:200] + "..." + } + content = fmt.Sprintf("❌ Task #%d \"%s\" failed: %s", payload.TaskNumber, payload.Subject, reason) + } + + if cfg.Mode == "leader" { + // Route through leader agent — model reformulates. + leadAgent := "" + if notifyAgentStore != nil { + if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil { + leadAgent = la.AgentKey + } + } + if leadAgent == "" { + return + } + leaderContent := fmt.Sprintf("[Auto-status — relay to user, NO task actions]\n%s\n\nBriefly inform the user. Do NOT create, retry, reassign, or modify any tasks.", content) + msgBus.TryPublishInbound(bus.InboundMessage{ + Channel: payload.Channel, + SenderID: "notification:progress", + ChatID: payload.ChatID, + AgentID: leadAgent, + UserID: payload.UserID, + Content: leaderContent, + }) + } else { + // Direct mode — send outbound directly to channel. + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: payload.Channel, + ChatID: payload.ChatID, + Content: content, + }) + } + }) + slog.Info("team progress notification subscriber registered") + } + // Setup graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index a4ed3383..909670a3 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -370,5 +370,10 @@ func buildTeamWorkspaceSection(teamWsPath string) []string { "- Your default workspace (for relative paths) is your personal workspace", "- To delete a team file, use write_file with empty content", "", + "## Auto-Status Updates", + "You may receive [Auto-status] messages about team task progress.", + "These are informational — simply relay the update to the user naturally.", + "Do NOT create, retry, reassign, or modify tasks based on these updates.", + "", } } diff --git a/internal/tools/team_access_policy.go b/internal/tools/team_access_policy.go index 2556c11b..0e755472 100644 --- a/internal/tools/team_access_policy.go +++ b/internal/tools/team_access_policy.go @@ -14,8 +14,8 @@ type teamAccessSettings struct { DenyUserIDs []string `json:"deny_user_ids"` AllowChannels []string `json:"allow_channels"` DenyChannels []string `json:"deny_channels"` - ProgressNotifications *bool `json:"progress_notifications,omitempty"` - FollowupIntervalMins *int `json:"followup_interval_minutes,omitempty"` + Notifications *TeamNotifyConfig `json:"notifications,omitempty"` + FollowupIntervalMins *int `json:"followup_interval_minutes,omitempty"` FollowupMaxReminders *int `json:"followup_max_reminders,omitempty"` EscalationMode string `json:"escalation_mode,omitempty"` EscalationActions []string `json:"escalation_actions,omitempty"` diff --git a/internal/tools/team_notify_config.go b/internal/tools/team_notify_config.go new file mode 100644 index 00000000..29921a26 --- /dev/null +++ b/internal/tools/team_notify_config.go @@ -0,0 +1,44 @@ +package tools + +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 + Progress bool `json:"progress"` // member updates progress + Failed bool `json:"failed"` // task failed + Mode string `json:"mode"` // "direct" (outbound) or "leader" (through leader agent) +} + +// DefaultTeamNotifyConfig returns the default notification config. +func DefaultTeamNotifyConfig() TeamNotifyConfig { + return TeamNotifyConfig{ + Dispatched: true, + Progress: true, + Failed: true, + Mode: "direct", + } +} + +// ParseTeamNotifyConfig extracts notification config from team settings JSON. +// Returns defaults for missing/invalid settings. +func ParseTeamNotifyConfig(settings json.RawMessage) TeamNotifyConfig { + cfg := DefaultTeamNotifyConfig() + if len(settings) == 0 { + return cfg + } + var s struct { + Notifications *TeamNotifyConfig `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.Mode == "leader" { + cfg.Mode = "leader" + } + return cfg +} diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index 9db763dd..74fe461f 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -237,8 +237,12 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: task.ID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, Status: store.TeamTaskStatusInProgress, OwnerAgentKey: m.agentKeyFromID(ctx, *task.OwnerAgentID), + Channel: task.Channel, + ChatID: task.ChatID, Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), ActorType: "system", ActorID: "dispatch_unblocked", diff --git a/pkg/protocol/team_events.go b/pkg/protocol/team_events.go index fe674586..57ad438f 100644 --- a/pkg/protocol/team_events.go +++ b/pkg/protocol/team_events.go @@ -107,6 +107,7 @@ type QualityGateRetryPayload struct { type TeamTaskEventPayload struct { TeamID string `json:"team_id"` TaskID string `json:"task_id"` + TaskNumber int `json:"task_number,omitempty"` Subject string `json:"subject,omitempty"` Status string `json:"status"` OwnerAgentKey string `json:"owner_agent_key,omitempty"` @@ -117,6 +118,10 @@ type TeamTaskEventPayload struct { ChatID string `json:"chat_id"` Timestamp string `json:"timestamp"` + // Progress (for team.task.progress events). + ProgressPercent int `json:"progress_percent,omitempty"` + ProgressStep string `json:"progress_step,omitempty"` + // Actor info for audit trail (recorded to team_task_events by subscriber). ActorType string `json:"actor_type,omitempty"` // "agent", "human", "system" ActorID string `json:"actor_id,omitempty"` // agent key, user ID, or system identifier diff --git a/ui/web/src/pages/teams/team-settings-tab.tsx b/ui/web/src/pages/teams/team-settings-tab.tsx index ce2378f8..77dc6bda 100644 --- a/ui/web/src/pages/teams/team-settings-tab.tsx +++ b/ui/web/src/pages/teams/team-settings-tab.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { Combobox } from "@/components/ui/combobox"; -import { X, Save, Check, Bell, ShieldAlert, Clock, Info, FolderLock, FolderSync } from "lucide-react"; +import { X, Save, Check, Bell, ShieldAlert, Clock, Info, FolderLock, FolderSync, Zap, Bot } from "lucide-react"; import { useTranslation } from "react-i18next"; import { CHANNEL_TYPES } from "@/constants/channels"; import type { TeamData, TeamAccessSettings, EscalationMode, EscalationAction } from "@/types/team"; @@ -71,7 +71,11 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) const [denyUserIds, setDenyUserIds] = useState(initial.deny_user_ids ?? []); const [allowChannels, setAllowChannels] = useState(initial.allow_channels ?? []); const [denyChannels, setDenyChannels] = useState(initial.deny_channels ?? []); - const [progressNotifications, setProgressNotifications] = useState(initial.progress_notifications ?? false); + const initNotify = initial.notifications ?? {}; + const [notifyDispatched, setNotifyDispatched] = useState(initNotify.dispatched ?? true); + const [notifyProgress, setNotifyProgress] = useState(initNotify.progress ?? true); + const [notifyFailed, setNotifyFailed] = useState(initNotify.failed ?? true); + const [notifyMode, setNotifyMode] = useState<"direct" | "leader">(initNotify.mode ?? "direct"); const [escalationMode, setEscalationMode] = useState(initial.escalation_mode ?? ""); const [escalationActions, setEscalationActions] = useState(initial.escalation_actions ?? []); const [followupInterval, setFollowupInterval] = useState(initial.followup_interval_minutes ?? 30); @@ -97,7 +101,11 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) setDenyUserIds(s.deny_user_ids ?? []); setAllowChannels(s.allow_channels ?? []); setDenyChannels(s.deny_channels ?? []); - setProgressNotifications(s.progress_notifications ?? false); + const sn = s.notifications ?? {}; + setNotifyDispatched(sn.dispatched ?? true); + setNotifyProgress(sn.progress ?? true); + setNotifyFailed(sn.failed ?? true); + setNotifyMode(sn.mode ?? "direct"); setEscalationMode(s.escalation_mode ?? ""); setEscalationActions(s.escalation_actions ?? []); setFollowupInterval(s.followup_interval_minutes ?? 30); @@ -117,14 +125,19 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) if (denyUserIds.length > 0) settings.deny_user_ids = denyUserIds; if (allowChannels.length > 0) settings.allow_channels = allowChannels; if (denyChannels.length > 0) settings.deny_channels = denyChannels; - if (progressNotifications) settings.progress_notifications = true; + settings.notifications = { + dispatched: notifyDispatched, + progress: notifyProgress, + failed: notifyFailed, + mode: notifyMode, + }; if (escalationMode) { settings.escalation_mode = escalationMode; if (escalationActions.length > 0) settings.escalation_actions = escalationActions; } if (followupInterval !== 30) settings.followup_interval_minutes = followupInterval; if (followupMaxReminders !== 0) settings.followup_max_reminders = followupMaxReminders; - if (workspaceScope === "shared") settings.workspace_scope = "shared"; + settings.workspace_scope = workspaceScope || "isolated"; if (version >= 2) settings.version = version; await updateTeamSettings(teamId, settings); setSaved(true); @@ -135,7 +148,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) } finally { setSaving(false); } - }, [teamId, version, allowUserIds, denyUserIds, allowChannels, denyChannels, progressNotifications, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved, t]); + }, [teamId, version, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyMode, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved, t]); const userOptions = knownUsers.map((u) => ({ value: u, label: u })); const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label })); @@ -192,87 +205,70 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) - {/* User Access Control */} -
-

{t("settings.userAccessControl")}

-
-
- -

- {t("settings.allowedUsersHint")} -

- -
-
- -

- {t("settings.deniedUsersHint")} -

- -
-
-
- - {/* Channel Restrictions */} -
-

{t("settings.channelRestrictions")}

-
-
- -

- {t("settings.allowedChannelsHint")} -

- -
-
- -

- {t("settings.deniedChannelsHint")} -

- -
-
-
- {/* Notifications */}

{t("settings.notifications")}

-
+
-
+
- {t("settings.progressNotifications")} - +
+ {t("settings.notifyDispatched")} +

{t("settings.notifyDispatchedHint")}

+
+ +
+
+
+ {t("settings.notifyProgress")} +

{t("settings.notifyProgressHint")}

+
+ +
+
+
+ {t("settings.notifyFailed")} +

{t("settings.notifyFailedHint")}

+
+ +
+
+ {t("settings.notifyMode")} +
+ {([ + { value: "direct" as const, Icon: Zap, labelKey: "notifyModeDirect", descKey: "notifyModeDirectDesc" }, + { value: "leader" as const, Icon: Bot, labelKey: "notifyModeLeader", descKey: "notifyModeLeaderDesc" }, + ]).map((opt) => ( + + ))} +
+ {notifyMode === "leader" && ( +

+ ⚠️ {t("settings.notifyModeLeaderWarning")} +

+ )}
-

- {t("settings.progressNotificationsHint")} -

@@ -303,7 +299,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) type="button" onClick={() => setWorkspaceScope(opt.value)} className={ - "flex items-start gap-3 rounded-lg border p-3 text-left transition-colors " + + "flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " + (workspaceScope === opt.value ? "border-primary bg-primary/5" : "border-border hover:border-primary/50") @@ -387,6 +383,68 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
} + {/* User Access Control */} +
+

{t("settings.userAccessControl")}

+
+
+ +

+ {t("settings.allowedUsersHint")} +

+ +
+
+ +

+ {t("settings.deniedUsersHint")} +

+ +
+
+
+ + {/* Channel Restrictions */} +
+

{t("settings.channelRestrictions")}

+
+
+ +

+ {t("settings.allowedChannelsHint")} +

+ +
+
+ +

+ {t("settings.deniedChannelsHint")} +

+ +
+
+
+ {/* Save button */}