diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index b2923875..e4f64c8f 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -112,6 +112,9 @@ function validateNotificationsSection( 'ignoredRepositories', 'snoozedUntil', 'snoozeMinutes', + 'notifyOnStatusChange', + 'statusChangeOnlySolo', + 'statusChangeStatuses', 'triggers', ]; @@ -162,6 +165,24 @@ function validateNotificationsSection( } result.notifyOnClarifications = value; break; + case 'notifyOnStatusChange': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.notifyOnStatusChange = value; + break; + case 'statusChangeOnlySolo': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.statusChangeOnlySolo = value; + break; + case 'statusChangeStatuses': + if (!isStringArray(value)) { + return { valid: false, error: `notifications.${key} must be a string[]` }; + } + result.statusChangeStatuses = value; + break; case 'ignoredRegex': if (!isStringArray(value)) { return { valid: false, error: `notifications.${key} must be a string[]` }; diff --git a/src/main/services/analysis/ConversationGroupBuilder.ts b/src/main/services/analysis/ConversationGroupBuilder.ts index 96780091..11dc0585 100644 --- a/src/main/services/analysis/ConversationGroupBuilder.ts +++ b/src/main/services/analysis/ConversationGroupBuilder.ts @@ -47,8 +47,8 @@ export function buildGroups(messages: ParsedMessage[], subagents: Process[]): Co subagents ); - // Link subagents to this group - const groupSubagents = linkSubagentsToGroup(userMsg, nextUserMsg, subagents); + // Link subagents to this group via deterministic parentTaskId matching + const groupSubagents = linkSubagentsToGroup(aiResponses, subagents); // Calculate metrics const { startTime, endTime, durationMs } = calculateGroupTiming(userMsg, aiResponses); @@ -168,25 +168,21 @@ function separateTaskExecutions( } /** - * Link subagents to a conversation group based on timing. + * Link subagents to a conversation group via deterministic parentTaskId matching. + * Only includes subagents whose parentTaskId matches a Task tool_use ID in the AI responses. */ -function linkSubagentsToGroup( - userMsg: ParsedMessage, - nextUserMsg: ParsedMessage | undefined, - allSubagents: Process[] -): Process[] { - const groupSubagents: Process[] = []; - const startTime = userMsg.timestamp; - const endTime = nextUserMsg?.timestamp ?? new Date(Date.now() + 1000 * 60 * 60 * 24); // Far future if no next message - - // Collect subagents that start within this group's time range - for (const subagent of allSubagents) { - if (subagent.startTime >= startTime && subagent.startTime < endTime) { - groupSubagents.push(subagent); +function linkSubagentsToGroup(aiResponses: ParsedMessage[], allSubagents: Process[]): Process[] { + const groupTaskIds = new Set(); + for (const msg of aiResponses) { + for (const toolCall of msg.toolCalls) { + if (toolCall.isTask) { + groupTaskIds.add(toolCall.id); + } } } - - return groupSubagents; + return allSubagents + .filter((s) => s.parentTaskId && groupTaskIds.has(s.parentTaskId)) + .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); } /** diff --git a/src/main/services/analysis/ProcessLinker.ts b/src/main/services/analysis/ProcessLinker.ts index addcc2c1..630e8116 100644 --- a/src/main/services/analysis/ProcessLinker.ts +++ b/src/main/services/analysis/ProcessLinker.ts @@ -1,23 +1,18 @@ /** * ProcessLinker service - Links subagent processes to AI chunks. * - * Uses a two-tier linking strategy: - * 1. Primary: parentTaskId matching - Links subagents to chunks containing the Task tool call - * that spawned them. This is reliable even when the response is still in progress. - * 2. Fallback: Timing-based - For orphaned subagents without parentTaskId, falls back to - * checking if the subagent's startTime falls within the chunk's time range. + * Uses deterministic parentTaskId matching only. If a subagent has no parentTaskId + * or it doesn't match any Task call in the chunk, the subagent is NOT linked. + * No timing-based or positional fallbacks — avoids false positives. */ import { type EnhancedAIChunk, type Process } from '@main/types'; /** - * Link processes to a single AI chunk. + * Link processes to a single AI chunk via deterministic parentTaskId matching. * - * Uses a two-tier linking strategy: - * 1. Primary: parentTaskId matching - Links subagents to chunks containing the Task tool call - * that spawned them. This is reliable even when the response is still in progress. - * 2. Fallback: Timing-based - For orphaned subagents without parentTaskId, falls back to - * checking if the subagent's startTime falls within the chunk's time range. + * Only links subagents whose parentTaskId matches a Task tool_use ID in the chunk. + * Subagents without parentTaskId or with non-matching parentTaskId are skipped. */ export function linkProcessesToAIChunk(chunk: EnhancedAIChunk, subagents: Process[]): void { // Build set of Task tool IDs from this chunk's responses @@ -30,30 +25,10 @@ export function linkProcessesToAIChunk(chunk: EnhancedAIChunk, subagents: Proces } } - // Track which subagents have been linked - const linkedSubagentIds = new Set(); - - // Primary linking: Match subagents to Task calls by parentTaskId + // Deterministic linking: Match subagents to Task calls by parentTaskId only for (const subagent of subagents) { if (subagent.parentTaskId && chunkTaskIds.has(subagent.parentTaskId)) { chunk.processes.push(subagent); - linkedSubagentIds.add(subagent.id); - } - } - - // Fallback linking: For orphaned subagents, use timing-based matching - // This handles edge cases where parentTaskId might not be set - for (const subagent of subagents) { - if (linkedSubagentIds.has(subagent.id)) { - continue; // Already linked via parentTaskId - } - - // Only use timing fallback if subagent has no parentTaskId - // (If it has parentTaskId but didn't match, it belongs to a different chunk) - if (!subagent.parentTaskId) { - if (subagent.startTime >= chunk.startTime && subagent.startTime <= chunk.endTime) { - chunk.processes.push(subagent); - } } } diff --git a/src/main/services/discovery/SubagentResolver.ts b/src/main/services/discovery/SubagentResolver.ts index b514518f..7e81c5ee 100644 --- a/src/main/services/discovery/SubagentResolver.ts +++ b/src/main/services/discovery/SubagentResolver.ts @@ -153,16 +153,16 @@ export class SubagentResolver { } /** - * Extract the summary attribute from the first tag in a subagent's messages. - * Returns the summary string if found, undefined otherwise. - * Used to match team member files to their spawning Task calls. + * Extract the teammate_id attribute from the first tag in a subagent's messages. + * Returns the teammate_id string if found, undefined otherwise. + * Used for deterministic matching of team member files to their spawning Task calls. */ - private extractTeamMessageSummary(messages: ParsedMessage[]): string | undefined { + private extractTeammateId(messages: ParsedMessage[]): string | undefined { const firstUserMessage = messages.find((m) => m.type === 'user'); if (!firstUserMessage) return undefined; const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : ''; - const match = /]*\bsummary="([^"]+)"/.exec(text); + const match = /]*\bteammate_id="([^"]+)"/.exec(text); return match?.[1]; } @@ -250,38 +250,41 @@ export class SubagentResolver { if (!taskCall) continue; this.enrichSubagentFromTask(subagent, taskCall); + subagent.linkType = 'agent-id'; matchedSubagentIds.add(subagent.id); matchedTaskIds.add(taskCallId); } - // Phase 2: Description-based matching for team members + // Phase 2: Deterministic teammate_id matching for team members // Team spawns use agent_id = "name@team_name" (not a file UUID), so Phase 1 can't match them. - // Instead, match by comparing the Task description to the summary attribute in the - // subagent file's first tag. + // Instead, match by comparing Task call input.name to the teammate_id XML attribute + // in the subagent file's first tag. const teamTaskCalls = taskCallsOnly.filter( - (tc) => !matchedTaskIds.has(tc.id) && tc.input?.team_name && tc.input?.name + (tc) => + !matchedTaskIds.has(tc.id) && + typeof tc.input?.team_name === 'string' && + typeof tc.input?.name === 'string' ); if (teamTaskCalls.length > 0) { - // Pre-extract summaries from unmatched subagent files - const subagentSummaries = new Map(); + // Pre-extract teammate_ids from unmatched subagent files + const subagentTeammateIds = new Map(); for (const subagent of subagents) { if (matchedSubagentIds.has(subagent.id)) continue; - const summary = this.extractTeamMessageSummary(subagent.messages); - if (summary) { - subagentSummaries.set(subagent.id, summary); + const teammateId = this.extractTeammateId(subagent.messages); + if (teammateId) { + subagentTeammateIds.set(subagent.id, teammateId); } } - // Match each team Task call to the earliest subagent file with matching summary + // Match each team Task call to the earliest subagent file with matching teammate_id for (const taskCall of teamTaskCalls) { - const description = taskCall.taskDescription; - if (!description) continue; + const inputName = taskCall.input?.name as string; let bestMatch: Process | undefined; for (const subagent of subagents) { if (matchedSubagentIds.has(subagent.id)) continue; - if (subagentSummaries.get(subagent.id) !== description) continue; + if (subagentTeammateIds.get(subagent.id) !== inputName) continue; if (!bestMatch || subagent.startTime < bestMatch.startTime) { bestMatch = subagent; } @@ -289,22 +292,18 @@ export class SubagentResolver { if (bestMatch) { this.enrichSubagentFromTask(bestMatch, taskCall); + bestMatch.linkType = 'team-member-id'; matchedSubagentIds.add(bestMatch.id); matchedTaskIds.add(taskCall.id); } } } - // Phase 3: Positional fallback for remaining unmatched non-team subagents (no wrap-around) - const unmatchedSubagents = [...subagents] - .filter((s) => !matchedSubagentIds.has(s.id)) - .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); - const unmatchedTasks = taskCallsOnly.filter( - (tc) => !matchedTaskIds.has(tc.id) && !(tc.input?.team_name && tc.input?.name) - ); - - for (let i = 0; i < unmatchedSubagents.length && i < unmatchedTasks.length; i++) { - this.enrichSubagentFromTask(unmatchedSubagents[i], unmatchedTasks[i]); + // Mark remaining unmatched subagents as unlinked (no Phase 3 positional fallback) + for (const subagent of subagents) { + if (!matchedSubagentIds.has(subagent.id) && !subagent.linkType) { + subagent.linkType = 'unlinked'; + } } } @@ -398,6 +397,7 @@ export class SubagentResolver { subagent.parentTaskId = subagent.parentTaskId ?? ancestor.parentTaskId; subagent.description = subagent.description ?? ancestor.description; subagent.subagentType = subagent.subagentType ?? ancestor.subagentType; + subagent.linkType = subagent.linkType ?? (ancestor.linkType ? 'parent-chain' : undefined); } } } diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 0fb40631..3b370905 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -46,6 +46,12 @@ export interface NotificationConfig { notifyOnUserInbox: boolean; /** Whether to show native OS notifications when a task needs user clarification */ notifyOnClarifications: boolean; + /** Whether to show native OS notifications when a task status changes */ + notifyOnStatusChange: boolean; + /** Only notify on status changes in solo teams (no teammates) */ + statusChangeOnlySolo: boolean; + /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ + statusChangeStatuses: string[]; /** Notification triggers - define when to generate notifications */ triggers: NotificationTrigger[]; } @@ -254,6 +260,9 @@ const DEFAULT_CONFIG: AppConfig = { notifyOnLeadInbox: false, notifyOnUserInbox: true, notifyOnClarifications: true, + notifyOnStatusChange: true, + statusChangeOnlySolo: true, + statusChangeStatuses: ['in_progress', 'completed'], triggers: DEFAULT_TRIGGERS, }, general: { diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index ba58bdc6..a3c88ee3 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -1,6 +1,8 @@ +import { getTasksBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { createReadStream } from 'fs'; -import { stat } from 'fs/promises'; +import { readFile, stat } from 'fs/promises'; +import * as path from 'path'; import * as readline from 'readline'; import { TeamConfigReader } from './TeamConfigReader'; @@ -104,7 +106,11 @@ export class ChangeExtractorService { /** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */ async getTaskChanges(teamName: string, taskId: string): Promise { - const logs = await this.logsFinder.findLogsForTask(teamName, taskId); + const taskMeta = await this.readTaskMeta(teamName, taskId); + const logs = await this.logsFinder.findLogsForTask(teamName, taskId, { + owner: taskMeta?.owner, + status: taskMeta?.status, + }); const logRefs = await this.resolveLogFileRefs(teamName, logs); if (logRefs.length === 0) { return this.emptyTaskChangeSet(teamName, taskId); @@ -163,6 +169,24 @@ export class ChangeExtractorService { // ---- Private methods ---- + /** Read task metadata (owner, status) from the task JSON file */ + private async readTaskMeta( + teamName: string, + taskId: string + ): Promise<{ owner?: string; status?: string } | null> { + try { + const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); + const raw = await readFile(taskPath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + return { + owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, + status: typeof parsed.status === 'string' ? parsed.status : undefined, + }; + } catch { + return null; + } + } + /** Получить projectPath из конфига команды */ private async resolveProjectPath(teamName: string): Promise { try { diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 4ff3b723..c95e579b 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -176,8 +176,10 @@ export class TeamMemberLogsFinder { typeof normalizedOwner === 'string' && normalizedOwner.length > 0 && normalizedOwner.toLowerCase() === leadMemberName.toLowerCase(); + const ownerRelevantStatus = + options?.status === 'in_progress' || options?.status === 'completed'; const includeOwnerSessions = - options?.status === 'in_progress' && + ownerRelevantStatus && typeof normalizedOwner === 'string' && normalizedOwner.length > 0 && !isLeadOwner; diff --git a/src/main/types/chunks.ts b/src/main/types/chunks.ts index 68e7145c..d24d3913 100644 --- a/src/main/types/chunks.ts +++ b/src/main/types/chunks.ts @@ -46,6 +46,8 @@ export interface Process { isParallel: boolean; /** The tool_use ID of the Task call that spawned this */ parentTaskId?: string; + /** How this process was linked to its parent Task call */ + linkType?: 'agent-id' | 'team-member-id' | 'parent-chain' | 'unlinked'; /** Whether this subagent is still in progress */ isOngoing?: boolean; /** diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index eda651ab..8b8016cd 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -151,6 +151,7 @@ export const SettingsView = (): React.JSX.Element | null => { onAddTrigger={handlers.handleAddTrigger} onUpdateTrigger={handlers.handleUpdateTrigger} onRemoveTrigger={handlers.handleRemoveTrigger} + onStatusChangeStatusesUpdate={handlers.handleStatusChangeStatusesUpdate} /> )} diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 4ff8b090..3951f170 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -10,6 +10,7 @@ import { useStore } from '@renderer/store'; import { useShallow } from 'zustand/react/shallow'; import type { AppConfig } from '@renderer/types/data'; +import type { TeamTaskStatus } from '@shared/types'; // Get the setState function from the store to update appConfig globally const setStoreState = useStore.setState; @@ -45,6 +46,9 @@ export interface SafeConfig { notifyOnLeadInbox: boolean; notifyOnUserInbox: boolean; notifyOnClarifications: boolean; + notifyOnStatusChange: boolean; + statusChangeOnlySolo: boolean; + statusChangeStatuses: TeamTaskStatus[]; triggers: AppConfig['notifications']['triggers']; }; display: { @@ -175,6 +179,11 @@ export function useSettingsConfig(): UseSettingsConfigReturn { notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false, notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true, notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true, + notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true, + statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true, + statusChangeStatuses: (displayConfig?.notifications?.statusChangeStatuses as + | TeamTaskStatus[] + | undefined) ?? ['in_progress', 'completed'], triggers: displayConfig?.notifications?.triggers ?? [], }, display: { diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 669b8687..3e617270 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -35,6 +35,7 @@ interface SettingsHandlers { // Notification handlers handleNotificationToggle: (key: keyof AppConfig['notifications'], value: boolean) => void; + handleStatusChangeStatusesUpdate: (statuses: string[]) => void; handleSnooze: (minutes: number) => Promise; handleClearSnooze: () => Promise; handleAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise; @@ -104,6 +105,13 @@ export function useSettingsHandlers({ [updateConfig] ); + const handleStatusChangeStatusesUpdate = useCallback( + (statuses: string[]) => { + void updateConfig('notifications', { statusChangeStatuses: statuses }); + }, + [updateConfig] + ); + const handleSnooze = useCallback( async (minutes: number) => { try { @@ -290,6 +298,9 @@ export function useSettingsHandlers({ notifyOnLeadInbox: false, notifyOnUserInbox: true, notifyOnClarifications: true, + notifyOnStatusChange: true, + statusChangeOnlySolo: true, + statusChangeStatuses: ['in_progress', 'completed'], triggers: defaultTriggers, }, general: { @@ -390,6 +401,7 @@ export function useSettingsHandlers({ handleLanguageChange, handleDefaultTabChange, handleNotificationToggle, + handleStatusChangeStatusesUpdate, handleSnooze, handleClearSnooze, handleAddIgnoredRepository, diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index 5c47e69f..1ec89ba2 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -14,6 +14,7 @@ import { NotificationTriggerSettings } from '../NotificationTriggerSettings'; import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsConfig'; import type { NotificationTrigger } from '@renderer/types/data'; +import type { TeamTaskStatus } from '@shared/types'; // Snooze duration options const SNOOZE_OPTIONS = [ @@ -38,9 +39,12 @@ interface NotificationsSectionProps { | 'includeSubagentErrors' | 'notifyOnLeadInbox' | 'notifyOnUserInbox' - | 'notifyOnClarifications', + | 'notifyOnClarifications' + | 'notifyOnStatusChange' + | 'statusChangeOnlySolo', value: boolean ) => void; + readonly onStatusChangeStatusesUpdate: (statuses: TeamTaskStatus[]) => void; readonly onSnooze: (minutes: number) => Promise; readonly onClearSnooze: () => Promise; readonly onAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise; @@ -67,6 +71,7 @@ export const NotificationsSection = ({ onAddTrigger, onUpdateTrigger, onRemoveTrigger, + onStatusChangeStatusesUpdate, }: NotificationsSectionProps): React.JSX.Element => { return (
@@ -166,6 +171,40 @@ export const NotificationsSection = ({ disabled={saving || !safeConfig.notifications.enabled} /> + + onNotificationToggle('notifyOnStatusChange', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + + {safeConfig.notifications.notifyOnStatusChange && safeConfig.notifications.enabled ? ( + <> + + onNotificationToggle('statusChangeOnlySolo', v)} + disabled={saving} + /> + + + + + + ) : null} ); }; + +const STATUS_OPTIONS: { value: TeamTaskStatus; label: string }[] = [ + { value: 'in_progress', label: 'Started' }, + { value: 'completed', label: 'Completed' }, + { value: 'pending', label: 'Pending' }, + { value: 'deleted', label: 'Deleted' }, +]; + +const StatusCheckboxGroup = ({ + selected, + onChange, + disabled, +}: { + selected: TeamTaskStatus[]; + onChange: (statuses: TeamTaskStatus[]) => void; + disabled: boolean; +}) => ( +
+ {STATUS_OPTIONS.map((opt) => { + const checked = selected.includes(opt.value); + return ( + + ); + })} +
+); diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 2ac57f73..8f1a405c 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -25,6 +25,8 @@ interface CollapsibleTeamSectionProps { action?: React.ReactNode; /** Stable identifier used for programmatic section navigation. */ sectionId?: string; + /** Extra classes applied to the content wrapper (e.g. padding). */ + contentClassName?: string; children: React.ReactNode; } @@ -38,6 +40,7 @@ export const CollapsibleTeamSection = ({ forceOpen, action, sectionId, + contentClassName, children, }: CollapsibleTeamSectionProps): React.JSX.Element => { const [open, setOpen] = useState(defaultOpen); @@ -93,7 +96,11 @@ export const CollapsibleTeamSection = ({
{action &&
{action}
} - {isOpen &&
{children}
} + {isOpen && ( +
+ {children} +
+ )} ); }; diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index d25ffc1f..c96ba6f6 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -55,13 +55,12 @@ export const TeamProvisioningBanner = ({ return null; } - if (progress.state === 'cancelled') { + if (progress.state === 'cancelled' || progress.state === 'disconnected') { return null; } const isReady = progress.state === 'ready'; const isFailed = progress.state === 'failed'; - const isDisconnected = progress.state === 'disconnected'; const isActive = progress.state === 'validating' || progress.state === 'spawning' || @@ -106,22 +105,6 @@ export const TeamProvisioningBanner = ({ ); } - if (isDisconnected) { - return ( -
-

Team offline

- -
- ); - } - if (isReady) { return (
diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index f0538ba3..d5e7e654 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -6,6 +6,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_BG, + CARD_BG_ZEBRA, CARD_BORDER_STYLE, CARD_ICON_MUTED, CARD_TEXT_LIGHT, @@ -43,6 +44,8 @@ interface ActivityItemProps { onReply?: (message: InboxMessage) => void; /** Called when a task ID link (e.g. #10) is clicked in message text. */ onTaskIdClick?: (taskId: string) => void; + /** When true, apply a subtle lighter background for zebra-striped lists. */ + zebraShade?: boolean; } function getStringField(obj: StructuredMessage, key: string): string | null { @@ -50,6 +53,12 @@ function getStringField(obj: StructuredMessage, key: string): string | null { return typeof value === 'string' && value.trim() !== '' ? value : null; } +/** Check if a message renders as a compact noise row (idle, shutdown, etc.). */ +export function isNoiseMessage(text: string): boolean { + const parsed = parseStructuredAgentMessage(text); + return parsed !== null && getNoiseLabel(parsed) !== null; +} + function getNoiseLabel(parsed: StructuredMessage): string | null { const type = getStringField(parsed, 'type'); @@ -165,6 +174,7 @@ export const ActivityItem = ({ onCreateTask, onReply, onTaskIdClick, + zebraShade, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const formattedRole = formatAgentRole(memberRole); @@ -227,7 +237,12 @@ export const ActivityItem = ({
void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; @@ -93,6 +96,7 @@ const MessageRowWithObserver = ({ memberColor={memberColor} recipientColor={recipientColor} isUnread={isUnread} + zebraShade={zebraShade} onMemberNameClick={onMemberNameClick} onCreateTask={onCreateTask} onReply={onReply} @@ -172,6 +176,18 @@ export const ActivityTimeline = ({ [messages, visibleCount, hiddenCount] ); + // Zebra striping: alternate shade on non-noise (full card) messages only. + const zebraShadeSet = useMemo(() => { + const result = new Set(); + let cardCount = 0; + for (let i = 0; i < visibleMessages.length; i++) { + if (isNoiseMessage(visibleMessages[i].text)) continue; + if (cardCount % 2 === 1) result.add(i); + cardCount++; + } + return result; + }, [visibleMessages]); + // Determine which messages are "new" (should animate). const newMessageKeys = useMemo(() => { @@ -251,6 +267,7 @@ export const ActivityTimeline = ({ recipientColor={recipientColor} isUnread={isUnread} isNew={newMessageKeys.has(messageKey)} + zebraShade={zebraShadeSet.has(index)} onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined} onCreateTask={onCreateTaskFromMessage} onReply={onReplyToMessage} diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 3f56f891..cea7c649 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -154,7 +154,7 @@ export const TaskCommentsSection = ({
) : null} - {visibleComments.map((comment) => ( + {visibleComments.map((comment, index) => (
diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 4c7c7e32..6888a2cb 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -453,7 +453,12 @@ export const TaskDetailDialog = ({ ) : null} {/* Description */} - } defaultOpen> + } + contentClassName="pl-2.5" + defaultOpen + > {editingDescription ? (
@@ -563,6 +568,7 @@ export const TaskDetailDialog = ({ title="Changes" icon={} badge={taskChangesFiles ? taskChangesFiles.length : undefined} + contentClassName="pl-2.5" defaultOpen={taskKnownHasChanges} > {changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? ( @@ -616,6 +622,7 @@ export const TaskDetailDialog = ({ ) : null } + contentClassName="pl-2.5" defaultOpen >
@@ -766,6 +773,7 @@ export const TaskDetailDialog = ({ title="Status History" icon={} badge={currentTask.statusHistory.length} + contentClassName="pl-2.5" defaultOpen={false} > @@ -781,6 +789,7 @@ export const TaskDetailDialog = ({ ? (currentTask.comments?.length ?? 0) : undefined } + contentClassName="pl-2.5" defaultOpen > (); +const notifiedStatusChangeKeys = new Set(); let isFirstFetchAllTasks = true; @@ -127,6 +129,61 @@ function fireClarificationNotification(task: GlobalTask): void { .catch(() => undefined); } +function detectStatusChangeNotifications( + oldTasks: GlobalTask[], + newTasks: GlobalTask[], + config: AppConfig | null, + teamByName: Record +): void { + if (!config?.notifications?.notifyOnStatusChange) return; + if (!config.notifications.enabled) return; + + const statuses = config.notifications.statusChangeStatuses ?? ['in_progress', 'completed']; + if (statuses.length === 0) return; + + const onlySolo = config.notifications.statusChangeOnlySolo ?? true; + + for (const task of newTasks) { + const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id); + if (!oldTask) continue; + if (oldTask.status === task.status) continue; + + if (onlySolo) { + const team = teamByName[task.teamName]; + if (team && team.memberCount > 0) continue; + } + + if (!statuses.includes(task.status)) continue; + + const key = `${task.teamName}:${task.id}:${task.status}`; + if (notifiedStatusChangeKeys.has(key)) continue; + notifiedStatusChangeKeys.add(key); + + fireStatusChangeNotification(task, oldTask.status); + } +} + +function fireStatusChangeNotification(task: GlobalTask, fromStatus: string): void { + const statusLabels: Record = { + pending: 'Pending', + in_progress: 'In Progress', + completed: 'Completed', + deleted: 'Deleted', + }; + const from = statusLabels[fromStatus] ?? fromStatus; + const to = statusLabels[task.status] ?? task.status; + + void api.teams + ?.showMessageNotification({ + teamDisplayName: task.teamDisplayName, + from: task.owner ?? 'system', + to: 'user', + summary: `Task #${task.id}: ${from} → ${to}`, + body: task.subject, + }) + .catch(() => undefined); +} + function mapSendMessageError(error: unknown): string { const message = error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; @@ -380,12 +437,14 @@ export const createTeamSlice: StateCreator = (set, const notifyOnClarifications = get().appConfig?.notifications?.notifyOnClarifications ?? true; detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications); + detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName); } else { - // Initial load — seed the Set to prevent false notifications on next update + // Initial load — seed the Sets to prevent false notifications on next update for (const task of tasks) { if (task.needsClarification === 'user') { notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`); } + notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`); } } diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 343ecf8e..030aa63f 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -253,6 +253,12 @@ export interface AppConfig { notifyOnUserInbox: boolean; /** Whether to show native OS notifications when a task needs user clarification */ notifyOnClarifications: boolean; + /** Whether to show native OS notifications when a task status changes */ + notifyOnStatusChange: boolean; + /** Only notify on status changes in solo teams (no teammates) */ + statusChangeOnlySolo: boolean; + /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ + statusChangeStatuses: string[]; /** Notification triggers - define when to generate notifications */ triggers: NotificationTrigger[]; }; diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index cf8becc2..b3e343c7 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -109,33 +109,76 @@ describe('configValidation', () => { } }); - it.each(['notifyOnLeadInbox', 'notifyOnUserInbox', 'notifyOnClarifications'] as const)( - 'accepts boolean %s toggle', - (key) => { - const resultOn = validateConfigUpdatePayload('notifications', { [key]: true }); - expect(resultOn.valid).toBe(true); - if (resultOn.valid) { - expect(resultOn.data).toEqual({ [key]: true }); - } - - const resultOff = validateConfigUpdatePayload('notifications', { [key]: false }); - expect(resultOff.valid).toBe(true); - if (resultOff.valid) { - expect(resultOff.data).toEqual({ [key]: false }); - } + it.each([ + 'notifyOnLeadInbox', + 'notifyOnUserInbox', + 'notifyOnClarifications', + 'notifyOnStatusChange', + 'statusChangeOnlySolo', + ] as const)('accepts boolean %s toggle', (key) => { + const resultOn = validateConfigUpdatePayload('notifications', { [key]: true }); + expect(resultOn.valid).toBe(true); + if (resultOn.valid) { + expect(resultOn.data).toEqual({ [key]: true }); } - ); - it.each(['notifyOnLeadInbox', 'notifyOnUserInbox', 'notifyOnClarifications'] as const)( - 'rejects non-boolean %s', - (key) => { - const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' }); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.error).toContain('boolean'); - } + const resultOff = validateConfigUpdatePayload('notifications', { [key]: false }); + expect(resultOff.valid).toBe(true); + if (resultOff.valid) { + expect(resultOff.data).toEqual({ [key]: false }); } - ); + }); + + it.each([ + 'notifyOnLeadInbox', + 'notifyOnUserInbox', + 'notifyOnClarifications', + 'notifyOnStatusChange', + 'statusChangeOnlySolo', + ] as const)('rejects non-boolean %s', (key) => { + const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('boolean'); + } + }); + + it('accepts valid statusChangeStatuses string array', () => { + const result = validateConfigUpdatePayload('notifications', { + statusChangeStatuses: ['completed', 'in_progress'], + }); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual({ statusChangeStatuses: ['completed', 'in_progress'] }); + } + }); + + it('accepts empty statusChangeStatuses array', () => { + const result = validateConfigUpdatePayload('notifications', { + statusChangeStatuses: [], + }); + expect(result.valid).toBe(true); + }); + + it('rejects non-array statusChangeStatuses', () => { + const result = validateConfigUpdatePayload('notifications', { + statusChangeStatuses: true, + }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('string[]'); + } + }); + + it('rejects statusChangeStatuses with non-string items', () => { + const result = validateConfigUpdatePayload('notifications', { + statusChangeStatuses: [42], + }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('string[]'); + } + }); it('rejects out-of-range snoozeMinutes', () => { const result = validateConfigUpdatePayload('notifications', { snoozeMinutes: 0 }); diff --git a/test/main/services/analysis/ChunkBuilder.test.ts b/test/main/services/analysis/ChunkBuilder.test.ts index 25912c12..28fa520d 100644 --- a/test/main/services/analysis/ChunkBuilder.test.ts +++ b/test/main/services/analysis/ChunkBuilder.test.ts @@ -385,6 +385,50 @@ describe('ChunkBuilder', () => { expect(chunks[0].processes[0].id).toBe(subagent.id); } }); + + it('should NOT link subagent without parentTaskId (no timing fallback)', () => { + const taskId = 'task-456'; + const messages = [ + createMessage({ + type: 'assistant', + timestamp: new Date('2026-01-01T00:00:00Z'), + content: [ + { type: 'text', text: 'Spawning' }, + { + type: 'tool_use', + id: taskId, + name: 'Task', + input: { prompt: 'Do something' }, + }, + ], + toolCalls: [ + { + id: taskId, + name: 'Task', + input: { prompt: 'Do something' }, + isTask: true, + taskDescription: 'Do something', + taskSubagentType: 'explore', + }, + ], + }), + ]; + + // Subagent with NO parentTaskId — should NOT be linked even if time overlaps + const orphan = createSubagent({ + parentTaskId: undefined, + startTime: new Date('2026-01-01T00:00:01Z'), + endTime: new Date('2026-01-01T00:00:30Z'), + }); + + const chunks = builder.buildChunks(messages, [orphan]); + expect(chunks).toHaveLength(1); + expect(isAIChunk(chunks[0])).toBe(true); + + if (isAIChunk(chunks[0])) { + expect(chunks[0].processes).toHaveLength(0); + } + }); }); }); diff --git a/test/main/services/analysis/ProcessLinker.test.ts b/test/main/services/analysis/ProcessLinker.test.ts new file mode 100644 index 00000000..cc2943ec --- /dev/null +++ b/test/main/services/analysis/ProcessLinker.test.ts @@ -0,0 +1,187 @@ +/** + * Tests for ProcessLinker — deterministic parentTaskId-only linking. + * + * Verifies: + * - Subagents with matching parentTaskId are linked to the chunk + * - Subagents without parentTaskId are NOT linked (no timing fallback) + * - Subagents with non-matching parentTaskId are NOT linked + * - Multiple subagents linked and sorted by startTime + * - Empty subagents array produces empty processes + * - Empty chunk (no Task calls) links nothing + * - Duplicate parentTaskId: both subagents linked + * - Already-populated chunk.processes is appended to + */ + +import { describe, expect, it } from 'vitest'; + +import { linkProcessesToAIChunk } from '../../../../src/main/services/analysis/ProcessLinker'; + +import type { EnhancedAIChunk, Process, SessionMetrics } from '../../../../src/main/types'; + +// ============================================================================= +// Helpers +// ============================================================================= + +const baseMetrics: SessionMetrics = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + messageCount: 0, + durationMs: 0, +}; + +function makeChunk(taskIds: string[]): EnhancedAIChunk { + return { + type: 'ai', + responses: [ + { + uuid: 'resp-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-01-01T00:00:00Z'), + content: [{ type: 'text', text: 'response' }], + isSidechain: false, + isMeta: false, + toolCalls: taskIds.map((id) => ({ + id, + name: 'Task', + input: { prompt: 'do stuff' }, + isTask: true, + taskDescription: 'do stuff', + taskSubagentType: 'general-purpose', + })), + toolResults: [], + }, + ], + processes: [], + startTime: new Date('2026-01-01T00:00:00Z'), + endTime: new Date('2026-01-01T00:01:00Z'), + durationMs: 60_000, + metrics: { ...baseMetrics }, + }; +} + +function makeSubagent(overrides: Partial & { id: string }): Process { + return { + filePath: `/path/${overrides.id}.jsonl`, + parentTaskId: undefined, + description: 'test', + subagentType: 'general-purpose', + isParallel: false, + startTime: new Date('2026-01-01T00:00:10Z'), + endTime: new Date('2026-01-01T00:00:50Z'), + durationMs: 40_000, + messages: [], + metrics: { ...baseMetrics }, + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('linkProcessesToAIChunk', () => { + it('links subagent with matching parentTaskId', () => { + const chunk = makeChunk(['task-1']); + const sub = makeSubagent({ id: 'agent-a', parentTaskId: 'task-1' }); + + linkProcessesToAIChunk(chunk, [sub]); + + expect(chunk.processes).toHaveLength(1); + expect(chunk.processes[0].id).toBe('agent-a'); + }); + + it('does NOT link subagent without parentTaskId (no timing fallback)', () => { + const chunk = makeChunk(['task-1']); + const sub = makeSubagent({ + id: 'orphan', + parentTaskId: undefined, + startTime: new Date('2026-01-01T00:00:30Z'), // within chunk time range + }); + + linkProcessesToAIChunk(chunk, [sub]); + + expect(chunk.processes).toHaveLength(0); + }); + + it('does NOT link subagent with non-matching parentTaskId', () => { + const chunk = makeChunk(['task-1']); + const sub = makeSubagent({ id: 'agent-b', parentTaskId: 'task-999' }); + + linkProcessesToAIChunk(chunk, [sub]); + + expect(chunk.processes).toHaveLength(0); + }); + + it('links multiple subagents sorted by startTime', () => { + const chunk = makeChunk(['task-1', 'task-2']); + const sub1 = makeSubagent({ + id: 'late', + parentTaskId: 'task-1', + startTime: new Date('2026-01-01T00:00:30Z'), + }); + const sub2 = makeSubagent({ + id: 'early', + parentTaskId: 'task-2', + startTime: new Date('2026-01-01T00:00:10Z'), + }); + + linkProcessesToAIChunk(chunk, [sub1, sub2]); + + expect(chunk.processes).toHaveLength(2); + expect(chunk.processes[0].id).toBe('early'); + expect(chunk.processes[1].id).toBe('late'); + }); + + it('handles empty subagents array', () => { + const chunk = makeChunk(['task-1']); + + linkProcessesToAIChunk(chunk, []); + + expect(chunk.processes).toHaveLength(0); + }); + + it('handles chunk with no Task calls', () => { + const chunk = makeChunk([]); + const sub = makeSubagent({ id: 'agent-a', parentTaskId: 'task-1' }); + + linkProcessesToAIChunk(chunk, [sub]); + + expect(chunk.processes).toHaveLength(0); + }); + + it('links both subagents when they share the same parentTaskId', () => { + const chunk = makeChunk(['task-1']); + const sub1 = makeSubagent({ + id: 'a1', + parentTaskId: 'task-1', + startTime: new Date('2026-01-01T00:00:20Z'), + }); + const sub2 = makeSubagent({ + id: 'a2', + parentTaskId: 'task-1', + startTime: new Date('2026-01-01T00:00:10Z'), + }); + + linkProcessesToAIChunk(chunk, [sub1, sub2]); + + expect(chunk.processes).toHaveLength(2); + expect(chunk.processes[0].id).toBe('a2'); // earlier + expect(chunk.processes[1].id).toBe('a1'); + }); + + it('appends to existing chunk.processes', () => { + const chunk = makeChunk(['task-1']); + const existing = makeSubagent({ id: 'existing', parentTaskId: 'task-0' }); + chunk.processes.push(existing); + + const sub = makeSubagent({ id: 'new', parentTaskId: 'task-1' }); + linkProcessesToAIChunk(chunk, [sub]); + + // existing + new, sorted by time + expect(chunk.processes).toHaveLength(2); + }); +}); diff --git a/test/main/services/discovery/SubagentResolver.linkType.test.ts b/test/main/services/discovery/SubagentResolver.linkType.test.ts new file mode 100644 index 00000000..dd4665d7 --- /dev/null +++ b/test/main/services/discovery/SubagentResolver.linkType.test.ts @@ -0,0 +1,343 @@ +/** + * Tests for SubagentResolver linkType assignment. + * + * Verifies: + * - Phase 1: agentId match → linkType 'agent-id' + * - Phase 2: teammate_id match → linkType 'team-member-id' + * - Unmatched subagents → linkType 'unlinked' + * - No positional fallback (Phase 3 removed) + * - propagateTeamMetadata → linkType 'parent-chain' + * - Different description but same teammate_id still matches + */ + +import { describe, expect, it } from 'vitest'; + +import { SubagentResolver } from '../../../../src/main/services/discovery/SubagentResolver'; + +import type { ParsedMessage, Process } from '../../../../src/main/types'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function msg(overrides: Partial): ParsedMessage { + return { + uuid: `msg-${Math.random().toString(36).slice(2, 9)}`, + parentUuid: null, + type: 'user', + timestamp: new Date('2026-01-01T00:00:00Z'), + content: '', + isSidechain: false, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +function subagent(overrides: Partial & { id: string }): Process { + return { + filePath: `/path/${overrides.id}.jsonl`, + parentTaskId: undefined, + isParallel: false, + startTime: new Date('2026-01-01T00:00:05Z'), + endTime: new Date('2026-01-01T00:00:55Z'), + durationMs: 50_000, + messages: [], + metrics: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + messageCount: 0, + durationMs: 0, + }, + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('SubagentResolver.linkType', () => { + const resolver = new SubagentResolver(); + + // Access private method via prototype for testing + const linkToTaskCalls = ( + resolver as unknown as { linkToTaskCalls: Function } + ).linkToTaskCalls.bind(resolver); + + describe('Phase 1: agent-id matching', () => { + it('sets linkType to agent-id when agentId matches subagent id', () => { + const subagentId = 'abc-123-def'; + const taskCallId = 'task-call-1'; + + const messages: ParsedMessage[] = [ + msg({ + type: 'assistant', + content: [ + { type: 'text', text: 'spawning' }, + { type: 'tool_use', id: taskCallId, name: 'Task', input: { prompt: 'explore' } }, + ], + toolCalls: [ + { + id: taskCallId, + name: 'Task', + input: { prompt: 'explore' }, + isTask: true, + taskDescription: 'explore', + taskSubagentType: 'Explore', + }, + ], + }), + // Tool result with agentId linking back to subagent + msg({ + type: 'user', + isMeta: true, + content: [{ type: 'tool_result', tool_use_id: taskCallId, content: 'done' }], + toolResults: [{ toolUseId: taskCallId, content: 'done' }], + sourceToolUseID: taskCallId, + toolUseResult: { agentId: subagentId }, + }), + ]; + + const sub = subagent({ id: subagentId }); + linkToTaskCalls([sub], messages); + + expect(sub.linkType).toBe('agent-id'); + expect(sub.parentTaskId).toBe(taskCallId); + }); + }); + + describe('Phase 2: team-member-id matching', () => { + it('sets linkType to team-member-id when teammate_id matches input.name', () => { + const taskCallId = 'task-call-2'; + const memberName = 'researcher'; + + const messages: ParsedMessage[] = [ + msg({ + type: 'assistant', + content: [ + { + type: 'tool_use', + id: taskCallId, + name: 'Task', + input: { prompt: 'research', team_name: 'my-team', name: memberName }, + }, + ], + toolCalls: [ + { + id: taskCallId, + name: 'Task', + input: { prompt: 'research', team_name: 'my-team', name: memberName }, + isTask: true, + taskDescription: 'research stuff', + taskSubagentType: 'general-purpose', + }, + ], + }), + ]; + + const sub = subagent({ + id: 'team-file-xyz', + messages: [ + msg({ + type: 'user', + content: `Hello`, + }), + ], + }); + + linkToTaskCalls([sub], messages); + + expect(sub.linkType).toBe('team-member-id'); + expect(sub.parentTaskId).toBe(taskCallId); + }); + + it('matches by teammate_id even when descriptions differ', () => { + const taskCallId = 'task-call-3'; + const memberName = 'coder'; + + const messages: ParsedMessage[] = [ + msg({ + type: 'assistant', + content: [ + { + type: 'tool_use', + id: taskCallId, + name: 'Task', + input: { prompt: 'write code', team_name: 'team-x', name: memberName }, + }, + ], + toolCalls: [ + { + id: taskCallId, + name: 'Task', + input: { prompt: 'write code', team_name: 'team-x', name: memberName }, + isTask: true, + taskDescription: 'COMPLETELY DIFFERENT description', + taskSubagentType: 'general-purpose', + }, + ], + }), + ]; + + const sub = subagent({ + id: 'team-file-abc', + messages: [ + msg({ + type: 'user', + content: `Content`, + }), + ], + }); + + linkToTaskCalls([sub], messages); + + expect(sub.linkType).toBe('team-member-id'); + expect(sub.parentTaskId).toBe(taskCallId); + }); + }); + + describe('unlinked subagents', () => { + it('sets linkType to unlinked when no match found', () => { + const messages: ParsedMessage[] = [ + msg({ + type: 'assistant', + content: [ + { + type: 'tool_use', + id: 'task-call-x', + name: 'Task', + input: { prompt: 'something' }, + }, + ], + toolCalls: [ + { + id: 'task-call-x', + name: 'Task', + input: { prompt: 'something' }, + isTask: true, + taskDescription: 'something', + taskSubagentType: 'Explore', + }, + ], + }), + ]; + + // Subagent with no matching agentId and no teammate_id + const sub = subagent({ + id: 'orphan-agent', + messages: [msg({ type: 'user', content: 'plain message without teammate tag' })], + }); + + linkToTaskCalls([sub], messages); + + expect(sub.linkType).toBe('unlinked'); + expect(sub.parentTaskId).toBeUndefined(); + }); + + it('does NOT use positional fallback (Phase 3 removed)', () => { + const messages: ParsedMessage[] = [ + msg({ + type: 'assistant', + content: [ + { + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'first task' }, + }, + { + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: { prompt: 'second task' }, + }, + ], + toolCalls: [ + { + id: 'task-1', + name: 'Task', + input: { prompt: 'first task' }, + isTask: true, + taskDescription: 'first', + taskSubagentType: 'Explore', + }, + { + id: 'task-2', + name: 'Task', + input: { prompt: 'second task' }, + isTask: true, + taskDescription: 'second', + taskSubagentType: 'Plan', + }, + ], + }), + ]; + + // Two subagents, neither has agentId match or teammate_id + const sub1 = subagent({ + id: 'sub-1', + startTime: new Date('2026-01-01T00:00:10Z'), + }); + const sub2 = subagent({ + id: 'sub-2', + startTime: new Date('2026-01-01T00:00:20Z'), + }); + + linkToTaskCalls([sub1, sub2], messages); + + // In the old code, sub1 would get task-1 and sub2 would get task-2 by position. + // Now both should be unlinked. + expect(sub1.linkType).toBe('unlinked'); + expect(sub2.linkType).toBe('unlinked'); + expect(sub1.parentTaskId).toBeUndefined(); + expect(sub2.parentTaskId).toBeUndefined(); + }); + }); + + describe('propagateTeamMetadata linkType', () => { + it('propagates parent-chain linkType from ancestor', () => { + // Access private method + const propagate = ( + resolver as unknown as { propagateTeamMetadata: Function } + ).propagateTeamMetadata.bind(resolver); + + const parentId = 'parent-last-uuid'; + + const parent = subagent({ + id: 'parent-agent', + parentTaskId: 'task-parent', + linkType: 'team-member-id', + messages: [ + msg({ + uuid: parentId, + type: 'assistant', + content: [{ type: 'text', text: 'done' }], + }), + ], + }); + parent.team = { teamName: 'my-team', memberName: 'worker', memberColor: '#ff0' }; + + const child = subagent({ + id: 'child-agent', + messages: [ + msg({ + type: 'user', + parentUuid: parentId, + content: 'continuation', + }), + ], + }); + + propagate([parent, child]); + + expect(child.team).toEqual(parent.team); + expect(child.linkType).toBe('parent-chain'); + expect(child.parentTaskId).toBe('task-parent'); + }); + }); +});