feat: enhance notification system with task status change support

- Added new configuration options for task status change notifications, including toggles for enabling notifications, solo mode restrictions, and customizable target statuses.
- Updated validation logic to accommodate new notification settings, ensuring proper handling of boolean and array types.
- Enhanced UI components to allow users to manage status change notification preferences effectively.
- Implemented backend logic to detect and trigger notifications based on task status changes, improving user awareness of task progress.
This commit is contained in:
iliya 2026-03-04 14:56:07 +02:00
parent dfc2a43a91
commit 01660f0791
26 changed files with 986 additions and 130 deletions

View file

@ -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[]` };

View file

@ -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<string>();
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());
}
/**

View file

@ -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<string>();
// 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);
}
}
}

View file

@ -153,16 +153,16 @@ export class SubagentResolver {
}
/**
* Extract the summary attribute from the first <teammate-message> 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 <teammate-message> 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 = /<teammate-message[^>]*\bsummary="([^"]+)"/.exec(text);
const match = /<teammate-message\s+[^>]*\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 <teammate-message> tag.
// Instead, match by comparing Task call input.name to the teammate_id XML attribute
// in the subagent file's first <teammate-message> 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<string, string>();
// Pre-extract teammate_ids from unmatched subagent files
const subagentTeammateIds = new Map<string, string>();
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);
}
}
}

View file

@ -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: {

View file

@ -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<TaskChangeSetV2> {
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<string, unknown>;
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<string | undefined> {
try {

View file

@ -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;

View file

@ -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;
/**

View file

@ -151,6 +151,7 @@ export const SettingsView = (): React.JSX.Element | null => {
onAddTrigger={handlers.handleAddTrigger}
onUpdateTrigger={handlers.handleUpdateTrigger}
onRemoveTrigger={handlers.handleRemoveTrigger}
onStatusChangeStatusesUpdate={handlers.handleStatusChangeStatusesUpdate}
/>
)}

View file

@ -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: {

View file

@ -35,6 +35,7 @@ interface SettingsHandlers {
// Notification handlers
handleNotificationToggle: (key: keyof AppConfig['notifications'], value: boolean) => void;
handleStatusChangeStatusesUpdate: (statuses: string[]) => void;
handleSnooze: (minutes: number) => Promise<void>;
handleClearSnooze: () => Promise<void>;
handleAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise<void>;
@ -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,

View file

@ -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<void>;
readonly onClearSnooze: () => Promise<void>;
readonly onAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise<void>;
@ -67,6 +71,7 @@ export const NotificationsSection = ({
onAddTrigger,
onUpdateTrigger,
onRemoveTrigger,
onStatusChangeStatusesUpdate,
}: NotificationsSectionProps): React.JSX.Element => {
return (
<div>
@ -166,6 +171,40 @@ export const NotificationsSection = ({
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
<SettingRow
label="Task status change notifications"
description="Show native OS notifications when a task's status changes"
>
<SettingsToggle
enabled={safeConfig.notifications.notifyOnStatusChange}
onChange={(v) => onNotificationToggle('notifyOnStatusChange', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
{safeConfig.notifications.notifyOnStatusChange && safeConfig.notifications.enabled ? (
<>
<SettingRow
label="Only in Solo mode"
description="Only notify when the team has no teammates (lead works alone)"
>
<SettingsToggle
enabled={safeConfig.notifications.statusChangeOnlySolo}
onChange={(v) => onNotificationToggle('statusChangeOnlySolo', v)}
disabled={saving}
/>
</SettingRow>
<SettingRow
label="Notify on these statuses"
description="Select which status transitions trigger notifications"
>
<StatusCheckboxGroup
selected={safeConfig.notifications.statusChangeStatuses}
onChange={onStatusChangeStatusesUpdate}
disabled={saving}
/>
</SettingRow>
</>
) : null}
<SettingRow
label="Snooze notifications"
description={
@ -230,3 +269,46 @@ export const NotificationsSection = ({
</div>
);
};
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;
}) => (
<div className="flex flex-wrap gap-2">
{STATUS_OPTIONS.map((opt) => {
const checked = selected.includes(opt.value);
return (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => {
const next = checked
? selected.filter((s) => s !== opt.value)
: [...selected, opt.value];
onChange(next);
}}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
checked
? 'bg-indigo-500/20 text-indigo-400'
: 'bg-[var(--color-surface-raised)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
} ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
>
{opt.label}
</button>
);
})}
</div>
);

View file

@ -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 = ({
</div>
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
</div>
{isOpen && <div className="mt-1.5 min-w-0 overflow-x-hidden pb-2 pl-2.5">{children}</div>}
{isOpen && (
<div className={`mt-1.5 min-w-0 overflow-x-hidden pb-2 ${contentClassName ?? ''}`}>
{children}
</div>
)}
</section>
);
};

View file

@ -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 (
<div className="mb-3 flex items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
<p className="flex-1 text-xs text-amber-200">Team offline</p>
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 border-amber-500/40 px-2 text-xs text-amber-300 hover:bg-amber-500/10 hover:text-amber-200"
onClick={() => setDismissed(true)}
>
<X size={12} />
</Button>
</div>
);
}
if (isReady) {
return (
<div className="mb-3">

View file

@ -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 = ({
<article
className="group overflow-hidden rounded-md"
style={{
backgroundColor: rateLimited || isApiError ? 'var(--tool-result-error-bg)' : CARD_BG,
backgroundColor:
rateLimited || isApiError
? 'var(--tool-result-error-bg)'
: zebraShade
? CARD_BG_ZEBRA
: CARD_BG,
border:
rateLimited || isApiError
? '1px solid var(--tool-result-error-border)'

View file

@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { parseStructuredAgentMessage } from '@renderer/utils/agentMessageFormatting';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ActivityItem } from './ActivityItem';
import { ActivityItem, isNoiseMessage } from './ActivityItem';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
@ -35,6 +36,7 @@ const MessageRowWithObserver = ({
recipientColor,
isUnread,
isNew,
zebraShade,
onMemberNameClick,
onCreateTask,
onReply,
@ -48,6 +50,7 @@ const MessageRowWithObserver = ({
recipientColor?: string;
isUnread?: boolean;
isNew?: boolean;
zebraShade?: boolean;
onMemberNameClick?: (name: string) => 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<number>();
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}

View file

@ -154,7 +154,7 @@ export const TaskCommentsSection = ({
</div>
) : null}
{visibleComments.map((comment) => (
{visibleComments.map((comment, index) => (
<div
key={comment.id}
className={[
@ -165,6 +165,11 @@ export const TaskCommentsSection = ({
? 'border border-blue-500/20 bg-blue-500/5'
: '',
].join(' ')}
style={
!comment.type && index % 2 === 1
? { backgroundColor: 'var(--card-bg-zebra)' }
: undefined
}
>
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />

View file

@ -453,7 +453,12 @@ export const TaskDetailDialog = ({
) : null}
{/* Description */}
<CollapsibleTeamSection title="Description" icon={<AlignLeft size={14} />} defaultOpen>
<CollapsibleTeamSection
title="Description"
icon={<AlignLeft size={14} />}
contentClassName="pl-2.5"
defaultOpen
>
{editingDescription ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
@ -563,6 +568,7 @@ export const TaskDetailDialog = ({
title="Changes"
icon={<FileDiff size={14} />}
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
contentClassName="pl-2.5"
defaultOpen={taskKnownHasChanges}
>
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
@ -616,6 +622,7 @@ export const TaskDetailDialog = ({
</span>
) : null
}
contentClassName="pl-2.5"
defaultOpen
>
<div className="min-w-0 overflow-hidden">
@ -766,6 +773,7 @@ export const TaskDetailDialog = ({
title="Status History"
icon={<History size={14} />}
badge={currentTask.statusHistory.length}
contentClassName="pl-2.5"
defaultOpen={false}
>
<StatusHistoryTimeline history={currentTask.statusHistory} />
@ -781,6 +789,7 @@ export const TaskDetailDialog = ({
? (currentTask.comments?.length ?? 0)
: undefined
}
contentClassName="pl-2.5"
defaultOpen
>
<TaskCommentInput

View file

@ -169,6 +169,9 @@ export const WORKTREE_BADGE_TEXT = '#a1a1aa';
/** Card background */
export const CARD_BG = 'var(--card-bg)';
/** Card background — zebra-striped alternate row */
export const CARD_BG_ZEBRA = 'var(--card-bg-zebra)';
/** Card border color */
const CARD_BORDER = 'var(--card-border)';

View file

@ -173,6 +173,7 @@
/* Subagent/Card styling */
--card-bg: #0f1018;
--card-bg-zebra: #14151f;
--card-border: #1c1d26;
--card-header-bg: #151620;
--card-header-hover: #1a1b26;
@ -380,6 +381,7 @@
/* Subagent/Card styling - Warm light mode */
--card-bg: #f9f9f7;
--card-bg-zebra: #f2f1ee;
--card-border: #d5d3cf;
--card-header-bg: #f0efed;
--card-header-hover: #eae9e6;

View file

@ -62,6 +62,7 @@ async function pollProvisioningStatus(
}
import type { AppState } from '../types';
import type { AppConfig } from '@renderer/types/data';
import type {
AddMemberRequest,
CreateTaskRequest,
@ -87,6 +88,7 @@ import type { StateCreator } from 'zustand';
// (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only
// handles clarification-specific logic (e.g., marking tasks as needing user input).
const notifiedClarificationTaskKeys = new Set<string>();
const notifiedStatusChangeKeys = new Set<string>();
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<string, TeamSummary>
): 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<string, string> = {
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<AppState, [], [], TeamSlice> = (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}`);
}
}

View file

@ -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[];
};

View file

@ -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 });

View file

@ -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);
}
});
});
});

View file

@ -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<Process> & { 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);
});
});

View file

@ -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>): 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<Process> & { 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: `<teammate-message teammate_id="${memberName}" color="#ff0000" summary="do research">Hello</teammate-message>`,
}),
],
});
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: `<teammate-message teammate_id="${memberName}" color="#00ff00" summary="some other summary">Content</teammate-message>`,
}),
],
});
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');
});
});
});