mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58: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:
@@ -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