Skip to content

Commit ea08275

Browse files
authored
Render Codex subagent tool calls (#210)
* feat: render Codex subagent tool calls * chore: format Codex subagent rendering tests
1 parent 2e7eaa0 commit ea08275

13 files changed

Lines changed: 513 additions & 13 deletions

File tree

common/chat-types.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,56 @@ export class TaskToolUseMessage {
212212
) {}
213213
}
214214

215+
export const CODEX_SUBAGENT_ACTIONS = [
216+
'spawn_agent',
217+
'send_input',
218+
'send_message',
219+
'followup_task',
220+
'wait_agent',
221+
'interrupt_agent',
222+
'list_agents',
223+
'close_agent',
224+
'resume_agent',
225+
] as const;
226+
227+
export type CodexSubagentAction = (typeof CODEX_SUBAGENT_ACTIONS)[number];
228+
229+
export interface CodexSubagentInputItem {
230+
type?: string;
231+
text?: string;
232+
imageUrl?: string;
233+
path?: string;
234+
name?: string;
235+
}
236+
237+
export interface CodexSubagentDetails {
238+
target?: string;
239+
targets?: string[];
240+
message?: string;
241+
taskName?: string;
242+
agentType?: string;
243+
model?: string;
244+
reasoningEffort?: string;
245+
serviceTier?: string;
246+
forkContext?: boolean;
247+
forkTurns?: string;
248+
timeoutMs?: number;
249+
pathPrefix?: string;
250+
interrupt?: boolean;
251+
items?: CodexSubagentInputItem[];
252+
}
253+
254+
export class CodexSubagentToolUseMessage {
255+
readonly type = 'codex-subagent-tool-use' as const;
256+
257+
constructor(
258+
public timestamp: string,
259+
public toolId: string,
260+
public action: CodexSubagentAction,
261+
public details: CodexSubagentDetails = {},
262+
) {}
263+
}
264+
215265
export class UpdatePlanToolUseMessage {
216266
readonly type = 'update-plan-tool-use' as const;
217267

@@ -514,6 +564,7 @@ export type ToolUseChatMessage =
514564
| TodoWriteToolUseMessage
515565
| TodoReadToolUseMessage
516566
| TaskToolUseMessage
567+
| CodexSubagentToolUseMessage
517568
| UpdatePlanToolUseMessage
518569
| WriteStdinToolUseMessage
519570
| EnterPlanModeToolUseMessage
@@ -701,6 +752,53 @@ function asOptionalChanges(v: unknown): Array<{ path?: string; kind?: string }>
701752
return undefined;
702753
}
703754

755+
const CODEX_SUBAGENT_ACTION_SET = new Set<string>(CODEX_SUBAGENT_ACTIONS);
756+
757+
function asCodexSubagentAction(v: unknown): CodexSubagentAction | undefined {
758+
return typeof v === 'string' && CODEX_SUBAGENT_ACTION_SET.has(v)
759+
? v as CodexSubagentAction
760+
: undefined;
761+
}
762+
763+
function asCodexSubagentInputItems(v: unknown): CodexSubagentInputItem[] | undefined {
764+
if (!Array.isArray(v)) return undefined;
765+
const items: CodexSubagentInputItem[] = [];
766+
for (const entry of v) {
767+
const raw = asRecord(entry);
768+
const item: CodexSubagentInputItem = {};
769+
if (typeof raw.type === 'string') item.type = raw.type;
770+
if (typeof raw.text === 'string') item.text = raw.text;
771+
if (typeof raw.imageUrl === 'string') item.imageUrl = raw.imageUrl;
772+
if (typeof raw.path === 'string') item.path = raw.path;
773+
if (typeof raw.name === 'string') item.name = raw.name;
774+
if (Object.keys(item).length > 0) items.push(item);
775+
}
776+
return items.length > 0 || v.length === 0 ? items : undefined;
777+
}
778+
779+
function asCodexSubagentDetails(v: unknown): CodexSubagentDetails {
780+
const raw = asRecord(v);
781+
const details: CodexSubagentDetails = {};
782+
if (typeof raw.target === 'string') details.target = raw.target;
783+
const targets = asStringArray(raw.targets);
784+
if (targets !== undefined) details.targets = targets;
785+
if (typeof raw.message === 'string') details.message = raw.message;
786+
if (typeof raw.taskName === 'string') details.taskName = raw.taskName;
787+
if (typeof raw.agentType === 'string') details.agentType = raw.agentType;
788+
if (typeof raw.model === 'string') details.model = raw.model;
789+
if (typeof raw.reasoningEffort === 'string') details.reasoningEffort = raw.reasoningEffort;
790+
if (typeof raw.serviceTier === 'string') details.serviceTier = raw.serviceTier;
791+
if (typeof raw.forkContext === 'boolean') details.forkContext = raw.forkContext;
792+
if (typeof raw.forkTurns === 'string') details.forkTurns = raw.forkTurns;
793+
const timeoutMs = asOptionalNumber(raw.timeoutMs);
794+
if (timeoutMs !== undefined) details.timeoutMs = timeoutMs;
795+
if (typeof raw.pathPrefix === 'string') details.pathPrefix = raw.pathPrefix;
796+
if (typeof raw.interrupt === 'boolean') details.interrupt = raw.interrupt;
797+
const items = asCodexSubagentInputItems(raw.items);
798+
if (items !== undefined) details.items = items;
799+
return details;
800+
}
801+
704802
type ToolUseMessageType = ToolUseChatMessage['type'];
705803
type ToolUseMessageParser = (data: Record<string, unknown>) => ToolUseChatMessage | null;
706804

@@ -794,6 +892,15 @@ const TOOL_USE_MESSAGE_PARSERS = {
794892
asOptionalString(data.model),
795893
asOptionalString(data.resume)),
796894

895+
'codex-subagent-tool-use': (data) => {
896+
const action = asCodexSubagentAction(data.action);
897+
if (action === undefined) return null;
898+
return new CodexSubagentToolUseMessage(
899+
str(data.timestamp), str(data.toolId),
900+
action,
901+
asCodexSubagentDetails(data.details));
902+
},
903+
797904
'update-plan-tool-use': (data) =>
798905
new UpdatePlanToolUseMessage(
799906
str(data.timestamp), str(data.toolId),
71.8 KB
Loading
47.9 KB
Loading

server/agents/codex/__tests__/jsonl-tool-use-converter.test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'bun:test';
22
import { convertCodexFunctionCall, convertCodexCustomToolCall } from '../jsonl-tool-use-converter.js';
33
import {
44
BashToolUseMessage,
5+
CodexSubagentToolUseMessage,
56
EditToolUseMessage,
67
WriteStdinToolUseMessage,
78
UpdatePlanToolUseMessage,
@@ -92,6 +93,37 @@ describe('convertCodexFunctionCall', () => {
9293
]);
9394
});
9495

96+
it('maps spawn_agent to CodexSubagentToolUseMessage', () => {
97+
const msg = convertCodexFunctionCall(TS, {
98+
name: 'spawn_agent',
99+
arguments: '{"task_name":"review-auth","message":"Review auth boundaries","model":"gpt-5.5","fork_turns":"all"}',
100+
call_id: 'call-subagent',
101+
});
102+
expect(msg).toBeInstanceOf(CodexSubagentToolUseMessage);
103+
expect(msg.type).toBe('codex-subagent-tool-use');
104+
expect(msg.action).toBe('spawn_agent');
105+
expect(msg.details).toEqual({
106+
message: 'Review auth boundaries',
107+
taskName: 'review-auth',
108+
model: 'gpt-5.5',
109+
forkTurns: 'all',
110+
});
111+
});
112+
113+
it('maps namespaced v1 subagent tools to CodexSubagentToolUseMessage', () => {
114+
const msg = convertCodexFunctionCall(TS, {
115+
name: 'multi_agent_v1.wait_agent',
116+
arguments: '{"target":"/root/review-auth","timeout_ms":5000}',
117+
call_id: 'call-subagent-v1',
118+
});
119+
expect(msg).toBeInstanceOf(CodexSubagentToolUseMessage);
120+
expect(msg.action).toBe('wait_agent');
121+
expect(msg.details).toEqual({
122+
target: '/root/review-auth',
123+
timeoutMs: 5000,
124+
});
125+
});
126+
95127
it('passes through unmapped function names as Unknown', () => {
96128
const msg = convertCodexFunctionCall(TS, {
97129
name: 'some_new_tool',

server/agents/codex/app-server/__tests__/app-server.test.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { EventEmitter } from 'events';
33
import { promises as fs } from 'fs';
44
import os from 'os';
55
import path from 'path';
6-
import { PermissionRequestMessage, PermissionResolvedMessage } from '../../../../../common/chat-types.js';
6+
import { CodexSubagentToolUseMessage, PermissionRequestMessage, PermissionResolvedMessage } from '../../../../../common/chat-types.js';
77
import { buildApprovalResponse, createPendingApproval } from '../approvals.ts';
88
import { CodexAppServerClient } from '../client.ts';
99
import { convertCodexAppServerItem, convertCodexAppServerLiveItem } from '../converter.ts';
@@ -462,6 +462,35 @@ describe('Codex app-server converter', () => {
462462
'tool-result',
463463
]);
464464
});
465+
466+
it('maps Codex subagent dynamic tool calls to explicit tool-use messages', () => {
467+
const items = [
468+
{ type: 'dynamicToolCall', id: 'd-sub-1', namespace: null, tool: 'spawn_agent', arguments: { task_name: 'review-auth', message: 'Review auth boundaries', model: 'gpt-5.5' }, status: 'completed', contentItems: [{ type: 'text', text: 'spawned /root/review-auth' }], success: true, durationMs: 10 },
469+
{ type: 'dynamicToolCall', id: 'd-sub-2', namespace: null, tool: 'multi_agent_v1.send_input', arguments: { target: '/root/review-auth', items: [{ type: 'text', text: 'Please inspect converter.ts' }] }, status: 'completed', contentItems: [], success: true, durationMs: 10 },
470+
];
471+
472+
const messages = items.flatMap((item) => convertCodexAppServerItem(item, '2026-02-21T10:00:00.000Z'));
473+
474+
expect(messages.map((message) => message.type)).toEqual([
475+
'codex-subagent-tool-use',
476+
'tool-result',
477+
'codex-subagent-tool-use',
478+
'tool-result',
479+
]);
480+
expect(messages[0]).toBeInstanceOf(CodexSubagentToolUseMessage);
481+
expect(messages[0].action).toBe('spawn_agent');
482+
expect(messages[0].details).toEqual({
483+
message: 'Review auth boundaries',
484+
taskName: 'review-auth',
485+
model: 'gpt-5.5',
486+
});
487+
expect(messages[2]).toBeInstanceOf(CodexSubagentToolUseMessage);
488+
expect(messages[2].action).toBe('send_input');
489+
expect(messages[2].details).toEqual({
490+
target: '/root/review-auth',
491+
items: [{ type: 'text', text: 'Please inspect converter.ts' }],
492+
});
493+
});
465494
});
466495

467496
describe('Codex app-server approvals', () => {

server/agents/codex/app-server/converter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from "../../../../common/chat-types.js";
2525
import { stripResolvedFileMentionContext } from "../../shared/file-mention-context.js";
2626
import { normalizeTodoItems, normalizeToolInput, normalizeToolResultContent } from "../../shared/normalize-util.js";
27+
import { convertCodexSubagentToolUse } from '../subagent-tool-use.js';
2728
import type { CodexThreadItem, CodexUserInput, CodexWebSearchAction } from './protocol.js';
2829

2930
export interface ConvertCodexAppServerItemOptions {
@@ -186,6 +187,9 @@ function convertKnownDynamicTool(
186187
tool: string,
187188
input: Record<string, unknown>,
188189
): ChatMessage | null {
190+
const subagentToolUse = convertCodexSubagentToolUse(timestamp, id, tool, input);
191+
if (subagentToolUse) return subagentToolUse;
192+
189193
switch (tool) {
190194
case 'shell_command':
191195
case 'exec_command':

server/agents/codex/jsonl-tool-use-converter.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type ToolUseChatMessage,
1212
} from '../../../common/chat-types.js';
1313
import { normalizeToolInput, normalizeTodoItems } from '../shared/normalize-util.js';
14+
import { convertCodexSubagentToolUse } from './subagent-tool-use.js';
1415

1516
interface ApplyPatchPayload {
1617
file_path: string;
@@ -37,6 +38,10 @@ export function convertCodexFunctionCall(ts: string, payload: unknown): ToolUseC
3738
const rawName = typeof rawPayload.name === 'string' ? rawPayload.name : 'unknown';
3839
const callId = typeof rawPayload.call_id === 'string' ? rawPayload.call_id : '';
3940
const rawArgs = rawPayload.arguments;
41+
const input = asObject(normalizeToolInput(rawArgs));
42+
43+
const subagentToolUse = convertCodexSubagentToolUse(ts, callId, rawName, input);
44+
if (subagentToolUse) return subagentToolUse;
4045

4146
if (rawName === 'shell_command' || rawName === 'exec_command') {
4247
let command: string | undefined;
@@ -58,15 +63,14 @@ export function convertCodexFunctionCall(ts: string, payload: unknown): ToolUseC
5863
}
5964

6065
if (rawName === 'write_stdin') {
61-
return new WriteStdinToolUseMessage(ts, callId, asObject(normalizeToolInput(rawArgs)));
66+
return new WriteStdinToolUseMessage(ts, callId, input);
6267
}
6368

6469
if (rawName === 'update_plan') {
65-
const input = asObject(normalizeToolInput(rawArgs));
6670
return new UpdatePlanToolUseMessage(ts, callId, normalizeTodoItems(input.items ?? input.todos ?? input.plan));
6771
}
6872

69-
return new UnknownToolUseMessage(ts, callId, rawName, asObject(normalizeToolInput(rawArgs)));
73+
return new UnknownToolUseMessage(ts, callId, rawName, input);
7074
}
7175

7276
/**

0 commit comments

Comments
 (0)