mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 21:57:13 +02:00
feat: show sub-agent activity cards in chat stream
Display compact status cards in the chat transcript showing each sub-agent's name, current activity (Thinking, Using grep, etc.), and elapsed time. Cards appear while sub-agents are running and clear when the session goes idle. - Extend SessionEventRecord with description, error, toolCallId, and model fields for subagent events - Forward additional subagent fields in handleTurnScopedEvent - Add subagentTracker reducer to derive active subagent state from session events, including agent-activity correlation - Create SubagentActivityCard component with spinner, status icon, agent name, activity label, and live elapsed timer - Wire activeSubagents state in App.tsx and pass to ChatPane - Add 16 unit tests for the subagent tracker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1827,6 +1827,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
subagentEventKind: event.eventKind,
|
subagentEventKind: event.eventKind,
|
||||||
customAgentName: event.customAgentName,
|
customAgentName: event.customAgentName,
|
||||||
customAgentDisplayName: event.customAgentDisplayName,
|
customAgentDisplayName: event.customAgentDisplayName,
|
||||||
|
customAgentDescription: event.customAgentDescription,
|
||||||
|
subagentError: event.error,
|
||||||
|
subagentToolCallId: event.toolCallId,
|
||||||
|
subagentModel: event.model,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
case 'skill-invoked':
|
case 'skill-invoked':
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type SessionUsageMap,
|
type SessionUsageMap,
|
||||||
type TurnEventLogMap,
|
type TurnEventLogMap,
|
||||||
} from '@renderer/lib/sessionActivity';
|
} from '@renderer/lib/sessionActivity';
|
||||||
|
import { applySubagentEvent, pruneSubagentMap, type ActiveSubagentMap } from '@renderer/lib/subagentTracker';
|
||||||
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
|
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
|
||||||
import { WelcomePane } from '@renderer/components/WelcomePane';
|
import { WelcomePane } from '@renderer/components/WelcomePane';
|
||||||
import { getElectronApi } from '@renderer/lib/electronApi';
|
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||||
@@ -101,6 +102,7 @@ export default function App() {
|
|||||||
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
|
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
|
||||||
const [sessionUsage, setSessionUsage] = useState<SessionUsageMap>({});
|
const [sessionUsage, setSessionUsage] = useState<SessionUsageMap>({});
|
||||||
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
|
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
|
||||||
|
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
||||||
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
||||||
@@ -144,6 +146,12 @@ export default function App() {
|
|||||||
ws.sessions.map((session) => session.id),
|
ws.sessions.map((session) => session.id),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
setActiveSubagents((current) =>
|
||||||
|
pruneSubagentMap(
|
||||||
|
current,
|
||||||
|
ws.sessions.map((session) => session.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const offSessionEvent = api.onSessionEvent((event) => {
|
const offSessionEvent = api.onSessionEvent((event) => {
|
||||||
@@ -151,6 +159,7 @@ export default function App() {
|
|||||||
setSessionActivities((current) => applySessionEventActivity(current, event));
|
setSessionActivities((current) => applySessionEventActivity(current, event));
|
||||||
setSessionUsage((current) => applySessionUsageEvent(current, event));
|
setSessionUsage((current) => applySessionUsageEvent(current, event));
|
||||||
setTurnEventLogs((current) => applyTurnEventLog(current, event));
|
setTurnEventLogs((current) => applyTurnEventLog(current, event));
|
||||||
|
setActiveSubagents((current) => applySubagentEvent(current, event));
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -205,6 +214,10 @@ export default function App() {
|
|||||||
() => (selectedSession ? sessionUsage[selectedSession.id] : undefined),
|
() => (selectedSession ? sessionUsage[selectedSession.id] : undefined),
|
||||||
[selectedSession, sessionUsage],
|
[selectedSession, sessionUsage],
|
||||||
);
|
);
|
||||||
|
const subagentsForSession = useMemo(
|
||||||
|
() => (selectedSession ? activeSubagents[selectedSession.id] : undefined),
|
||||||
|
[selectedSession, activeSubagents],
|
||||||
|
);
|
||||||
const turnEventsForSession = useMemo(
|
const turnEventsForSession = useMemo(
|
||||||
() => (selectedSession ? turnEventLogs[selectedSession.id] : undefined),
|
() => (selectedSession ? turnEventLogs[selectedSession.id] : undefined),
|
||||||
[selectedSession, turnEventLogs],
|
[selectedSession, turnEventLogs],
|
||||||
@@ -388,6 +401,7 @@ export default function App() {
|
|||||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||||
session={selectedSession}
|
session={selectedSession}
|
||||||
sessionUsage={usageForSession}
|
sessionUsage={usageForSession}
|
||||||
|
activeSubagents={subagentsForSession}
|
||||||
terminalOpen={terminalOpen}
|
terminalOpen={terminalOpen}
|
||||||
terminalRunning={terminalRunning}
|
terminalRunning={terminalRunning}
|
||||||
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
|||||||
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
||||||
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
|
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
|
||||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||||
|
import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard';
|
||||||
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
||||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||||
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
||||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||||
import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment';
|
import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment';
|
||||||
import type { SessionUsageState } from '@renderer/lib/sessionActivity';
|
import type { SessionUsageState } from '@renderer/lib/sessionActivity';
|
||||||
|
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
||||||
import {
|
import {
|
||||||
findModel,
|
findModel,
|
||||||
getSupportedReasoningEfforts,
|
getSupportedReasoningEfforts,
|
||||||
@@ -44,6 +46,7 @@ interface ChatPaneProps {
|
|||||||
mcpProbingServerIds?: string[];
|
mcpProbingServerIds?: string[];
|
||||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||||
sessionUsage?: SessionUsageState;
|
sessionUsage?: SessionUsageState;
|
||||||
|
activeSubagents?: ReadonlyArray<ActiveSubagent>;
|
||||||
terminalOpen?: boolean;
|
terminalOpen?: boolean;
|
||||||
terminalRunning?: boolean;
|
terminalRunning?: boolean;
|
||||||
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
||||||
@@ -72,6 +75,7 @@ export function ChatPane({
|
|||||||
mcpProbingServerIds,
|
mcpProbingServerIds,
|
||||||
runtimeTools,
|
runtimeTools,
|
||||||
sessionUsage,
|
sessionUsage,
|
||||||
|
activeSubagents,
|
||||||
terminalOpen,
|
terminalOpen,
|
||||||
terminalRunning,
|
terminalRunning,
|
||||||
onSend,
|
onSend,
|
||||||
@@ -409,6 +413,11 @@ export function ChatPane({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{activeSubagents && activeSubagents.length > 0 && (
|
||||||
|
<div className="px-6 py-1">
|
||||||
|
<SubagentActivityList subagents={activeSubagents} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
||||||
|
|
||||||
|
function formatElapsed(startedAt: string): string {
|
||||||
|
const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainder = seconds % 60;
|
||||||
|
return `${minutes}m ${remainder}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: ActiveSubagent['status'] }) {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return <Loader2 className="size-3.5 animate-spin text-sky-400" aria-label="Running" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle2 className="size-3.5 text-emerald-400" aria-label="Completed" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="size-3.5 text-red-400" aria-label="Failed" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ElapsedTimer({ startedAt }: { startedAt: string }) {
|
||||||
|
const [elapsed, setElapsed] = useState(() => formatElapsed(startedAt));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setElapsed(formatElapsed(startedAt)), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [startedAt]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-zinc-600">{elapsed}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubagentActivityCardProps {
|
||||||
|
subagent: ActiveSubagent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
|
||||||
|
const borderClass =
|
||||||
|
subagent.status === 'running'
|
||||||
|
? 'border-sky-500/20'
|
||||||
|
: subagent.status === 'failed'
|
||||||
|
? 'border-red-500/20'
|
||||||
|
: 'border-emerald-500/20';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-lg border bg-zinc-900/60 px-3 py-1.5 ${borderClass}`}
|
||||||
|
role="status"
|
||||||
|
aria-label={`Sub-agent ${subagent.name}: ${subagent.activityLabel}`}
|
||||||
|
>
|
||||||
|
<StatusIcon status={subagent.status} />
|
||||||
|
<Bot className="size-3 text-zinc-500" />
|
||||||
|
<span className="text-[11px] font-medium text-zinc-300">{subagent.name}</span>
|
||||||
|
<span className="text-[10px] text-zinc-600">—</span>
|
||||||
|
<span className="text-[10px] text-zinc-500">{subagent.activityLabel}</span>
|
||||||
|
{subagent.status === 'running' && <ElapsedTimer startedAt={subagent.startedAt} />}
|
||||||
|
{subagent.error && (
|
||||||
|
<span className="truncate text-[10px] text-red-400" title={subagent.error}>
|
||||||
|
{subagent.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubagentActivityListProps {
|
||||||
|
subagents: ReadonlyArray<ActiveSubagent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubagentActivityList({ subagents }: SubagentActivityListProps) {
|
||||||
|
if (subagents.length === 0) return null;
|
||||||
|
|
||||||
|
// Only show running subagents in the chat stream
|
||||||
|
const visible = subagents.filter((s) => s.status === 'running');
|
||||||
|
if (visible.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 py-1" aria-label="Active sub-agents">
|
||||||
|
{visible.map((subagent) => (
|
||||||
|
<SubagentActivityCard key={subagent.toolCallId} subagent={subagent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { SessionEventRecord, SubagentEventKind } from '@shared/domain/event';
|
||||||
|
|
||||||
|
export interface ActiveSubagent {
|
||||||
|
toolCallId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
model?: string;
|
||||||
|
activityLabel: string;
|
||||||
|
startedAt: string;
|
||||||
|
status: 'running' | 'completed' | 'failed';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActiveSubagentMap = Record<string, ReadonlyArray<ActiveSubagent> | undefined>;
|
||||||
|
|
||||||
|
function subagentKey(event: SessionEventRecord): string {
|
||||||
|
return event.subagentToolCallId ?? event.customAgentName ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentDisplayName(event: SessionEventRecord): string {
|
||||||
|
return event.customAgentDisplayName ?? event.customAgentName ?? 'Sub-agent';
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityLabelForKind(kind: SubagentEventKind | undefined): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'started':
|
||||||
|
return 'Starting…';
|
||||||
|
case 'selected':
|
||||||
|
return 'Selected';
|
||||||
|
case 'completed':
|
||||||
|
return 'Completed';
|
||||||
|
case 'failed':
|
||||||
|
return 'Failed';
|
||||||
|
case 'deselected':
|
||||||
|
return 'Deselected';
|
||||||
|
default:
|
||||||
|
return 'Working…';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySubagentEvent(
|
||||||
|
current: ActiveSubagentMap,
|
||||||
|
event: SessionEventRecord,
|
||||||
|
): ActiveSubagentMap {
|
||||||
|
if (event.kind !== 'subagent') {
|
||||||
|
// Clear all subagents when session goes idle
|
||||||
|
if (event.kind === 'status' && event.status === 'idle') {
|
||||||
|
if (!current[event.sessionId]?.length) return current;
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[event.sessionId];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update running subagent activity from agent-activity events
|
||||||
|
if (event.kind === 'agent-activity' && event.agentName) {
|
||||||
|
const existing = current[event.sessionId];
|
||||||
|
if (!existing?.length) return current;
|
||||||
|
|
||||||
|
const agentName = event.agentName.trim();
|
||||||
|
const hasMatch = existing.some(
|
||||||
|
(s) => s.status === 'running' && s.name === agentName,
|
||||||
|
);
|
||||||
|
if (!hasMatch) return current;
|
||||||
|
|
||||||
|
let label: string;
|
||||||
|
switch (event.activityType) {
|
||||||
|
case 'tool-calling':
|
||||||
|
label = `Using ${event.toolName?.trim() || 'a tool'}…`;
|
||||||
|
break;
|
||||||
|
case 'thinking':
|
||||||
|
label = 'Thinking…';
|
||||||
|
break;
|
||||||
|
case 'handoff':
|
||||||
|
label = 'Handling handoff…';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[event.sessionId]: existing.map((s) =>
|
||||||
|
s.status === 'running' && s.name === agentName
|
||||||
|
? { ...s, activityLabel: label }
|
||||||
|
: s,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = subagentKey(event);
|
||||||
|
const existing = current[event.sessionId] ?? [];
|
||||||
|
|
||||||
|
switch (event.subagentEventKind) {
|
||||||
|
case 'started': {
|
||||||
|
const entry: ActiveSubagent = {
|
||||||
|
toolCallId: key,
|
||||||
|
name: subagentDisplayName(event),
|
||||||
|
description: event.customAgentDescription,
|
||||||
|
model: event.subagentModel,
|
||||||
|
activityLabel: activityLabelForKind('started'),
|
||||||
|
startedAt: event.occurredAt,
|
||||||
|
status: 'running',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[event.sessionId]: [...existing.filter((s) => s.toolCallId !== key), entry],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'completed': {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[event.sessionId]: existing.map((s) =>
|
||||||
|
s.toolCallId === key
|
||||||
|
? { ...s, status: 'completed' as const, activityLabel: activityLabelForKind('completed') }
|
||||||
|
: s,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'failed': {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[event.sessionId]: existing.map((s) =>
|
||||||
|
s.toolCallId === key
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
status: 'failed' as const,
|
||||||
|
activityLabel: activityLabelForKind('failed'),
|
||||||
|
error: event.subagentError,
|
||||||
|
}
|
||||||
|
: s,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneSubagentMap(
|
||||||
|
current: ActiveSubagentMap,
|
||||||
|
sessionIds: Iterable<string>,
|
||||||
|
): ActiveSubagentMap {
|
||||||
|
const allowed = new Set(sessionIds);
|
||||||
|
const next: ActiveSubagentMap = {};
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const [sessionId, subagents] of Object.entries(current)) {
|
||||||
|
if (!allowed.has(sessionId)) {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next[sessionId] = subagents;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
|
||||||
|
}
|
||||||
@@ -40,6 +40,10 @@ export interface SessionEventRecord {
|
|||||||
subagentEventKind?: SubagentEventKind;
|
subagentEventKind?: SubagentEventKind;
|
||||||
customAgentName?: string;
|
customAgentName?: string;
|
||||||
customAgentDisplayName?: string;
|
customAgentDisplayName?: string;
|
||||||
|
customAgentDescription?: string;
|
||||||
|
subagentError?: string;
|
||||||
|
subagentToolCallId?: string;
|
||||||
|
subagentModel?: string;
|
||||||
|
|
||||||
// Skill invoked fields
|
// Skill invoked fields
|
||||||
skillName?: string;
|
skillName?: string;
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applySubagentEvent,
|
||||||
|
pruneSubagentMap,
|
||||||
|
type ActiveSubagentMap,
|
||||||
|
} from '@renderer/lib/subagentTracker';
|
||||||
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
|
|
||||||
|
function subagentStarted(overrides: Partial<SessionEventRecord> = {}): SessionEventRecord {
|
||||||
|
return {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'subagent',
|
||||||
|
occurredAt: '2026-03-28T10:00:00.000Z',
|
||||||
|
subagentEventKind: 'started',
|
||||||
|
customAgentName: 'explore',
|
||||||
|
customAgentDisplayName: 'Explore Agent',
|
||||||
|
customAgentDescription: 'Explores the codebase',
|
||||||
|
subagentToolCallId: 'tc-1',
|
||||||
|
subagentModel: 'claude-haiku-4.5',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentCompleted(overrides: Partial<SessionEventRecord> = {}): SessionEventRecord {
|
||||||
|
return {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'subagent',
|
||||||
|
occurredAt: '2026-03-28T10:00:05.000Z',
|
||||||
|
subagentEventKind: 'completed',
|
||||||
|
customAgentName: 'explore',
|
||||||
|
customAgentDisplayName: 'Explore Agent',
|
||||||
|
subagentToolCallId: 'tc-1',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentFailed(overrides: Partial<SessionEventRecord> = {}): SessionEventRecord {
|
||||||
|
return {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'subagent',
|
||||||
|
occurredAt: '2026-03-28T10:00:05.000Z',
|
||||||
|
subagentEventKind: 'failed',
|
||||||
|
customAgentName: 'explore',
|
||||||
|
customAgentDisplayName: 'Explore Agent',
|
||||||
|
subagentToolCallId: 'tc-1',
|
||||||
|
subagentError: 'Something went wrong',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentActivity(overrides: Partial<SessionEventRecord> = {}): SessionEventRecord {
|
||||||
|
return {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'agent-activity',
|
||||||
|
occurredAt: '2026-03-28T10:00:01.000Z',
|
||||||
|
activityType: 'thinking',
|
||||||
|
agentName: 'Explore Agent',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('subagent tracker', () => {
|
||||||
|
describe('applySubagentEvent', () => {
|
||||||
|
test('adds a running subagent on started event', () => {
|
||||||
|
const result = applySubagentEvent({}, subagentStarted());
|
||||||
|
|
||||||
|
const subagents = result['session-1'];
|
||||||
|
expect(subagents).toHaveLength(1);
|
||||||
|
expect(subagents![0]).toEqual(expect.objectContaining({
|
||||||
|
toolCallId: 'tc-1',
|
||||||
|
name: 'Explore Agent',
|
||||||
|
description: 'Explores the codebase',
|
||||||
|
model: 'claude-haiku-4.5',
|
||||||
|
status: 'running',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks subagent completed', () => {
|
||||||
|
let state: ActiveSubagentMap = {};
|
||||||
|
state = applySubagentEvent(state, subagentStarted());
|
||||||
|
state = applySubagentEvent(state, subagentCompleted());
|
||||||
|
|
||||||
|
const subagents = state['session-1'];
|
||||||
|
expect(subagents).toHaveLength(1);
|
||||||
|
expect(subagents![0]!.status).toBe('completed');
|
||||||
|
expect(subagents![0]!.activityLabel).toBe('Completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks subagent failed with error', () => {
|
||||||
|
let state: ActiveSubagentMap = {};
|
||||||
|
state = applySubagentEvent(state, subagentStarted());
|
||||||
|
state = applySubagentEvent(state, subagentFailed());
|
||||||
|
|
||||||
|
const subagents = state['session-1'];
|
||||||
|
expect(subagents).toHaveLength(1);
|
||||||
|
expect(subagents![0]!.status).toBe('failed');
|
||||||
|
expect(subagents![0]!.error).toBe('Something went wrong');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracks multiple subagents concurrently', () => {
|
||||||
|
let state: ActiveSubagentMap = {};
|
||||||
|
state = applySubagentEvent(state, subagentStarted({ subagentToolCallId: 'tc-1', customAgentDisplayName: 'Agent A' }));
|
||||||
|
state = applySubagentEvent(state, subagentStarted({ subagentToolCallId: 'tc-2', customAgentDisplayName: 'Agent B' }));
|
||||||
|
|
||||||
|
const subagents = state['session-1'];
|
||||||
|
expect(subagents).toHaveLength(2);
|
||||||
|
expect(subagents![0]!.name).toBe('Agent A');
|
||||||
|
expect(subagents![1]!.name).toBe('Agent B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completes one subagent while another keeps running', () => {
|
||||||
|
let state: ActiveSubagentMap = {};
|
||||||
|
state = applySubagentEvent(state, subagentStarted({ subagentToolCallId: 'tc-1', customAgentDisplayName: 'Agent A' }));
|
||||||
|
state = applySubagentEvent(state, subagentStarted({ subagentToolCallId: 'tc-2', customAgentDisplayName: 'Agent B' }));
|
||||||
|
state = applySubagentEvent(state, subagentCompleted({ subagentToolCallId: 'tc-1' }));
|
||||||
|
|
||||||
|
const subagents = state['session-1'];
|
||||||
|
expect(subagents).toHaveLength(2);
|
||||||
|
expect(subagents![0]!.status).toBe('completed');
|
||||||
|
expect(subagents![1]!.status).toBe('running');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears subagents when session goes idle', () => {
|
||||||
|
let state: ActiveSubagentMap = {};
|
||||||
|
state = applySubagentEvent(state, subagentStarted());
|
||||||
|
state = applySubagentEvent(state, {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'status',
|
||||||
|
occurredAt: '2026-03-28T10:00:10.000Z',
|
||||||
|
status: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state['session-1']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns same reference when idle event has no subagents', () => {
|
||||||
|
const state: ActiveSubagentMap = {};
|
||||||
|
const result = applySubagentEvent(state, {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'status',
|
||||||
|
occurredAt: '2026-03-28T10:00:10.000Z',
|
||||||
|
status: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores unrelated event kinds', () => {
|
||||||
|
const state: ActiveSubagentMap = {};
|
||||||
|
const result = applySubagentEvent(state, {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'message-delta',
|
||||||
|
occurredAt: '2026-03-28T10:00:00.000Z',
|
||||||
|
contentDelta: 'hello',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates activity label from agent-activity event', () => {
|
||||||
|
let state: ActiveSubagentMap = {};
|
||||||
|
state = applySubagentEvent(state, subagentStarted());
|
||||||
|
state = applySubagentEvent(state, agentActivity({
|
||||||
|
activityType: 'tool-calling',
|
||||||
|
toolName: 'grep',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(state['session-1']![0]!.activityLabel).toBe('Using grep…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates activity label to thinking', () => {
|
||||||
|
let state: ActiveSubagentMap = {};
|
||||||
|
state = applySubagentEvent(state, subagentStarted());
|
||||||
|
state = applySubagentEvent(state, agentActivity({
|
||||||
|
activityType: 'thinking',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(state['session-1']![0]!.activityLabel).toBe('Thinking…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores agent-activity for non-matching agent name', () => {
|
||||||
|
let state: ActiveSubagentMap = {};
|
||||||
|
state = applySubagentEvent(state, subagentStarted());
|
||||||
|
const before = state;
|
||||||
|
state = applySubagentEvent(state, agentActivity({
|
||||||
|
agentName: 'Completely Different Agent',
|
||||||
|
activityType: 'thinking',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(state).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update completed subagent from agent-activity', () => {
|
||||||
|
let state: ActiveSubagentMap = {};
|
||||||
|
state = applySubagentEvent(state, subagentStarted());
|
||||||
|
state = applySubagentEvent(state, subagentCompleted());
|
||||||
|
const before = state;
|
||||||
|
state = applySubagentEvent(state, agentActivity({
|
||||||
|
activityType: 'thinking',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(state).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses customAgentName when displayName is absent', () => {
|
||||||
|
const result = applySubagentEvent({}, subagentStarted({
|
||||||
|
customAgentDisplayName: undefined,
|
||||||
|
customAgentName: 'task-agent',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result['session-1']![0]!.name).toBe('task-agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses toolCallId as key, falls back to customAgentName', () => {
|
||||||
|
const result = applySubagentEvent({}, subagentStarted({
|
||||||
|
subagentToolCallId: undefined,
|
||||||
|
customAgentName: 'fallback-key',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result['session-1']![0]!.toolCallId).toBe('fallback-key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pruneSubagentMap', () => {
|
||||||
|
test('removes entries for deleted sessions', () => {
|
||||||
|
const state: ActiveSubagentMap = {
|
||||||
|
'session-1': [{ toolCallId: 'tc-1', name: 'A', activityLabel: 'Working…', startedAt: '', status: 'running' }],
|
||||||
|
'session-2': [{ toolCallId: 'tc-2', name: 'B', activityLabel: 'Working…', startedAt: '', status: 'running' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = pruneSubagentMap(state, ['session-1']);
|
||||||
|
expect(result['session-1']).toBeDefined();
|
||||||
|
expect(result['session-2']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns same reference when nothing is pruned', () => {
|
||||||
|
const state: ActiveSubagentMap = {
|
||||||
|
'session-1': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = pruneSubagentMap(state, ['session-1']);
|
||||||
|
expect(result).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user