Skip to content

Commit 4150fea

Browse files
committed
feat: add Codex subagent management bar
1 parent bcad3d3 commit 4150fea

9 files changed

Lines changed: 629 additions & 105 deletions
101 KB
Loading
56.5 KB
Loading
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
CodexSubagentToolUseMessage,
4+
ToolResultMessage,
5+
type ChatMessage,
6+
} from '$shared/chat-types';
7+
import { buildSubagentManagementModel } from '../subagent-management';
8+
9+
const TS = '2024-01-01T00:00:00Z';
10+
11+
describe('buildSubagentManagementModel', () => {
12+
it('returns only the root entry when no subagents are present', () => {
13+
const model = buildSubagentManagementModel([], {
14+
rootTitle: 'Main chat',
15+
rootModel: 'gpt-5',
16+
rootStatus: 'idle',
17+
});
18+
19+
expect(model.entries).toHaveLength(1);
20+
expect(model.subagents).toHaveLength(0);
21+
expect(model.entries[0]).toMatchObject({
22+
kind: 'root',
23+
name: 'Main chat',
24+
model: 'gpt-5',
25+
statusLabel: 'Idle',
26+
});
27+
});
28+
29+
it('creates a subagent entry from a spawn tool event', () => {
30+
const messages: ChatMessage[] = [
31+
new CodexSubagentToolUseMessage(TS, 'tool-subagent-1', 'spawn_agent', {
32+
taskName: 'review-auth',
33+
message: 'Review auth boundaries',
34+
model: 'gpt-5.5',
35+
}),
36+
];
37+
38+
const model = buildSubagentManagementModel(messages, { rootStatus: 'running' });
39+
40+
expect(model.entries).toHaveLength(2);
41+
expect(model.subagents[0]).toMatchObject({
42+
kind: 'subagent',
43+
name: 'review-auth',
44+
model: 'gpt-5.5',
45+
message: 'Review auth boundaries',
46+
status: 'running',
47+
statusLabel: 'Running',
48+
lastActionLabel: 'Spawned',
49+
anchorId: 'tool-input-tool-subagent-1',
50+
});
51+
});
52+
53+
it('merges follow-up events into the spawned subagent by target alias', () => {
54+
const messages: ChatMessage[] = [
55+
new CodexSubagentToolUseMessage(TS, 'tool-subagent-1', 'spawn_agent', {
56+
taskName: 'review-auth',
57+
message: 'Review auth boundaries',
58+
model: 'gpt-5.5',
59+
}),
60+
new CodexSubagentToolUseMessage(TS, 'tool-subagent-2', 'followup_task', {
61+
target: '/root/review-auth',
62+
message: 'Check websocket auth too',
63+
}),
64+
];
65+
66+
const model = buildSubagentManagementModel(messages);
67+
68+
expect(model.subagents).toHaveLength(1);
69+
expect(model.subagents[0]).toMatchObject({
70+
name: 'review-auth',
71+
path: '/root/review-auth',
72+
message: 'Check websocket auth too',
73+
lastActionLabel: 'Follow-up',
74+
anchorId: 'tool-input-tool-subagent-1',
75+
});
76+
});
77+
78+
it('marks errored and lifecycle events with stable statuses', () => {
79+
const messages: ChatMessage[] = [
80+
new CodexSubagentToolUseMessage(TS, 'tool-subagent-1', 'spawn_agent', {
81+
taskName: 'review-auth',
82+
}),
83+
new CodexSubagentToolUseMessage(TS, 'tool-subagent-2', 'wait_agent', {
84+
target: '/root/review-auth',
85+
}),
86+
new ToolResultMessage(TS, 'tool-subagent-2', { content: 'timed out' }, true),
87+
new CodexSubagentToolUseMessage(TS, 'tool-subagent-3', 'close_agent', {
88+
target: '/root/review-auth',
89+
}),
90+
];
91+
92+
const errored = buildSubagentManagementModel(messages.slice(0, 3));
93+
expect(errored.subagents[0]).toMatchObject({
94+
status: 'error',
95+
statusLabel: 'Error',
96+
});
97+
98+
const closed = buildSubagentManagementModel(messages);
99+
expect(closed.subagents[0]).toMatchObject({
100+
status: 'closed',
101+
statusLabel: 'Closed',
102+
lastActionLabel: 'Closed',
103+
});
104+
});
105+
});
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import {
2+
CodexSubagentToolUseMessage,
3+
ToolResultMessage,
4+
type ChatMessage,
5+
type CodexSubagentAction,
6+
type CodexSubagentDetails,
7+
} from '$shared/chat-types';
8+
9+
export type SubagentManagementStatus =
10+
| 'idle'
11+
| 'running'
12+
| 'waiting'
13+
| 'interrupted'
14+
| 'closed'
15+
| 'error'
16+
| 'observing';
17+
18+
export interface SubagentManagementEntry {
19+
id: string;
20+
kind: 'root' | 'subagent';
21+
name: string;
22+
status: SubagentManagementStatus;
23+
statusLabel: string;
24+
model?: string;
25+
path?: string;
26+
message?: string;
27+
lastActionLabel?: string;
28+
anchorId?: string;
29+
}
30+
31+
export interface SubagentManagementModel {
32+
entries: SubagentManagementEntry[];
33+
subagents: SubagentManagementEntry[];
34+
}
35+
36+
export interface BuildSubagentManagementOptions {
37+
rootTitle?: string;
38+
rootModel?: string | null;
39+
rootStatus?: 'idle' | 'running';
40+
}
41+
42+
export function buildSubagentManagementModel(
43+
messages: ChatMessage[],
44+
options: BuildSubagentManagementOptions = {},
45+
): SubagentManagementModel {
46+
const resultsByToolId = new Map<string, ToolResultMessage>();
47+
for (const message of messages) {
48+
if (message instanceof ToolResultMessage) {
49+
resultsByToolId.set(message.toolId, message);
50+
}
51+
}
52+
53+
const rootStatus = options.rootStatus ?? 'idle';
54+
const rootEntry: SubagentManagementEntry = {
55+
id: 'root',
56+
kind: 'root',
57+
name: options.rootTitle || 'Root',
58+
model: options.rootModel ?? undefined,
59+
status: rootStatus,
60+
statusLabel: statusLabelFor(rootStatus),
61+
};
62+
63+
const orderedSubagents: SubagentManagementEntry[] = [];
64+
const subagentsByKey = new Map<string, SubagentManagementEntry>();
65+
const aliasToKey = new Map<string, string>();
66+
67+
for (const message of messages) {
68+
if (!(message instanceof CodexSubagentToolUseMessage)) continue;
69+
70+
const result = resultsByToolId.get(message.toolId);
71+
const key = resolveEntryKey(message.details, message.toolId, aliasToKey);
72+
let entry = subagentsByKey.get(key);
73+
if (!entry) {
74+
entry = createSubagentEntry(key, message);
75+
subagentsByKey.set(key, entry);
76+
orderedSubagents.push(entry);
77+
}
78+
79+
registerAliases(key, message.details, aliasToKey);
80+
applySubagentEvent(entry, message, result);
81+
}
82+
83+
return {
84+
entries: [rootEntry, ...orderedSubagents],
85+
subagents: orderedSubagents,
86+
};
87+
}
88+
89+
function createSubagentEntry(
90+
key: string,
91+
message: CodexSubagentToolUseMessage,
92+
): SubagentManagementEntry {
93+
return {
94+
id: key,
95+
kind: 'subagent',
96+
name: displayNameFor(message.details, message.toolId),
97+
path: message.details.target ?? message.details.pathPrefix ?? message.details.taskName,
98+
model: message.details.model,
99+
message: message.details.message,
100+
status: 'running',
101+
statusLabel: statusLabelFor('running'),
102+
lastActionLabel: actionLabelFor(message.action),
103+
anchorId: `tool-input-${message.toolId}`,
104+
};
105+
}
106+
107+
function applySubagentEvent(
108+
entry: SubagentManagementEntry,
109+
message: CodexSubagentToolUseMessage,
110+
result: ToolResultMessage | undefined,
111+
): void {
112+
entry.name = displayNameFor(message.details, entry.name);
113+
entry.path = message.details.target ?? message.details.pathPrefix ?? entry.path;
114+
entry.model = message.details.model ?? entry.model;
115+
entry.message = message.details.message ?? entry.message;
116+
entry.lastActionLabel = actionLabelFor(message.action);
117+
entry.status = statusFor(message.action, result?.isError === true);
118+
entry.statusLabel = statusLabelFor(entry.status);
119+
}
120+
121+
function resolveEntryKey(
122+
details: CodexSubagentDetails,
123+
fallback: string,
124+
aliasToKey: Map<string, string>,
125+
): string {
126+
const candidates = [
127+
details.target,
128+
details.pathPrefix,
129+
details.taskName ? `/root/${details.taskName}` : undefined,
130+
details.taskName,
131+
];
132+
for (const candidate of candidates) {
133+
if (!candidate) continue;
134+
const alias = normalizeAlias(candidate);
135+
const existing = aliasToKey.get(alias);
136+
if (existing) return existing;
137+
}
138+
139+
return normalizeAlias(details.target ?? details.pathPrefix ?? details.taskName ?? fallback);
140+
}
141+
142+
function registerAliases(
143+
key: string,
144+
details: CodexSubagentDetails,
145+
aliasToKey: Map<string, string>,
146+
): void {
147+
const aliases = [
148+
details.target,
149+
details.pathPrefix,
150+
details.taskName,
151+
details.taskName ? `/root/${details.taskName}` : undefined,
152+
];
153+
for (const alias of aliases) {
154+
if (alias) aliasToKey.set(normalizeAlias(alias), key);
155+
}
156+
}
157+
158+
function normalizeAlias(value: string): string {
159+
return value.trim().replace(/\/+$/, '') || value;
160+
}
161+
162+
function displayNameFor(details: CodexSubagentDetails, fallback: string): string {
163+
if (details.taskName) return details.taskName;
164+
if (details.target) return details.target.split('/').filter(Boolean).at(-1) ?? details.target;
165+
if (details.pathPrefix)
166+
return details.pathPrefix.split('/').filter(Boolean).at(-1) ?? details.pathPrefix;
167+
return fallback;
168+
}
169+
170+
function actionLabelFor(action: CodexSubagentAction): string {
171+
switch (action) {
172+
case 'spawn_agent':
173+
return 'Spawned';
174+
case 'send_input':
175+
case 'send_message':
176+
return 'Messaged';
177+
case 'followup_task':
178+
return 'Follow-up';
179+
case 'wait_agent':
180+
return 'Waiting';
181+
case 'interrupt_agent':
182+
return 'Interrupted';
183+
case 'list_agents':
184+
return 'Listed';
185+
case 'close_agent':
186+
return 'Closed';
187+
case 'resume_agent':
188+
return 'Resumed';
189+
}
190+
}
191+
192+
function statusFor(action: CodexSubagentAction, isError: boolean): SubagentManagementStatus {
193+
if (isError) return 'error';
194+
switch (action) {
195+
case 'close_agent':
196+
return 'closed';
197+
case 'interrupt_agent':
198+
return 'interrupted';
199+
case 'wait_agent':
200+
return 'waiting';
201+
case 'list_agents':
202+
return 'observing';
203+
case 'spawn_agent':
204+
case 'send_input':
205+
case 'send_message':
206+
case 'followup_task':
207+
case 'resume_agent':
208+
return 'running';
209+
}
210+
}
211+
212+
function statusLabelFor(status: SubagentManagementStatus): string {
213+
switch (status) {
214+
case 'idle':
215+
return 'Idle';
216+
case 'running':
217+
return 'Running';
218+
case 'waiting':
219+
return 'Waiting';
220+
case 'interrupted':
221+
return 'Interrupted';
222+
case 'closed':
223+
return 'Closed';
224+
case 'error':
225+
return 'Error';
226+
case 'observing':
227+
return 'Observed';
228+
}
229+
}

0 commit comments

Comments
 (0)