feat(codex): refine tool activity display

This commit is contained in:
weishu
2026-07-24 09:39:22 +08:00
parent dd42263aaf
commit 74ad25ec57
28 changed files with 1591 additions and 85 deletions
+2
View File
@@ -112,6 +112,8 @@ describe('codexLocal', () => {
const hookArg = args.find((arg) => arg.startsWith('hooks.SessionStart='));
expect(hookArg).toBeDefined();
expect(hookArg).toContain('{ hooks = [{ type = "command", command = "');
expect(args.some((arg) => arg.startsWith('hooks.PreToolUse='))).toBe(true);
expect(args.some((arg) => arg.startsWith('hooks.PostToolUse='))).toBe(true);
expect(args).toContain("mcp_servers.hapi.args=['mcp','--url','http://127.0.0.1:63995/']");
expect(args).toContain('mcp_servers.hapi.tools.change_title.approval_mode="approve"');
});
+2 -2
View File
@@ -3,7 +3,7 @@ import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard';
import {
buildMcpServerConfigArgs,
buildDeveloperInstructionsArg,
buildSessionStartHookConfigArgs,
buildCodexHookConfigArgs,
buildModelReasoningEffortConfigArgs
} from './utils/codexMcpConfig';
import { codexSystemPrompt } from './utils/systemPrompt';
@@ -70,7 +70,7 @@ export async function codexLocal(opts: {
}
if (opts.sessionHook) {
args.push(...buildSessionStartHookConfigArgs(opts.sessionHook.port, opts.sessionHook.token));
args.push(...buildCodexHookConfigArgs(opts.sessionHook.port, opts.sessionHook.token));
}
// Add developer instructions (system prompt)
+119
View File
@@ -361,6 +361,125 @@ describe('codexLocalLauncher', () => {
});
});
it('renders nested Code Mode plans and commands without their covered exec wrapper', async () => {
const transcriptPath = join(tempDir, 'codex-hook-transcript.jsonl');
const { session, agentMessages } = createSessionStub('default');
let releaseRunBarrier: (() => void) | undefined;
harness.runBarrier = new Promise((resolve) => {
releaseRunBarrier = resolve;
});
await writeFile(
transcriptPath,
JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-hook' } }) + '\n'
);
const launcherPromise = codexLocalLauncher(session as never);
await wait(50);
harness.sessionHookHandlers[0]?.('codex-thread-hook', {
hook_event_name: 'SessionStart',
transcript_path: transcriptPath
});
await wait(100);
await appendFile(transcriptPath, JSON.stringify({
type: 'response_item',
payload: {
type: 'custom_tool_call',
name: 'exec',
call_id: 'call-wrapper',
input: [
'await tools.update_plan({ plan: [{ step: "Inspect", status: "completed" }] });',
'const r = await tools.exec_command({ cmd: "pwd" });',
'text(r.output);'
].join('\n'),
internal_chat_message_metadata_passthrough: { turn_id: 'turn-hook' }
}
}) + '\n');
await wait(700);
harness.sessionHookHandlers[0]?.('codex-thread-hook', {
hook_event_name: 'PreToolUse',
turn_id: 'turn-hook',
cwd: '/tmp/worktree',
tool_name: 'update_plan',
tool_input: { plan: [{ step: 'Inspect', status: 'completed' }] },
tool_use_id: 'exec-plan-1'
});
harness.sessionHookHandlers[0]?.('codex-thread-hook', {
hook_event_name: 'PostToolUse',
turn_id: 'turn-hook',
cwd: '/tmp/worktree',
tool_name: 'update_plan',
tool_input: { plan: [{ step: 'Inspect', status: 'completed' }] },
tool_response: 'Plan updated',
tool_use_id: 'exec-plan-1'
});
harness.sessionHookHandlers[0]?.('codex-thread-hook', {
hook_event_name: 'PreToolUse',
turn_id: 'turn-hook',
cwd: '/tmp/worktree',
tool_name: 'Bash',
tool_input: { command: 'pwd' },
tool_use_id: 'exec-command-1'
});
harness.sessionHookHandlers[0]?.('codex-thread-hook', {
hook_event_name: 'PostToolUse',
turn_id: 'turn-hook',
cwd: '/tmp/worktree',
tool_name: 'Bash',
tool_input: { command: 'pwd' },
tool_response: '/tmp/worktree\n',
tool_use_id: 'exec-command-1'
});
await appendFile(transcriptPath, JSON.stringify({
type: 'response_item',
payload: {
type: 'custom_tool_call_output',
call_id: 'call-wrapper',
output: [{ type: 'input_text', text: '/tmp/worktree\n' }],
internal_chat_message_metadata_passthrough: { turn_id: 'turn-hook' }
}
}) + '\n');
await wait(700);
releaseRunBarrier?.();
await launcherPromise;
expect(agentMessages).toEqual([{
type: 'tool-call',
name: 'update_plan',
callId: 'exec-plan-1',
input: { plan: [{ step: 'Inspect', status: 'completed' }] },
id: expect.any(String)
}, {
type: 'tool-call-result',
callId: 'exec-plan-1',
output: 'Plan updated',
id: expect.any(String)
}, {
type: 'tool-call',
name: 'CodexBash',
callId: 'exec-command-1',
input: {
command: 'pwd',
cwd: '/tmp/worktree',
source: 'codex-hook'
},
id: expect.any(String)
}, {
type: 'tool-call-result',
callId: 'exec-command-1',
output: {
stdout: '/tmp/worktree\n',
stderr: '',
status: 'completed'
},
id: expect.any(String)
}]);
});
it('falls back to the top-level review transcript when a review subagent is active', async () => {
const originalCodexHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = tempDir;
+67
View File
@@ -11,8 +11,16 @@ import { parseCodexCliOverrides, stripCodexCliOverrides } from './utils/codexCli
import { buildCodexPermissionModeCliArgs } from './utils/permissionModeConfig';
import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher';
import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './utils/codexTranscriptLocator';
import { CodexToolHookBridge, isCodexToolHookEvent } from './utils/codexToolHookBridge';
import { countHookCoveredExecCalls } from './utils/codexExecWrapper';
type ProposedPlanMessage = Extract<CodexMessage, { type: 'proposed_plan' }>;
type ToolCallMessage = Extract<CodexMessage, { type: 'tool-call' }>;
type PendingExecWrapper = {
message: ToolCallMessage;
turnId?: string;
};
export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
const resumeSessionId = session.sessionId;
@@ -25,6 +33,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
let transcriptLocator: CodexTranscriptLocator | null = null;
let scannerTranscriptPath: string | null = null;
const pendingPlansByTurnId = new Map<string, ProposedPlanMessage>();
const pendingExecWrappers = new Map<string, PendingExecWrapper>();
const toolHookBridge = new CodexToolHookBridge();
const permissionMode = session.getPermissionMode();
const managedPermissionMode = permissionMode === 'read-only' || permissionMode === 'safe-yolo' || permissionMode === 'yolo'
? permissionMode
@@ -97,6 +107,30 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
}
};
const flushPendingExecWrapper = (callId: string, result?: CodexMessage): void => {
const pending = pendingExecWrappers.get(callId);
if (!pending) return;
pendingExecWrappers.delete(callId);
session.sendAgentMessage(pending.message);
if (result) {
session.sendAgentMessage(result);
}
};
const flushAllPendingExecWrappers = (): void => {
for (const [callId, pending] of pendingExecWrappers) {
session.sendAgentMessage(pending.message);
session.sendAgentMessage({
type: 'tool-call-result',
callId,
output: { error: 'Codex ended before the exec wrapper returned a result.' },
is_error: true,
id: `${pending.message.id}:incomplete`
});
}
pendingExecWrappers.clear();
};
const bindPrimarySession = (sessionId: string, transcriptPath: string, allowSwitch = false): void => {
if (primarySessionId && primarySessionId !== sessionId && !allowSwitch) {
logger.debug(`[codex-local]: Ignoring non-primary SessionStart hook ${sessionId}; primary is ${primarySessionId}`);
@@ -155,12 +189,32 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
if (message.type === 'proposed_plan') {
// Codex may complete the Plan item before emitting its final text preface.
pendingPlansByTurnId.set(message.turnId, message);
} else if (message.type === 'tool-call' && message.name === 'exec') {
if (countHookCoveredExecCalls(message.input) === null) {
session.sendAgentMessage(message);
} else {
pendingExecWrappers.set(message.callId, {
message,
...(converted?.turnId ? { turnId: converted.turnId } : {})
});
}
} else if (message.type === 'tool-call-result' && pendingExecWrappers.has(message.callId)) {
const pending = pendingExecWrappers.get(message.callId);
const turnId = pending?.turnId ?? converted?.turnId;
if (pending && toolHookBridge.hasCompletedAllObservedNestedTools(turnId)) {
pendingExecWrappers.delete(message.callId);
} else {
flushPendingExecWrapper(message.callId, message);
}
} else {
session.sendAgentMessage(message);
}
}
if (converted?.finishedTurnId) {
flushPendingPlan(converted.finishedTurnId);
for (const message of toolHookBridge.finishTurn(converted.finishedTurnId)) {
session.sendAgentMessage(message);
}
}
}
});
@@ -217,6 +271,15 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
if (shuttingDown) {
return;
}
if (isCodexToolHookEvent(data)) {
if (primarySessionId && primarySessionId !== sessionId) {
return;
}
for (const message of toolHookBridge.handle(data)) {
session.sendAgentMessage(message);
}
return;
}
handleSessionHook(sessionId, data);
}
});
@@ -297,6 +360,10 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
if (activeScanner) {
await activeScanner.cleanup();
}
flushAllPendingExecWrappers();
for (const message of toolHookBridge.finish()) {
session.sendAgentMessage(message);
}
flushAllPendingPlans();
happyServer.stop();
if (!hookReady) {
+82
View File
@@ -64,6 +64,7 @@ const harness = vi.hoisted(() => ({
emitSecondParentSpawnStartWithoutEnd: false,
emitParentSendInputFailure: false,
emitParentResumeSuccess: false,
emitParentV2AgentTools: false,
emitRunningChildTurnBeforeSuppressedParent: false,
emitCompletedChildTurnBeforeSuppressedParent: false,
emitTurnAbortedOnInterrupt: false,
@@ -426,6 +427,48 @@ vi.mock('./codexAppServerClient', () => {
this.notificationHandler?.('thread/compacted', parentCompact);
}
if (harness.emitParentV2AgentTools) {
const v2Calls = [{
callId: 'v2-spawn',
name: 'spawn_agent',
input: { task_name: 'review', message: 'Review this' },
output: { task_name: '/root/review', nickname: 'Reviewer' }
}, {
callId: 'v2-wait',
name: 'wait_agent',
input: { timeout_ms: 10_000 },
output: { message: 'Wait timed out.', timed_out: true }
}];
for (const call of v2Calls) {
const started = {
threadId,
turnId,
item: {
type: 'function_call',
namespace: 'collaboration',
name: call.name,
arguments: JSON.stringify(call.input),
call_id: call.callId
}
};
harness.notifications.push({ method: 'rawResponseItem/completed', params: started });
this.notificationHandler?.('rawResponseItem/completed', started);
const completed = {
threadId,
turnId,
item: {
type: 'function_call_output',
call_id: call.callId,
output: JSON.stringify(call.output)
}
};
harness.notifications.push({ method: 'rawResponseItem/completed', params: completed });
this.notificationHandler?.('rawResponseItem/completed', completed);
}
}
if (harness.emitParentSpawnFailureWithoutAgentId || harness.emitParentSpawnStartWithoutEnd) {
const spawnStart = {
item: {
@@ -1105,6 +1148,7 @@ describe('codexRemoteLauncher', () => {
harness.emitSecondParentSpawnStartWithoutEnd = false;
harness.emitParentSendInputFailure = false;
harness.emitParentResumeSuccess = false;
harness.emitParentV2AgentTools = false;
harness.emitRunningChildTurnBeforeSuppressedParent = false;
harness.emitCompletedChildTurnBeforeSuppressedParent = false;
harness.emitTurnAbortedOnInterrupt = false;
@@ -2477,6 +2521,44 @@ describe('codexRemoteLauncher', () => {
}));
});
it('forwards shared MultiAgent V2 tools as direct tool cards', async () => {
harness.emitParentV2AgentTools = true;
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'tool-call',
name: 'spawn_agent',
callId: 'v2-spawn',
input: { task_name: 'review', message: 'Review this' }
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'tool-call-result',
callId: 'v2-spawn',
output: '{"task_name":"/root/review","nickname":"Reviewer"}'
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'tool-call',
name: 'wait_agent',
callId: 'v2-wait',
input: { timeout_ms: 10_000 }
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'tool-call-result',
callId: 'v2-wait',
output: '{"message":"Wait timed out.","timed_out":true}'
}));
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'agent-run-start',
cardId: 'v2-spawn'
}));
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'spawn-error:v2-spawn'
}));
});
it('marks pending spawn_agent cards failed with the Codex router argument error from stderr', async () => {
harness.emitParentSpawnStartWithoutEnd = true;
harness.emitParentSpawnRouterStderrError = true;
+30 -16
View File
@@ -684,6 +684,20 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|| toolName === 'close_agent';
};
const isLegacyCodexAgentToolCall = (toolName: string | null, input: unknown): boolean => {
if (!isCodexAgentToolName(toolName)) return false;
const inputRecord = asRecord(input);
if (toolName === 'spawn_agent') {
return !asString(inputRecord?.task_name ?? inputRecord?.taskName);
}
if (toolName === 'wait_agent') {
return Array.isArray(inputRecord?.targets);
}
return true;
};
const isTerminalAgentRunStatus = (status: string | null | undefined): boolean => {
return status === 'completed'
|| status === 'failed'
@@ -1707,7 +1721,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
const callId = asString(msg.call_id ?? msg.callId);
const name = asString(msg.name);
if (callId && name) {
if (isCodexAgentToolName(name)) {
if (isLegacyCodexAgentToolCall(name, msg.input)) {
const error = 'Nested agent calls are disabled for child agents.';
runtime.blockedNestedAgent = true;
emitAgentRunTraceMessage(agentId, {
@@ -2988,20 +3002,20 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
const callId = asString(msg.call_id ?? msg.callId);
const name = asString(msg.name);
if (callId && name) {
if (isCodexAgentToolName(name)) {
const input = msg.input ?? {};
pendingAgentToolInputByCallId.set(callId, { name, input });
if (name === 'spawn_agent') {
emitAgentRunStart(callId, input);
} else {
for (const agentId of extractAgentTargets(input)) {
if (!agentCardByAgentId.has(agentId)) {
continue;
}
const activity = name === 'wait_agent'
? 'Waiting for agent'
: name === 'send_input'
? 'Sending input'
if (isLegacyCodexAgentToolCall(name, msg.input)) {
const input = msg.input ?? {};
pendingAgentToolInputByCallId.set(callId, { name, input });
if (name === 'spawn_agent') {
emitAgentRunStart(callId, input);
} else {
for (const agentId of extractAgentTargets(input)) {
if (!agentCardByAgentId.has(agentId)) {
continue;
}
const activity = name === 'wait_agent'
? 'Waiting for agent'
: name === 'send_input'
? 'Sending input'
: name === 'resume_agent'
? 'Resuming agent'
: name === 'close_agent'
@@ -3030,7 +3044,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
const callId = asString(msg.call_id ?? msg.callId);
const name = asString(msg.name) ?? pendingAgentToolInputByCallId.get(callId ?? '')?.name ?? null;
if (callId) {
if (name && isCodexAgentToolName(name)) {
if (name && pendingAgentToolInputByCallId.has(callId)) {
handleAgentToolEnd(callId, name, msg.output, Boolean(msg.is_error ?? msg.isError));
return;
}
@@ -359,6 +359,145 @@ describe('AppServerEventConverter', () => {
}]);
});
it('maps raw MultiAgent V2 calls without inventing collab tool variants', () => {
const converter = new AppServerEventConverter();
expect(converter.handleNotification('rawResponseItem/completed', {
threadId: 'thread-1',
turnId: 'turn-1',
item: {
type: 'function_call',
namespace: 'collaboration',
name: 'followup_task',
arguments: JSON.stringify({ target: '/root/review', message: 'Continue with tests' }),
call_id: 'call-followup'
}
})).toEqual([{
thread_id: 'thread-1',
turn_id: 'turn-1',
type: 'codex_tool_call_begin',
call_id: 'call-followup',
name: 'followup_task',
input: {
message: 'Continue with tests',
target: '/root/review'
}
}]);
expect(converter.handleNotification('rawResponseItem/completed', {
threadId: 'thread-1',
turnId: 'turn-1',
item: {
type: 'function_call_output',
call_id: 'call-followup',
output: ''
}
})).toEqual([{
thread_id: 'thread-1',
turn_id: 'turn-1',
type: 'codex_tool_call_end',
call_id: 'call-followup',
name: 'followup_task',
output: '',
is_error: false
}]);
expect(converter.handleNotification('item/started', {
item: {
id: 'call-followup',
type: 'collabAgentToolCall',
tool: 'followupTask'
}
})).toEqual([]);
});
it('keeps MultiAgent V1 calls on the collab item stream', () => {
const converter = new AppServerEventConverter();
expect(converter.handleNotification('rawResponseItem/completed', {
threadId: 'thread-1',
turnId: 'turn-1',
item: {
type: 'function_call',
namespace: 'multi_agent_v1',
name: 'spawn_agent',
arguments: JSON.stringify({ message: 'Do side work' }),
call_id: 'call-v1-spawn'
}
})).toEqual([]);
expect(converter.handleNotification('rawResponseItem/completed', {
threadId: 'thread-1',
turnId: 'turn-1',
item: {
type: 'function_call_output',
call_id: 'call-v1-spawn',
output: '{"agent_id":"agent-1","nickname":null}'
}
})).toEqual([]);
expect(converter.handleNotification('item/started', {
item: {
id: 'call-v1-spawn',
type: 'collabAgentToolCall',
tool: 'spawnAgent',
prompt: 'Do side work'
}
})).toEqual([expect.objectContaining({
type: 'codex_tool_call_begin',
call_id: 'call-v1-spawn',
name: 'spawn_agent'
})]);
});
it('maps shared MultiAgent V2 names only when their V2 argument shape is present', () => {
const converter = new AppServerEventConverter();
expect(converter.handleNotification('rawResponseItem/completed', {
item: {
type: 'function_call',
namespace: 'collaboration',
name: 'spawn_agent',
arguments: JSON.stringify({ task_name: 'review', message: 'Review this' }),
call_id: 'call-v2-spawn'
}
})).toEqual([expect.objectContaining({
type: 'codex_tool_call_begin',
call_id: 'call-v2-spawn',
name: 'spawn_agent',
input: { task_name: 'review', message: 'Review this' }
})]);
expect(converter.handleNotification('rawResponseItem/completed', {
item: {
type: 'function_call',
namespace: 'collaboration',
name: 'wait_agent',
arguments: JSON.stringify({ timeout_ms: 10_000 }),
call_id: 'call-v2-wait'
}
})).toEqual([expect.objectContaining({
type: 'codex_tool_call_begin',
call_id: 'call-v2-wait',
name: 'wait_agent',
input: { timeout_ms: 10_000 }
})]);
expect(converter.handleNotification('rawResponseItem/completed', {
item: {
type: 'function_call',
namespace: 'agents',
name: 'list_agents',
arguments: '{}',
call_id: 'call-v2-custom-namespace'
}
})).toEqual([expect.objectContaining({
type: 'codex_tool_call_begin',
call_id: 'call-v2-custom-namespace',
name: 'list_agents'
})]);
});
it('maps reasoning deltas', () => {
const converter = new AppServerEventConverter();
+107 -2
View File
@@ -287,19 +287,75 @@ function sanitizeUnhandledNotificationLogValue(value: unknown, depth: number = 0
return result;
}
function normalizeCollabAgentToolName(value: unknown): string | null {
function normalizeCodexAgentToolName(value: unknown): string | null {
const raw = asString(value);
if (!raw) return null;
const normalized = raw.trim().toLowerCase().replace(/[\s_-]/g, '');
if (normalized === 'spawnagent' || normalized === 'spawn') return 'spawn_agent';
if (normalized === 'sendinput' || normalized === 'sendmessage') return 'send_input';
if (normalized === 'sendinput') return 'send_input';
if (normalized === 'sendmessage') return 'send_message';
if (normalized === 'resumeagent' || normalized === 'resume') return 'resume_agent';
if (normalized === 'followuptask' || normalized === 'assigntask') return 'followup_task';
if (normalized === 'waitagent' || normalized === 'wait') return 'wait_agent';
if (normalized === 'closeagent' || normalized === 'close') return 'close_agent';
if (normalized === 'interruptagent' || normalized === 'interrupt') return 'interrupt_agent';
if (normalized === 'listagents') return 'list_agents';
return null;
}
function normalizeCollabAgentToolName(value: unknown): string | null {
const toolName = normalizeCodexAgentToolName(value);
return toolName === 'spawn_agent'
|| toolName === 'send_input'
|| toolName === 'resume_agent'
|| toolName === 'wait_agent'
|| toolName === 'close_agent'
? toolName
: null;
}
function parseRawToolInput(value: unknown): unknown {
if (typeof value !== 'string') return value ?? {};
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
const MULTI_AGENT_V1_NAMESPACE = 'multi_agent_v1';
function isRawMultiAgentV2Call(
toolName: string,
input: unknown,
namespace: unknown
): boolean {
// V1 already has richer collabAgentToolCall lifecycle items. V2 uses the
// configurable namespace ("collaboration" by default), so only the fixed
// V1 namespace can be excluded here.
if (asString(namespace) === MULTI_AGENT_V1_NAMESPACE) return false;
if (
toolName === 'send_message'
|| toolName === 'followup_task'
|| toolName === 'interrupt_agent'
|| toolName === 'list_agents'
) {
return true;
}
const inputRecord = asRecord(input);
if (toolName === 'spawn_agent') {
return Boolean(asString(inputRecord?.task_name ?? inputRecord?.taskName));
}
if (toolName === 'wait_agent') {
return !Array.isArray(inputRecord?.targets);
}
return false;
}
function extractStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
@@ -443,6 +499,8 @@ export class AppServerEventConverter {
private readonly lastAgentMessageDeltaByItemId = new Map<string, string>();
private readonly lastReasoningDeltaByItemId = new Map<string, string>();
private readonly lastCommandOutputDeltaByItemId = new Map<string, string>();
private readonly rawAgentToolCallIds = new Set<string>();
private readonly rawAgentToolNames = new Map<string, string>();
private handleWrappedCodexEvent(paramsRecord: Record<string, unknown>): ConvertedEvent[] | null {
const msg = asRecord(paramsRecord.msg);
@@ -794,6 +852,50 @@ export class AppServerEventConverter {
return events;
}
if (method === 'rawResponseItem/completed') {
const item = asRecord(paramsRecord.item);
if (!item) return events;
const itemType = normalizeItemType(item.type);
const callId = asString(item.call_id ?? item.callId);
if (!itemType || !callId) return events;
if (itemType === 'functioncall') {
const toolName = normalizeCodexAgentToolName(item.name);
const input = parseRawToolInput(item.arguments);
if (
!toolName
|| !isRawMultiAgentV2Call(toolName, input, item.namespace)
|| this.rawAgentToolCallIds.has(callId)
) return events;
this.rawAgentToolCallIds.add(callId);
this.rawAgentToolNames.set(callId, toolName);
events.push(scoped({
type: 'codex_tool_call_begin',
call_id: callId,
name: toolName,
input
}));
return events;
}
if (itemType === 'functioncalloutput') {
const toolName = this.rawAgentToolNames.get(callId);
if (!toolName) return events;
this.rawAgentToolNames.delete(callId);
events.push(scoped({
type: 'codex_tool_call_end',
call_id: callId,
name: toolName,
output: item.output,
is_error: false
}));
}
return events;
}
if (method === 'item/agentMessage/delta') {
const itemId = extractItemId(paramsRecord);
const delta = asString(paramsRecord.delta ?? paramsRecord.text ?? paramsRecord.message);
@@ -1012,6 +1114,7 @@ export class AppServerEventConverter {
}
if (itemType === 'collabagenttoolcall') {
if (this.rawAgentToolCallIds.has(itemId)) return events;
const toolName = normalizeCollabAgentToolName(item.tool ?? item.name);
if (!toolName) return events;
@@ -1092,5 +1195,7 @@ export class AppServerEventConverter {
this.lastAgentMessageDeltaByItemId.clear();
this.lastReasoningDeltaByItemId.clear();
this.lastCommandOutputDeltaByItemId.clear();
this.rawAgentToolCallIds.clear();
this.rawAgentToolNames.clear();
}
}
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import {
buildWrappedErrorEvent,
buildWrappedItemNotification,
buildWrappedReasoningSectionBreakNotification,
buildWrappedTerminalEvent,
buildWrappedTextDeltaNotification,
isIgnoredWrappedCodexEventType,
isWrappedTerminalEventType
} from './appServerWrappedEvents';
describe('app-server wrapped event helpers', () => {
it('builds wrapped terminal events', () => {
expect(isWrappedTerminalEventType('task_complete')).toBe(true);
expect(isWrappedTerminalEventType('agent_message')).toBe(false);
expect(buildWrappedTerminalEvent({
type: 'task_failed',
thread: { id: 'thread-1' },
turn: { id: 'turn-1' },
message: 'boom'
}, {
thread_id: 'thread-1',
turn_id: 'turn-1'
})).toEqual({
type: 'task_failed',
thread_id: 'thread-1',
turn_id: 'turn-1',
error: 'boom'
});
expect(buildWrappedTerminalEvent({ type: 'task_complete' }, {})).toBeNull();
expect(buildWrappedTerminalEvent({ type: 'agent_message' }, {})).toBeNull();
});
it('builds wrapped forwarded notifications', () => {
expect(buildWrappedTextDeltaNotification({
type: 'agent_message_delta',
item_id: 'msg-1',
delta: 'Hello'
}, { turn_id: 'turn-1' })).toEqual({
method: 'item/agentMessage/delta',
params: {
itemId: 'msg-1',
delta: 'Hello',
turn_id: 'turn-1'
}
});
expect(buildWrappedTextDeltaNotification({
type: 'exec_command_output_delta',
call_id: 'cmd-1',
stdout: 'ok'
}, {})).toEqual({
method: 'item/commandExecution/outputDelta',
params: {
itemId: 'cmd-1',
delta: 'ok'
}
});
expect(buildWrappedTextDeltaNotification({ type: 'agent_message_delta' }, {})).toBeNull();
expect(buildWrappedReasoningSectionBreakNotification({
type: 'agent_reasoning_section_break',
item_id: 'r1',
summary_index: 2
}, { thread_id: 'thread-1' })).toEqual({
method: 'item/reasoning/summaryPartAdded',
params: {
itemId: 'r1',
thread_id: 'thread-1',
summaryIndex: 2
}
});
});
it('builds wrapped item forwarded notifications', () => {
expect(buildWrappedItemNotification({
type: 'item_started',
item: {
id: 'cmd-1',
type: 'commandExecution',
command: 'pwd',
thread: { id: 'child-thread' },
turn: { id: 'child-turn' }
}
}, {
thread_id: 'child-thread',
turn_id: 'child-turn'
})).toEqual({
method: 'item/started',
params: {
thread_id: 'child-thread',
turn_id: 'child-turn',
item: {
id: 'cmd-1',
type: 'commandExecution',
command: 'pwd',
thread: { id: 'child-thread' },
turn: { id: 'child-turn' }
},
itemId: 'cmd-1',
threadId: 'child-thread',
turnId: 'child-turn'
}
});
expect(buildWrappedItemNotification({ type: 'agent_message' }, {})).toBeNull();
});
it('keeps wrapped ignore event types table-driven', () => {
expect(isIgnoredWrappedCodexEventType('agent_message')).toBe(true);
expect(isIgnoredWrappedCodexEventType('agent_reasoning_delta')).toBe(true);
expect(isIgnoredWrappedCodexEventType('mcp_startup_update')).toBe(true);
expect(isIgnoredWrappedCodexEventType('item_completed')).toBe(false);
expect(isIgnoredWrappedCodexEventType('task_failed')).toBe(false);
});
it('builds wrapped error events', () => {
expect(buildWrappedErrorEvent({ message: 'fatal' })).toEqual({
type: 'task_failed',
error: 'fatal'
});
expect(buildWrappedErrorEvent({
message: 'temporary',
error: { willRetry: true }
})).toBeNull();
expect(buildWrappedErrorEvent({ type: 'error' })).toBeNull();
});
});
@@ -223,6 +223,23 @@ describe('convertCodexEvent', () => {
});
});
it('preserves the turn id for custom exec wrapper correlation', () => {
const result = convertCodexEvent({
type: 'response_item',
payload: {
type: 'custom_tool_call',
name: 'exec',
call_id: 'call-exec-turn',
input: 'await tools.exec_command({ cmd: "pwd" });',
internal_chat_message_metadata_passthrough: {
turn_id: 'turn-1'
}
}
});
expect(result?.turnId).toBe('turn-1');
});
it('converts tool_search_call items', () => {
const result = convertCodexEvent({
type: 'response_item',
@@ -46,6 +46,7 @@ export type CodexMessage = {
export type CodexConversionResult = {
sessionId?: string;
turnId?: string;
messages?: CodexMessage[];
userMessage?: string;
userActivity?: true;
@@ -99,6 +100,11 @@ function extractCallId(payload: Record<string, unknown>): string | null {
return null;
}
function extractResponseItemTurnId(payload: Record<string, unknown>): string | null {
const metadata = asRecord(payload.internal_chat_message_metadata_passthrough);
return metadata ? asString(metadata.turn_id) ?? asString(metadata.turnId) : null;
}
export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | null {
const parsed = CodexSessionEventSchema.safeParse(rawEvent);
if (!parsed.success) {
@@ -266,7 +272,9 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
if (!name || !callId) {
return null;
}
const turnId = extractResponseItemTurnId(payloadRecord);
return {
...(turnId ? { turnId } : {}),
messages: [{
type: 'tool-call',
name,
@@ -282,7 +290,9 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
if (!callId) {
return null;
}
const turnId = extractResponseItemTurnId(payloadRecord);
return {
...(turnId ? { turnId } : {}),
messages: [{
type: 'tool-call-result',
callId,
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { countHookCoveredExecCalls, isFullyHookCoveredExecSource } from './codexExecWrapper';
describe('isFullyHookCoveredExecSource', () => {
it('accepts direct and parallel hook-covered calls', () => {
const source = `
const calls = [
tools.exec_command({ cmd: 'pwd' }),
tools.apply_patch('*** Begin Patch')
];
await Promise.all(calls);
`;
expect(isFullyHookCoveredExecSource(source)).toBe(true);
expect(countHookCoveredExecCalls(source)).toBe(2);
});
it('accepts literal MCP property access', () => {
expect(isFullyHookCoveredExecSource(`
await tools.mcp__hapi__change_title({ title: 'Title' });
`)).toBe(true);
expect(isFullyHookCoveredExecSource(`
await tools['mcp__hapi__change_title']({ title: 'Title' });
`)).toBe(true);
});
it('accepts mixed plan and command wrappers', () => {
expect(isFullyHookCoveredExecSource(`
await tools.update_plan({ plan: [] });
await tools.exec_command({ cmd: 'pwd' });
`)).toBe(true);
});
it('accepts dynamically registered literal tool names', () => {
expect(isFullyHookCoveredExecSource('await tools.view_image({ path: "/tmp/a.png" });')).toBe(true);
expect(isFullyHookCoveredExecSource('await tools.get_goal({});')).toBe(true);
expect(countHookCoveredExecCalls('await tools.view_image({ path: "/tmp/a.png" });')).toBe(1);
});
it('retains wrappers with dynamic tool access', () => {
expect(isFullyHookCoveredExecSource('await tools[selectedTool](input);')).toBe(false);
expect(isFullyHookCoveredExecSource('const registry = tools;')).toBe(false);
});
});
+29
View File
@@ -0,0 +1,29 @@
export function countHookCoveredExecCalls(source: unknown): number | null {
if (typeof source !== 'string' || source.length === 0) return null;
const toolReference = /\btools\b/g;
let toolCount = 0;
for (const match of source.matchAll(toolReference)) {
const tail = source.slice((match.index ?? 0) + match[0].length).trimStart();
let toolName: string | null = null;
if (tail.startsWith('.')) {
toolName = tail.slice(1).match(/^[$A-Z_a-z][$\w]*/)?.[0] ?? null;
} else if (tail.startsWith('[')) {
const bracket = tail.match(/^\[\s*(['"])([$A-Z_a-z][$\w]*(?:__[$A-Z_a-z][$\w]*)*)\1\s*\]/);
toolName = bracket?.[2] ?? null;
}
if (!toolName) {
return null;
}
toolCount += 1;
}
return toolCount > 0 ? toolCount : null;
}
export function isFullyHookCoveredExecSource(source: unknown): boolean {
return countHookCoveredExecCalls(source) !== null;
}
+13 -7
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
buildMcpServerConfigArgs,
buildDeveloperInstructionsArg,
buildSessionStartHookConfigArgs
buildCodexHookConfigArgs
} from './codexMcpConfig';
describe('codexMcpConfig', () => {
@@ -113,18 +113,24 @@ describe('codexMcpConfig', () => {
});
});
describe('buildSessionStartHookConfigArgs', () => {
it('builds a SessionStart hook config override', () => {
const args = buildSessionStartHookConfigArgs(4312, 'secret-token');
describe('buildCodexHookConfigArgs', () => {
it('builds trusted SessionStart and tool lifecycle hook overrides', () => {
const args = buildCodexHookConfigArgs(4312, 'secret-token');
expect(args[0]).toBe('-c');
expect(args[1]).toContain('hooks.SessionStart=[');
expect(args[1]).toContain('type = "command"');
expect(args[1]).toContain('hook-forwarder --port 4312 --token secret-token');
expect(args[2]).toBe('-c');
expect(args[3]).toContain('hooks.state={');
expect(args[3]).toContain(':session_start:0:0');
expect(args[3]).toContain('trusted_hash="sha256:');
expect(args[3]).toContain('hooks.PreToolUse=[');
expect(args[3]).toContain('matcher = "*"');
expect(args[5]).toContain('hooks.PostToolUse=[');
expect(args[5]).toContain('matcher = "*"');
expect(args[7]).toContain('hooks.state={');
expect(args[7]).toContain(':session_start:0:0');
expect(args[7]).toContain(':pre_tool_use:0:0');
expect(args[7]).toContain(':post_tool_use:0:0');
expect(args[7].match(/trusted_hash="sha256:/g)).toHaveLength(3);
});
});
});
+31 -10
View File
@@ -8,6 +8,7 @@
*/
import { createHash } from 'node:crypto';
import { CODEX_TOOL_HOOK_MATCHER } from './codexToolHookNames';
import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
import type { McpServersConfig } from './buildHapiMcpBridge';
@@ -73,9 +74,16 @@ function versionForTomlLikeValue(value: unknown): string {
return `sha256:${createHash('sha256').update(serialized).digest('hex')}`;
}
function buildSessionStartHookTrustedHash(command: string): string {
const CODEX_HOOK_EVENTS = [
{ configName: 'SessionStart', stateName: 'session_start' },
{ configName: 'PreToolUse', stateName: 'pre_tool_use', matcher: CODEX_TOOL_HOOK_MATCHER },
{ configName: 'PostToolUse', stateName: 'post_tool_use', matcher: CODEX_TOOL_HOOK_MATCHER }
] as const;
function buildCodexHookTrustedHash(command: string, stateName: string, matcher?: string): string {
return versionForTomlLikeValue({
event_name: 'session_start',
event_name: stateName,
...(matcher ? { matcher } : {}),
hooks: [
{
async: false,
@@ -87,14 +95,14 @@ function buildSessionStartHookTrustedHash(command: string): string {
});
}
function sessionFlagsHookStateKey(): string {
function sessionFlagsHookStateKey(stateName: string): string {
const sourcePath = process.platform === 'win32'
? 'C:\\<session-flags>\\config.toml'
: '/<session-flags>/config.toml';
return `${sourcePath}:session_start:0:0`;
return `${sourcePath}:${stateName}:0:0`;
}
export function buildSessionStartHookConfigArgs(port: number, token: string): string[] {
export function buildCodexHookConfigArgs(port: number, token: string): string[] {
const { command, args } = getHappyCliCommand([
'hook-forwarder',
'--port',
@@ -104,11 +112,24 @@ export function buildSessionStartHookConfigArgs(port: number, token: string): st
]);
const hookCommand = shellJoin([command, ...args]);
const escapedHookCommand = escapeTomlString(hookCommand);
const hookConfig = `hooks.SessionStart=[{ hooks = [{ type = "command", command = "${escapedHookCommand}" }] }]`;
const trustedHash = buildSessionStartHookTrustedHash(hookCommand);
const escapedStateKey = escapeTomlString(sessionFlagsHookStateKey());
const hookState = `hooks.state={"${escapedStateKey}"={trusted_hash="${trustedHash}"}}`;
return ['-c', hookConfig, '-c', hookState];
const configArgs: string[] = [];
const stateEntries: string[] = [];
for (const event of CODEX_HOOK_EVENTS) {
const matcher = 'matcher' in event ? event.matcher : undefined;
const matcherConfig = matcher ? ` matcher = "${escapeTomlString(matcher)}",` : '';
configArgs.push(
'-c',
`hooks.${event.configName}=[{${matcherConfig} hooks = [{ type = "command", command = "${escapedHookCommand}" }] }]`
);
const trustedHash = buildCodexHookTrustedHash(hookCommand, event.stateName, matcher);
const escapedStateKey = escapeTomlString(sessionFlagsHookStateKey(event.stateName));
stateEntries.push(`"${escapedStateKey}"={trusted_hash="${trustedHash}"}`);
}
configArgs.push('-c', `hooks.state={${stateEntries.join(',')}}`);
return configArgs;
}
/**
@@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest';
import { CodexToolHookBridge, isCodexToolHookEvent } from './codexToolHookBridge';
function hook(overrides: Record<string, unknown>): Record<string, unknown> {
return {
session_id: 'session-1',
turn_id: 'turn-1',
cwd: '/tmp/project',
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'git status --short' },
tool_use_id: 'exec-command-1',
...overrides
};
}
describe('CodexToolHookBridge', () => {
it('maps nested Bash hooks to one CodexBash lifecycle', () => {
const bridge = new CodexToolHookBridge();
expect(bridge.handle(hook({}))).toEqual([expect.objectContaining({
type: 'tool-call',
name: 'CodexBash',
callId: 'exec-command-1',
input: {
command: 'git status --short',
cwd: '/tmp/project',
source: 'codex-hook'
}
})]);
expect(bridge.hasCompletedAllObservedNestedTools('turn-1')).toBe(false);
expect(bridge.handle(hook({
hook_event_name: 'PostToolUse',
tool_response: ' M README.md\n'
}))).toEqual([expect.objectContaining({
type: 'tool-call-result',
callId: 'exec-command-1',
output: {
stdout: ' M README.md\n',
stderr: '',
status: 'completed'
}
})]);
expect(bridge.hasObservedNestedTool('turn-1')).toBe(true);
expect(bridge.hasCompletedAllObservedNestedTools('turn-1')).toBe(true);
});
it('maps apply_patch and extracts changed file paths', () => {
const bridge = new CodexToolHookBridge();
const patch = [
'*** Begin Patch',
'*** Update File: src/a.ts',
'@@',
'-old',
'+new',
'*** Add File: src/b.ts',
'+content',
'*** End Patch'
].join('\n');
expect(bridge.handle(hook({
tool_name: 'apply_patch',
tool_input: { command: patch },
tool_use_id: 'exec-patch-1'
}))).toEqual([expect.objectContaining({
type: 'tool-call',
name: 'CodexPatch',
input: expect.objectContaining({
patch,
changes: {
'src/a.ts': { kind: 'update' },
'src/b.ts': { kind: 'add' }
}
})
})]);
});
it('maps MCP hooks using their canonical tool name', () => {
const bridge = new CodexToolHookBridge();
expect(bridge.handle(hook({
tool_name: 'mcp__hapi__change_title',
tool_input: { title: 'New title' },
tool_use_id: 'exec-mcp-1'
}))).toEqual([expect.objectContaining({
type: 'tool-call',
name: 'mcp__hapi__change_title',
input: { title: 'New title' }
})]);
expect(bridge.handle(hook({
hook_event_name: 'PostToolUse',
tool_name: 'mcp__hapi__change_title',
tool_input: { title: 'New title' },
tool_response: { content: [{ type: 'text', text: 'done' }] },
tool_use_id: 'exec-mcp-1'
}))).toEqual([expect.objectContaining({
type: 'tool-call-result',
callId: 'exec-mcp-1',
output: { content: [{ type: 'text', text: 'done' }] }
})]);
});
it('preserves plan and MultiAgent V2 names and inputs', () => {
const bridge = new CodexToolHookBridge();
expect(bridge.handle(hook({
tool_name: 'update_plan',
tool_input: {
explanation: 'Starting implementation',
plan: [{ step: 'Implement', status: 'in_progress' }]
},
tool_use_id: 'exec-plan-1'
}))).toEqual([expect.objectContaining({
type: 'tool-call',
name: 'update_plan',
input: {
explanation: 'Starting implementation',
plan: [{ step: 'Implement', status: 'in_progress' }]
}
})]);
expect(bridge.handle(hook({
tool_name: 'followup_task',
tool_input: { target: '/root/review', message: 'Run tests' },
tool_use_id: 'exec-agent-1'
}))).toEqual([expect.objectContaining({
type: 'tool-call',
name: 'followup_task',
input: { target: '/root/review', message: 'Run tests' }
})]);
});
it('maps dynamically registered Code Mode tools', () => {
const bridge = new CodexToolHookBridge();
expect(bridge.handle(hook({
tool_name: 'view_image',
tool_input: { path: '/tmp/result.png' },
tool_use_id: 'exec-image-1'
}))).toEqual([expect.objectContaining({
type: 'tool-call',
name: 'view_image',
input: { path: '/tmp/result.png' }
})]);
});
it('waits for every runtime call produced by a loop before covering its wrapper', () => {
const bridge = new CodexToolHookBridge();
bridge.handle(hook({ tool_use_id: 'exec-loop-1' }));
bridge.handle(hook({ tool_use_id: 'exec-loop-2' }));
bridge.handle(hook({
hook_event_name: 'PostToolUse',
tool_response: 'first',
tool_use_id: 'exec-loop-1'
}));
expect(bridge.hasCompletedAllObservedNestedTools('turn-1')).toBe(false);
bridge.handle(hook({
hook_event_name: 'PostToolUse',
tool_response: 'second',
tool_use_id: 'exec-loop-2'
}));
expect(bridge.hasCompletedAllObservedNestedTools('turn-1')).toBe(true);
});
it('ignores direct tools and subagent hooks to avoid duplicate cards', () => {
const bridge = new CodexToolHookBridge();
expect(bridge.handle(hook({ tool_use_id: 'call-direct-1' }))).toEqual([]);
expect(bridge.handle(hook({ agent_id: 'child-1' }))).toEqual([]);
expect(bridge.hasObservedNestedTool('turn-1')).toBe(false);
});
it('synthesizes a begin event when PostToolUse arrives first', () => {
const bridge = new CodexToolHookBridge();
const messages = bridge.handle(hook({
hook_event_name: 'PostToolUse',
tool_response: 'done'
}));
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({ type: 'tool-call', name: 'CodexBash' });
expect(messages[1]).toMatchObject({ type: 'tool-call-result', output: { stdout: 'done' } });
});
it('closes pending cards when the bridge shuts down', () => {
const bridge = new CodexToolHookBridge();
bridge.handle(hook({}));
expect(bridge.finish()).toEqual([expect.objectContaining({
type: 'tool-call-result',
callId: 'exec-command-1',
is_error: true,
output: expect.objectContaining({ status: 'incomplete' })
})]);
expect(bridge.finish()).toEqual([]);
});
it('closes only the unfinished cards from a completed turn', () => {
const bridge = new CodexToolHookBridge();
bridge.handle(hook({ tool_use_id: 'exec-turn-1' }));
bridge.handle(hook({ turn_id: 'turn-2', tool_use_id: 'exec-turn-2' }));
expect(bridge.finishTurn('turn-1')).toEqual([expect.objectContaining({
type: 'tool-call-result',
callId: 'exec-turn-1',
is_error: true
})]);
expect(bridge.finish()).toEqual([expect.objectContaining({
callId: 'exec-turn-2'
})]);
});
});
describe('isCodexToolHookEvent', () => {
it('recognizes PreToolUse and PostToolUse events', () => {
expect(isCodexToolHookEvent({ hook_event_name: 'PreToolUse' })).toBe(true);
expect(isCodexToolHookEvent({ hook_event_name: 'PostToolUse' })).toBe(true);
expect(isCodexToolHookEvent({ hook_event_name: 'SessionStart' })).toBe(false);
});
});
+239
View File
@@ -0,0 +1,239 @@
import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import type { CodexMessage } from './codexEventConverter';
const CodexToolHookSchema = z.object({
hook_event_name: z.enum(['PreToolUse', 'PostToolUse']),
turn_id: z.string().min(1),
tool_name: z.string().min(1),
tool_input: z.unknown(),
tool_response: z.unknown().optional(),
tool_use_id: z.string().min(1),
cwd: z.string().optional(),
agent_id: z.string().optional()
}).passthrough();
type CodexToolHook = z.infer<typeof CodexToolHookSchema>;
type PendingToolCall = {
displayName: string;
input: unknown;
turnId: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function isCodeModeToolUseId(toolUseId: string): boolean {
return toolUseId.startsWith('exec-');
}
function extractPatchChanges(patch: string): Record<string, { kind: string }> {
const changes: Record<string, { kind: string }> = {};
const fileHeader = /^\*\*\* (Add|Update|Delete) File: (.+)$/gm;
for (const match of patch.matchAll(fileHeader)) {
const operation = match[1];
const path = match[2]?.trim();
if (operation && path) {
changes[path] = { kind: operation.toLowerCase() };
}
}
return changes;
}
function toolCallFromHook(hook: CodexToolHook): { displayName: string; input: unknown } | null {
const input = asRecord(hook.tool_input) ?? {};
if (hook.tool_name === 'Bash') {
const command = asString(input.command);
if (!command) return null;
return {
displayName: 'CodexBash',
input: {
command,
...(hook.cwd ? { cwd: hook.cwd } : {}),
source: 'codex-hook'
}
};
}
if (hook.tool_name === 'apply_patch') {
const patch = asString(input.command);
if (!patch) return null;
return {
displayName: 'CodexPatch',
input: {
patch,
changes: extractPatchChanges(patch),
source: 'codex-hook'
}
};
}
return {
displayName: hook.tool_name,
input: hook.tool_input
};
}
function responseText(value: unknown): string {
if (typeof value === 'string') return value;
if (value === undefined || value === null) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function toolResultFromHook(displayName: string, response: unknown): { output: unknown; isError: boolean } {
if (displayName === 'CodexBash') {
return {
output: {
stdout: responseText(response),
stderr: '',
status: 'completed'
},
isError: false
};
}
if (displayName === 'CodexPatch') {
return {
output: response,
isError: false
};
}
const responseRecord = asRecord(response);
return {
output: response,
isError: responseRecord?.isError === true || responseRecord?.is_error === true
};
}
function incompleteToolResult(callId: string, pending: PendingToolCall): CodexMessage {
const reason = 'Codex ended before the PostToolUse hook returned a result.';
const output = pending.displayName === 'CodexBash'
? { stdout: '', stderr: reason, status: 'incomplete' }
: { error: reason };
return {
type: 'tool-call-result',
callId,
output,
is_error: true,
id: randomUUID()
};
}
export function isCodexToolHookEvent(data: Record<string, unknown>): boolean {
return data.hook_event_name === 'PreToolUse' || data.hook_event_name === 'PostToolUse';
}
export class CodexToolHookBridge {
private readonly pending = new Map<string, PendingToolCall>();
private readonly observedCallIdsByTurn = new Map<string, Set<string>>();
private readonly completedCallIdsByTurn = new Map<string, Set<string>>();
private recordCall(map: Map<string, Set<string>>, turnId: string, callId: string): void {
const callIds = map.get(turnId) ?? new Set<string>();
callIds.add(callId);
map.set(turnId, callIds);
}
handle(rawHook: Record<string, unknown>): CodexMessage[] {
const parsed = CodexToolHookSchema.safeParse(rawHook);
if (!parsed.success) return [];
const hook = parsed.data;
if (hook.agent_id || !isCodeModeToolUseId(hook.tool_use_id)) {
return [];
}
const toolCall = toolCallFromHook(hook);
if (!toolCall) return [];
this.recordCall(this.observedCallIdsByTurn, hook.turn_id, hook.tool_use_id);
if (hook.hook_event_name === 'PreToolUse') {
if (this.pending.has(hook.tool_use_id)) return [];
this.pending.set(hook.tool_use_id, { ...toolCall, turnId: hook.turn_id });
return [{
type: 'tool-call',
name: toolCall.displayName,
callId: hook.tool_use_id,
input: toolCall.input,
id: randomUUID()
}];
}
const pending = this.pending.get(hook.tool_use_id) ?? { ...toolCall, turnId: hook.turn_id };
const messages: CodexMessage[] = [];
if (!this.pending.has(hook.tool_use_id)) {
messages.push({
type: 'tool-call',
name: pending.displayName,
callId: hook.tool_use_id,
input: pending.input,
id: randomUUID()
});
}
const result = toolResultFromHook(pending.displayName, hook.tool_response);
messages.push({
type: 'tool-call-result',
callId: hook.tool_use_id,
output: result.output,
...(result.isError ? { is_error: true } : {}),
id: randomUUID()
});
this.pending.delete(hook.tool_use_id);
this.recordCall(this.completedCallIdsByTurn, hook.turn_id, hook.tool_use_id);
return messages;
}
hasObservedNestedTool(turnId: string | undefined): boolean {
return Boolean(turnId && (this.observedCallIdsByTurn.get(turnId)?.size ?? 0) > 0);
}
hasCompletedAllObservedNestedTools(turnId: string | undefined): boolean {
if (!turnId) return false;
const observed = this.observedCallIdsByTurn.get(turnId);
const completed = this.completedCallIdsByTurn.get(turnId);
if (!observed || observed.size === 0 || !completed || completed.size !== observed.size) {
return false;
}
return Array.from(observed).every((callId) => completed.has(callId));
}
finishTurn(turnId: string): CodexMessage[] {
const messages: CodexMessage[] = [];
for (const [callId, pending] of this.pending) {
if (pending.turnId !== turnId) continue;
messages.push(incompleteToolResult(callId, pending));
this.pending.delete(callId);
}
this.observedCallIdsByTurn.delete(turnId);
this.completedCallIdsByTurn.delete(turnId);
return messages;
}
finish(): CodexMessage[] {
const messages = Array.from(this.pending, ([callId, pending]) => incompleteToolResult(callId, pending));
this.pending.clear();
this.observedCallIdsByTurn.clear();
this.completedCallIdsByTurn.clear();
return messages;
}
}
@@ -0,0 +1,3 @@
// Code Mode dynamically exposes every non-hidden executor. Match the same open
// tool set here; the bridge filters direct calls by their non-exec call ids.
export const CODEX_TOOL_HOOK_MATCHER = '*';
+11 -11
View File
@@ -45,16 +45,16 @@ describe('codexVersion', () => {
describe('isCodexVersionAtLeast', () => {
it('accepts the minimum supported version', () => {
expect(isCodexVersionAtLeast('0.124.0', MIN_CODEX_HOOKS_VERSION)).toBe(true)
expect(isCodexVersionAtLeast('0.145.0', MIN_CODEX_HOOKS_VERSION)).toBe(true)
})
it('accepts newer patch and minor versions', () => {
expect(isCodexVersionAtLeast('0.124.1', MIN_CODEX_HOOKS_VERSION)).toBe(true)
expect(isCodexVersionAtLeast('0.125.0', MIN_CODEX_HOOKS_VERSION)).toBe(true)
expect(isCodexVersionAtLeast('0.145.1', MIN_CODEX_HOOKS_VERSION)).toBe(true)
expect(isCodexVersionAtLeast('0.146.0', MIN_CODEX_HOOKS_VERSION)).toBe(true)
})
it('rejects older versions', () => {
expect(isCodexVersionAtLeast('0.123.9', MIN_CODEX_HOOKS_VERSION)).toBe(false)
expect(isCodexVersionAtLeast('0.144.9', MIN_CODEX_HOOKS_VERSION)).toBe(false)
})
})
@@ -66,7 +66,7 @@ describe('codexVersion', () => {
})
spawnSyncMock.mockReturnValueOnce({
status: 0,
stdout: 'codex-cli 0.124.0\n',
stdout: 'codex-cli 0.145.0\n',
stderr: ''
})
@@ -84,7 +84,7 @@ describe('codexVersion', () => {
it('passes when codex is new enough', () => {
spawnSyncMock.mockReturnValueOnce({
status: 0,
stdout: 'codex-cli 0.124.0\n',
stdout: 'codex-cli 0.145.0\n',
stderr: ''
})
@@ -94,12 +94,12 @@ describe('codexVersion', () => {
it('fails when codex is too old', () => {
spawnSyncMock.mockReturnValueOnce({
status: 0,
stdout: 'codex-cli 0.123.9\n',
stdout: 'codex-cli 0.144.9\n',
stderr: ''
})
expect(() => assertCodexLocalSupported()).toThrow(
'Codex CLI 0.124.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Detected: 0.123.9. Please upgrade Codex and retry.'
'Codex CLI 0.145.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Detected: 0.144.9. Please upgrade Codex and retry.'
)
})
@@ -111,7 +111,7 @@ describe('codexVersion', () => {
})
expect(() => assertCodexLocalSupported()).toThrow(
'Could not determine Codex CLI version. Codex CLI 0.124.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Please upgrade Codex and retry.'
'Could not determine Codex CLI version. Codex CLI 0.145.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Please upgrade Codex and retry.'
)
})
@@ -126,7 +126,7 @@ describe('codexVersion', () => {
})
expect(() => assertCodexLocalSupported()).toThrow(
'Codex CLI 0.124.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Codex was not found on PATH. Please install or upgrade Codex and retry.'
'Codex CLI 0.145.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Codex was not found on PATH. Please install or upgrade Codex and retry.'
)
})
@@ -138,7 +138,7 @@ describe('codexVersion', () => {
})
expect(() => assertCodexLocalSupported()).toThrow(
'Could not determine Codex CLI version. codex failed Codex CLI 0.124.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Please upgrade Codex and retry.'
'Could not determine Codex CLI version. codex failed Codex CLI 0.145.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Please upgrade Codex and retry.'
)
})
})
+1 -1
View File
@@ -2,7 +2,7 @@ import spawn from 'cross-spawn'
import { withBunRuntimeEnv } from '@/utils/bunRuntime'
import { resolveCodexCommand } from './codexExecutable'
export const MIN_CODEX_HOOKS_VERSION = '0.124.0'
export const MIN_CODEX_HOOKS_VERSION = '0.145.0'
export const CODEX_VERSION_TIMEOUT_MS = 3_000
const SEMVER_PATTERN = /\b(\d+)\.(\d+)\.(\d+)\b/