mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 13:38:43 +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,
|
||||
customAgentName: event.customAgentName,
|
||||
customAgentDisplayName: event.customAgentDisplayName,
|
||||
customAgentDescription: event.customAgentDescription,
|
||||
subagentError: event.error,
|
||||
subagentToolCallId: event.toolCallId,
|
||||
subagentModel: event.model,
|
||||
});
|
||||
return;
|
||||
case 'skill-invoked':
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type SessionUsageMap,
|
||||
type TurnEventLogMap,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { applySubagentEvent, pruneSubagentMap, type ActiveSubagentMap } from '@renderer/lib/subagentTracker';
|
||||
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
|
||||
import { WelcomePane } from '@renderer/components/WelcomePane';
|
||||
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||
@@ -101,6 +102,7 @@ export default function App() {
|
||||
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
|
||||
const [sessionUsage, setSessionUsage] = useState<SessionUsageMap>({});
|
||||
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
|
||||
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
||||
@@ -144,6 +146,12 @@ export default function App() {
|
||||
ws.sessions.map((session) => session.id),
|
||||
),
|
||||
);
|
||||
setActiveSubagents((current) =>
|
||||
pruneSubagentMap(
|
||||
current,
|
||||
ws.sessions.map((session) => session.id),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const offSessionEvent = api.onSessionEvent((event) => {
|
||||
@@ -151,6 +159,7 @@ export default function App() {
|
||||
setSessionActivities((current) => applySessionEventActivity(current, event));
|
||||
setSessionUsage((current) => applySessionUsageEvent(current, event));
|
||||
setTurnEventLogs((current) => applyTurnEventLog(current, event));
|
||||
setActiveSubagents((current) => applySubagentEvent(current, event));
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -205,6 +214,10 @@ export default function App() {
|
||||
() => (selectedSession ? sessionUsage[selectedSession.id] : undefined),
|
||||
[selectedSession, sessionUsage],
|
||||
);
|
||||
const subagentsForSession = useMemo(
|
||||
() => (selectedSession ? activeSubagents[selectedSession.id] : undefined),
|
||||
[selectedSession, activeSubagents],
|
||||
);
|
||||
const turnEventsForSession = useMemo(
|
||||
() => (selectedSession ? turnEventLogs[selectedSession.id] : undefined),
|
||||
[selectedSession, turnEventLogs],
|
||||
@@ -388,6 +401,7 @@ export default function App() {
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
session={selectedSession}
|
||||
sessionUsage={usageForSession}
|
||||
activeSubagents={subagentsForSession}
|
||||
terminalOpen={terminalOpen}
|
||||
terminalRunning={terminalRunning}
|
||||
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 { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
|
||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||
import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard';
|
||||
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment';
|
||||
import type { SessionUsageState } from '@renderer/lib/sessionActivity';
|
||||
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
||||
import {
|
||||
findModel,
|
||||
getSupportedReasoningEfforts,
|
||||
@@ -44,6 +46,7 @@ interface ChatPaneProps {
|
||||
mcpProbingServerIds?: string[];
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||
sessionUsage?: SessionUsageState;
|
||||
activeSubagents?: ReadonlyArray<ActiveSubagent>;
|
||||
terminalOpen?: boolean;
|
||||
terminalRunning?: boolean;
|
||||
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
||||
@@ -72,6 +75,7 @@ export function ChatPane({
|
||||
mcpProbingServerIds,
|
||||
runtimeTools,
|
||||
sessionUsage,
|
||||
activeSubagents,
|
||||
terminalOpen,
|
||||
terminalRunning,
|
||||
onSend,
|
||||
@@ -409,6 +413,11 @@ export function ChatPane({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{activeSubagents && activeSubagents.length > 0 && (
|
||||
<div className="px-6 py-1">
|
||||
<SubagentActivityList subagents={activeSubagents} />
|
||||
</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;
|
||||
customAgentName?: string;
|
||||
customAgentDisplayName?: string;
|
||||
customAgentDescription?: string;
|
||||
subagentError?: string;
|
||||
subagentToolCallId?: string;
|
||||
subagentModel?: string;
|
||||
|
||||
// Skill invoked fields
|
||||
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