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
This commit is contained in:
viettranx committed 2026-03-16 22:46:51 +07:00
1 parent 2c5ae04c01
commit eee79d111e
8 files changed
+311 -82

No files matched your search

+106
View File
@@ -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()
+5
View File
@@ -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.",
"",
}
}
+2 -2
View File
@@ -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"`
+44
View File
@@ -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
}
+4
View File
@@ -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",
+5
View File
@@ -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
+137 -79
View File
@@ -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<string[]>(initial.deny_user_ids ?? []);
const [allowChannels, setAllowChannels] = useState<string[]>(initial.allow_channels ?? []);
const [denyChannels, setDenyChannels] = useState<string[]>(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<EscalationMode | "">(initial.escalation_mode ?? "");
const [escalationActions, setEscalationActions] = useState<EscalationAction[]>(initial.escalation_actions ?? []);
const [followupInterval, setFollowupInterval] = useState<number>(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)
</div>
</div>
{/* User Access Control */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.userAccessControl")}</h3>
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.allowedUsers")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.allowedUsersHint")}
</p>
<MultiSelect
options={userOptions}
selected={allowUserIds}
onChange={setAllowUserIds}
placeholder={t("settings.searchUsers")}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.deniedUsers")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.deniedUsersHint")}
</p>
<MultiSelect
options={userOptions}
selected={denyUserIds}
onChange={setDenyUserIds}
placeholder={t("settings.searchUsers")}
/>
</div>
</div>
</div>
{/* Channel Restrictions */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.channelRestrictions")}</h3>
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.allowedChannels")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.allowedChannelsHint")}
</p>
<MultiSelect
options={channelOptions}
selected={allowChannels}
onChange={setAllowChannels}
placeholder={t("settings.selectChannel")}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.deniedChannels")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.deniedChannelsHint")}
</p>
<MultiSelect
options={channelOptions}
selected={denyChannels}
onChange={setDenyChannels}
placeholder={t("settings.selectChannel")}
/>
</div>
</div>
</div>
{/* Notifications */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.notifications")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-blue-500/5 to-purple-500/5 p-4">
<div className="rounded-lg border bg-gradient-to-r from-blue-500/5 to-purple-500/5 p-4 space-y-3">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-blue-500/10 p-2.5 text-blue-600 dark:text-blue-400">
<Bell className="h-5 w-5" />
</div>
<div className="flex-1 space-y-1">
<div className="flex-1 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{t("settings.progressNotifications")}</span>
<Switch
checked={progressNotifications}
onCheckedChange={setProgressNotifications}
/>
<div>
<span className="text-sm font-semibold">{t("settings.notifyDispatched")}</span>
<p className="text-xs text-muted-foreground">{t("settings.notifyDispatchedHint")}</p>
</div>
<Switch checked={notifyDispatched} onCheckedChange={setNotifyDispatched} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.notifyProgress")}</span>
<p className="text-xs text-muted-foreground">{t("settings.notifyProgressHint")}</p>
</div>
<Switch checked={notifyProgress} onCheckedChange={setNotifyProgress} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold">{t("settings.notifyFailed")}</span>
<p className="text-xs text-muted-foreground">{t("settings.notifyFailedHint")}</p>
</div>
<Switch checked={notifyFailed} onCheckedChange={setNotifyFailed} />
</div>
<div className="border-t pt-3 space-y-2">
<span className="text-sm font-semibold">{t("settings.notifyMode")}</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{([
{ value: "direct" as const, Icon: Zap, labelKey: "notifyModeDirect", descKey: "notifyModeDirectDesc" },
{ value: "leader" as const, Icon: Bot, labelKey: "notifyModeLeader", descKey: "notifyModeLeaderDesc" },
]).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setNotifyMode(opt.value)}
className={
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
(notifyMode === opt.value
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50")
}
>
<opt.Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t(`settings.${opt.descKey}`)}
</div>
</div>
</button>
))}
</div>
{notifyMode === "leader" && (
<p className="text-xs text-amber-600 dark:text-amber-400">
⚠️ {t("settings.notifyModeLeaderWarning")}
</p>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.progressNotificationsHint")}
</p>
</div>
</div>
</div>
@@ -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)
</div>
</div>}
{/* User Access Control */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.userAccessControl")}</h3>
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.allowedUsers")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.allowedUsersHint")}
</p>
<MultiSelect
options={userOptions}
selected={allowUserIds}
onChange={setAllowUserIds}
placeholder={t("settings.searchUsers")}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.deniedUsers")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.deniedUsersHint")}
</p>
<MultiSelect
options={userOptions}
selected={denyUserIds}
onChange={setDenyUserIds}
placeholder={t("settings.searchUsers")}
/>
</div>
</div>
</div>
{/* Channel Restrictions */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.channelRestrictions")}</h3>
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.allowedChannels")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.allowedChannelsHint")}
</p>
<MultiSelect
options={channelOptions}
selected={allowChannels}
onChange={setAllowChannels}
placeholder={t("settings.selectChannel")}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.deniedChannels")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.deniedChannelsHint")}
</p>
<MultiSelect
options={channelOptions}
selected={denyChannels}
onChange={setDenyChannels}
placeholder={t("settings.selectChannel")}
/>
</div>
</div>
</div>
{/* Save button */}
<div className="flex items-center gap-3">
<Button onClick={handleSave} disabled={saving} className="gap-2">
+8 -1
View File
@@ -5,13 +5,20 @@ export type EscalationMode = "auto" | "review" | "reject";
export const ESCALATION_ACTIONS = ["pin", "unpin", "tag", "set_template", "delete"] as const;
export type EscalationAction = (typeof ESCALATION_ACTIONS)[number];
export interface TeamNotifyConfig {
dispatched?: boolean;
progress?: boolean;
failed?: boolean;
mode?: "direct" | "leader";
}
export interface TeamAccessSettings {
version?: number;
allow_user_ids?: string[];
deny_user_ids?: string[];
allow_channels?: string[];
deny_channels?: string[];
progress_notifications?: boolean;
notifications?: TeamNotifyConfig;
escalation_mode?: EscalationMode;
escalation_actions?: EscalationAction[];
followup_interval_minutes?: number;