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
@@ -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/