From 74ad25ec570337063eff03740bce59bbdd67ec49 Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 24 Jul 2026 09:37:41 +0800 Subject: [PATCH] feat(codex): refine tool activity display --- cli/src/claude/utils/startHookServer.ts | 26 +- cli/src/codex/codexLocal.test.ts | 2 + cli/src/codex/codexLocal.ts | 4 +- cli/src/codex/codexLocalLauncher.test.ts | 119 +++++++++ cli/src/codex/codexLocalLauncher.ts | 67 +++++ cli/src/codex/codexRemoteLauncher.test.ts | 82 ++++++ cli/src/codex/codexRemoteLauncher.ts | 46 ++-- .../utils/appServerEventConverter.test.ts | 139 ++++++++++ .../codex/utils/appServerEventConverter.ts | 109 +++++++- .../utils/appServerWrappedEvents.test.ts | 131 ++++++++++ .../codex/utils/codexEventConverter.test.ts | 17 ++ cli/src/codex/utils/codexEventConverter.ts | 10 + cli/src/codex/utils/codexExecWrapper.test.ts | 44 ++++ cli/src/codex/utils/codexExecWrapper.ts | 29 +++ cli/src/codex/utils/codexMcpConfig.test.ts | 20 +- cli/src/codex/utils/codexMcpConfig.ts | 41 ++- .../codex/utils/codexToolHookBridge.test.ts | 224 ++++++++++++++++ cli/src/codex/utils/codexToolHookBridge.ts | 239 ++++++++++++++++++ cli/src/codex/utils/codexToolHookNames.ts | 3 + cli/src/codex/utils/codexVersion.test.ts | 22 +- cli/src/codex/utils/codexVersion.ts | 2 +- web/src/chat/toolGroups.ts | 6 +- web/src/components/ToolCard/codexAgents.ts | 120 +++++++-- .../components/ToolCard/knownTools.test.tsx | 82 ++++++ web/src/components/ToolCard/knownTools.tsx | 36 +++ web/src/components/ToolCard/views/_all.tsx | 8 + .../ToolCard/views/_results.test.tsx | 3 + .../components/ToolCard/views/_results.tsx | 45 +++- 28 files changed, 1591 insertions(+), 85 deletions(-) create mode 100644 cli/src/codex/utils/appServerWrappedEvents.test.ts create mode 100644 cli/src/codex/utils/codexExecWrapper.test.ts create mode 100644 cli/src/codex/utils/codexExecWrapper.ts create mode 100644 cli/src/codex/utils/codexToolHookBridge.test.ts create mode 100644 cli/src/codex/utils/codexToolHookBridge.ts create mode 100644 cli/src/codex/utils/codexToolHookNames.ts diff --git a/cli/src/claude/utils/startHookServer.ts b/cli/src/claude/utils/startHookServer.ts index 096ac6f1..5e910500 100644 --- a/cli/src/claude/utils/startHookServer.ts +++ b/cli/src/claude/utils/startHookServer.ts @@ -1,8 +1,7 @@ /** - * Dedicated HTTP server for receiving Claude session hooks. + * Dedicated loopback HTTP server for receiving agent lifecycle hooks. * - * This server receives notifications from Claude when sessions change - * (new session, resume, compact, fork, etc.) via the SessionStart hook. + * Claude uses it for SessionStart; Codex also forwards selected tool hooks. */ import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http'; @@ -87,8 +86,6 @@ export async function startHookServer(options: HookServerOptions): Promise { - try { - onSessionHook(sessionId, data); - } catch (error) { - logger.debug('[hookServer] Error dispatching session hook:', error); - } - }); } catch (error) { clearTimeout(timeout); if (timedOut) { diff --git a/cli/src/codex/codexLocal.test.ts b/cli/src/codex/codexLocal.test.ts index a51d01cc..989581fe 100644 --- a/cli/src/codex/codexLocal.test.ts +++ b/cli/src/codex/codexLocal.test.ts @@ -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"'); }); diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index e60e3a87..92bab0f9 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -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) diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 088ec696..c6dafbfd 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -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; diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index bce20442..ae37bb90 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -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; +type ToolCallMessage = Extract; + +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(); + const pendingExecWrappers = new Map(); + 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) { diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index ed6e5026..3ee5c686 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -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; diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 849298f8..c39b208f 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -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; } diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 31e4846e..a3c0bc48 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -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(); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index e83253c5..ea8abe95 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -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(); private readonly lastReasoningDeltaByItemId = new Map(); private readonly lastCommandOutputDeltaByItemId = new Map(); + private readonly rawAgentToolCallIds = new Set(); + private readonly rawAgentToolNames = new Map(); private handleWrappedCodexEvent(paramsRecord: Record): 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(); } } diff --git a/cli/src/codex/utils/appServerWrappedEvents.test.ts b/cli/src/codex/utils/appServerWrappedEvents.test.ts new file mode 100644 index 00000000..f3c5c2b2 --- /dev/null +++ b/cli/src/codex/utils/appServerWrappedEvents.test.ts @@ -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(); + }); +}); diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts index c1f42c1f..4add86df 100644 --- a/cli/src/codex/utils/codexEventConverter.test.ts +++ b/cli/src/codex/utils/codexEventConverter.test.ts @@ -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', diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index 70dbc427..2d10397c 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -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 | null { return null; } +function extractResponseItemTurnId(payload: Record): 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, diff --git a/cli/src/codex/utils/codexExecWrapper.test.ts b/cli/src/codex/utils/codexExecWrapper.test.ts new file mode 100644 index 00000000..7115a43d --- /dev/null +++ b/cli/src/codex/utils/codexExecWrapper.test.ts @@ -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); + }); +}); diff --git a/cli/src/codex/utils/codexExecWrapper.ts b/cli/src/codex/utils/codexExecWrapper.ts new file mode 100644 index 00000000..ec90c95c --- /dev/null +++ b/cli/src/codex/utils/codexExecWrapper.ts @@ -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; +} diff --git a/cli/src/codex/utils/codexMcpConfig.test.ts b/cli/src/codex/utils/codexMcpConfig.test.ts index 298f2dbd..1513f17a 100644 --- a/cli/src/codex/utils/codexMcpConfig.test.ts +++ b/cli/src/codex/utils/codexMcpConfig.test.ts @@ -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); }); }); }); diff --git a/cli/src/codex/utils/codexMcpConfig.ts b/cli/src/codex/utils/codexMcpConfig.ts index 367bee1a..aad4bffb 100644 --- a/cli/src/codex/utils/codexMcpConfig.ts +++ b/cli/src/codex/utils/codexMcpConfig.ts @@ -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:\\\\config.toml' : '//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; } /** diff --git a/cli/src/codex/utils/codexToolHookBridge.test.ts b/cli/src/codex/utils/codexToolHookBridge.test.ts new file mode 100644 index 00000000..6144af7e --- /dev/null +++ b/cli/src/codex/utils/codexToolHookBridge.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import { CodexToolHookBridge, isCodexToolHookEvent } from './codexToolHookBridge'; + +function hook(overrides: Record): Record { + 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); + }); +}); diff --git a/cli/src/codex/utils/codexToolHookBridge.ts b/cli/src/codex/utils/codexToolHookBridge.ts new file mode 100644 index 00000000..69b66839 --- /dev/null +++ b/cli/src/codex/utils/codexToolHookBridge.ts @@ -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; + +type PendingToolCall = { + displayName: string; + input: unknown; + turnId: string; +}; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as Record; +} + +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 { + const changes: Record = {}; + 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): boolean { + return data.hook_event_name === 'PreToolUse' || data.hook_event_name === 'PostToolUse'; +} + +export class CodexToolHookBridge { + private readonly pending = new Map(); + private readonly observedCallIdsByTurn = new Map>(); + private readonly completedCallIdsByTurn = new Map>(); + + private recordCall(map: Map>, turnId: string, callId: string): void { + const callIds = map.get(turnId) ?? new Set(); + callIds.add(callId); + map.set(turnId, callIds); + } + + handle(rawHook: Record): 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; + } +} diff --git a/cli/src/codex/utils/codexToolHookNames.ts b/cli/src/codex/utils/codexToolHookNames.ts new file mode 100644 index 00000000..fe87a3a2 --- /dev/null +++ b/cli/src/codex/utils/codexToolHookNames.ts @@ -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 = '*'; diff --git a/cli/src/codex/utils/codexVersion.test.ts b/cli/src/codex/utils/codexVersion.test.ts index 47efcbbd..4d945b86 100644 --- a/cli/src/codex/utils/codexVersion.test.ts +++ b/cli/src/codex/utils/codexVersion.test.ts @@ -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.' ) }) }) diff --git a/cli/src/codex/utils/codexVersion.ts b/cli/src/codex/utils/codexVersion.ts index deeb5b0b..0afd2e44 100644 --- a/cli/src/codex/utils/codexVersion.ts +++ b/cli/src/codex/utils/codexVersion.ts @@ -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/ diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index 1fe5dd78..56f3de2e 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -59,9 +59,13 @@ const MILESTONE_TOOL_NAMES = new Set([ 'Skill', 'spawn_agent', 'send_input', + 'send_message', 'resume_agent', + 'followup_task', 'wait_agent', - 'close_agent' + 'close_agent', + 'interrupt_agent', + 'list_agents' ]) const INTERACTIVE_TOOL_NAMES = new Set([ diff --git a/web/src/components/ToolCard/codexAgents.ts b/web/src/components/ToolCard/codexAgents.ts index 10e2ae96..ba1726f6 100644 --- a/web/src/components/ToolCard/codexAgents.ts +++ b/web/src/components/ToolCard/codexAgents.ts @@ -4,9 +4,13 @@ import { getInputStringAny, truncate } from '@/lib/toolInputUtils' export const codexAgentToolNames = [ 'spawn_agent', 'send_input', + 'send_message', 'resume_agent', + 'followup_task', 'wait_agent', - 'close_agent' + 'close_agent', + 'interrupt_agent', + 'list_agents' ] as const export type CodexAgentToolName = typeof codexAgentToolNames[number] @@ -200,6 +204,9 @@ export function getCodexAgentFieldRows(toolName: string, input: unknown): Array< const timeout = typeof input.timeout_ms === 'number' ? `${input.timeout_ms} ms` : null if (timeout) rows.push({ label: 'Timeout', value: timeout }) + + const pathPrefix = asNonEmptyString(input.path_prefix) + if (pathPrefix) rows.push({ label: 'Path prefix', value: pathPrefix }) } const targets = getCodexAgentTargets(input) @@ -216,6 +223,7 @@ export function getCodexAgentFieldRows(toolName: string, input: unknown): Array< export type CodexSpawnAgentResult = { agentId: string | null nickname: string | null + taskName: string | null } export function parseCodexSpawnAgentResult(result: unknown): CodexSpawnAgentResult | null { @@ -224,9 +232,10 @@ export function parseCodexSpawnAgentResult(result: unknown): CodexSpawnAgentResu const agentId = asNonEmptyString(obj.agent_id) ?? asNonEmptyString(obj.agentId) ?? asNonEmptyString(obj.id) const nickname = asNonEmptyString(obj.nickname) ?? asNonEmptyString(obj.name) + const taskName = asNonEmptyString(obj.task_name) ?? asNonEmptyString(obj.taskName) - if (!agentId && !nickname) return null - return { agentId, nickname } + if (!agentId && !nickname && !taskName) return null + return { agentId, nickname, taskName } } export type CodexAgentStatus = { @@ -236,10 +245,12 @@ export type CodexAgentStatus = { } function extractStatusText(value: unknown): string | null { - if (typeof value === 'string') return value + if (typeof value === 'string') { + return normalizeStatusState(value) ? null : value + } if (!isObject(value)) return null - const candidates = ['completed', 'failed', 'error', 'message', 'output', 'text', 'reason'] + const candidates = ['completed', 'errored', 'failed', 'error', 'message', 'output', 'text', 'reason'] for (const key of candidates) { const candidate = value[key] if (typeof candidate === 'string') return candidate @@ -248,19 +259,51 @@ function extractStatusText(value: unknown): string | null { return safeStringify(value) } +const CODEX_AGENT_STATUS_STATES = new Set([ + 'completed', + 'errored', + 'failed', + 'error', + 'canceled', + 'cancelled', + 'killed', + 'running', + 'pending', + 'pending_init', + 'interrupted', + 'shutdown', + 'not_found' +]) + +function normalizeStatusState(value: string): string | null { + const state = value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase() + .replace(/[\s-]+/g, '_') + if (!CODEX_AGENT_STATUS_STATES.has(state)) return null + if (state === 'cancelled') return 'canceled' + if (state === 'errored') return 'error' + return state +} + function extractStatusState(value: unknown): string { + if (typeof value === 'string') return normalizeStatusState(value) ?? 'completed' if (!isObject(value)) return 'completed' - const states = ['completed', 'failed', 'error', 'canceled', 'cancelled', 'killed', 'running', 'pending'] - for (const state of states) { - if (state in value) return state === 'cancelled' ? 'canceled' : state + for (const state of CODEX_AGENT_STATUS_STATES) { + if (state in value) return normalizeStatusState(state) ?? state } const status = asNonEmptyString(value.status) ?? asNonEmptyString(value.state) - return status ?? 'completed' + return status ? normalizeStatusState(status) ?? status : 'completed' } -export function parseCodexWaitAgentResult(result: unknown): { statuses: CodexAgentStatus[]; timedOut: boolean | null } | null { +export function parseCodexWaitAgentResult(result: unknown): { + statuses: CodexAgentStatus[] + timedOut: boolean | null + message: string | null +} | null { const obj = parseMaybeJsonObject(result) if (!obj) return null @@ -282,19 +325,22 @@ export function parseCodexWaitAgentResult(result: unknown): { statuses: CodexAge : typeof obj.timedOut === 'boolean' ? obj.timedOut : null + const message = asNonEmptyString(obj.message) - if (statuses.length === 0 && timedOut === null) return null - return { statuses, timedOut } + if (statuses.length === 0 && timedOut === null && !message) return null + return { statuses, timedOut, message } } export function parseCodexCloseAgentResult(result: unknown): CodexAgentStatus | null { const obj = parseMaybeJsonObject(result) if (!obj) return null - const previousStatus = isObject(obj.previous_status) ? obj.previous_status - : isObject(obj.previousStatus) ? obj.previousStatus + const previousStatus = Object.prototype.hasOwnProperty.call(obj, 'previous_status') + ? obj.previous_status + : Object.prototype.hasOwnProperty.call(obj, 'previousStatus') + ? obj.previousStatus : null - if (!previousStatus) return null + if (previousStatus === null || previousStatus === undefined) return null return { agentId: '', @@ -303,13 +349,36 @@ export function parseCodexCloseAgentResult(result: unknown): CodexAgentStatus | } } +export function parseCodexListAgentsResult(result: unknown): CodexAgentStatus[] | null { + const obj = parseMaybeJsonObject(result) + if (!obj || !Array.isArray(obj.agents)) return null + + const agents = obj.agents.flatMap((value): CodexAgentStatus[] => { + if (!isObject(value)) return [] + const agentId = asNonEmptyString(value.agent_name) + ?? asNonEmptyString(value.agentName) + ?? asNonEmptyString(value.agent_id) + ?? asNonEmptyString(value.agentId) + if (!agentId) return [] + const status = value.agent_status ?? value.agentStatus ?? value.status + return [{ + agentId, + state: extractStatusState(status), + text: extractStatusText(status) + }] + }) + + return agents +} + export function summarizeCodexAgentResult(toolName: string, result: unknown): string | null { if (toolName === 'spawn_agent') { const parsed = parseCodexSpawnAgentResult(result) if (!parsed) return null - const label = parsed.nickname && parsed.agentId - ? `${parsed.nickname} (${parsed.agentId})` - : parsed.nickname ?? parsed.agentId + const reference = parsed.taskName ?? parsed.agentId + const label = parsed.nickname && reference + ? `${parsed.nickname} (${reference})` + : parsed.nickname ?? reference return label ? `Launched ${label}` : 'Agent launched' } @@ -317,6 +386,7 @@ export function summarizeCodexAgentResult(toolName: string, result: unknown): st const parsed = parseCodexWaitAgentResult(result) if (!parsed) return null if (parsed.statuses.length === 0 && parsed.timedOut) return 'Timed out' + if (parsed.statuses.length === 0 && parsed.message) return parsed.message const completed = parsed.statuses.filter((status) => status.state === 'completed').length const failed = parsed.statuses.filter((status) => status.state !== 'completed').length const parts = [] @@ -326,10 +396,20 @@ export function summarizeCodexAgentResult(toolName: string, result: unknown): st return parts.length > 0 ? parts.join(', ') : 'No agent status yet' } - if (toolName === 'close_agent') { + if (toolName === 'close_agent' || toolName === 'interrupt_agent') { const parsed = parseCodexCloseAgentResult(result) if (!parsed) return null - return `Closed (${parsed.state})` + return `${toolName === 'interrupt_agent' ? 'Interrupted' : 'Closed'} (${parsed.state})` + } + + if (toolName === 'list_agents') { + const agents = parseCodexListAgentsResult(result) + if (!agents) return null + if (agents.length === 0) return 'No live agents' + const running = agents.filter((agent) => agent.state === 'running').length + return running > 0 + ? `${agents.length} live, ${running} running` + : `${agents.length} live agent${agents.length === 1 ? '' : 's'}` } return null diff --git a/web/src/components/ToolCard/knownTools.test.tsx b/web/src/components/ToolCard/knownTools.test.tsx index b7165a40..a2f51f4b 100644 --- a/web/src/components/ToolCard/knownTools.test.tsx +++ b/web/src/components/ToolCard/knownTools.test.tsx @@ -214,6 +214,88 @@ describe('getToolPresentation — Codex agent tools', () => { expect(presentation.subtitle).not.toContain('hidden child output') expect(presentation.minimal).toBe(true) }) + + it('presents MultiAgent V2 messaging tools by intent', () => { + const message = getToolPresentation({ + toolName: 'send_message', + input: { target: '/root/review', message: 'Status?' }, + result: '', + childrenCount: 0, + description: null, + metadata: null, + }) + const followup = getToolPresentation({ + toolName: 'followup_task', + input: { target: '/root/review', message: 'Run tests' }, + result: '', + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(message).toMatchObject({ title: 'Message agent', subtitle: '/root/review', minimal: true }) + expect(followup).toMatchObject({ title: 'Follow up agent', subtitle: '/root/review', minimal: true }) + }) + + it('summarizes list_agents and interrupt_agent results', () => { + const list = getToolPresentation({ + toolName: 'list_agents', + input: {}, + result: JSON.stringify({ + agents: [ + { agent_name: '/root/a', agent_status: 'running' }, + { agent_name: '/root/b', agent_status: { completed: 'done' } } + ] + }), + childrenCount: 0, + description: null, + metadata: null, + }) + const interrupt = getToolPresentation({ + toolName: 'interrupt_agent', + input: { target: '/root/a' }, + result: '{"previous_status":"running"}', + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(list).toMatchObject({ title: 'List agents', subtitle: '2 live, 1 running' }) + expect(interrupt).toMatchObject({ title: 'Interrupt agent', subtitle: 'Interrupted (running)' }) + }) + + it('uses MultiAgent V2 result fields and status variants', () => { + const spawn = getToolPresentation({ + toolName: 'spawn_agent', + input: { task_name: 'review' }, + result: JSON.stringify({ task_name: '/root/review', nickname: 'Reviewer' }), + childrenCount: 0, + description: null, + metadata: null, + }) + const wait = getToolPresentation({ + toolName: 'wait_agent', + input: { timeout_ms: 1000 }, + result: JSON.stringify({ message: 'Wait completed.', timed_out: false }), + childrenCount: 0, + description: null, + metadata: null, + }) + const list = getToolPresentation({ + toolName: 'list_agents', + input: {}, + result: JSON.stringify({ + agents: [{ agent_name: '/root/review', agent_status: { errored: 'test failed' } }] + }), + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(spawn.subtitle).toBe('Launched Reviewer (/root/review)') + expect(wait.subtitle).toBe('Wait completed.') + expect(list.subtitle).toBe('1 live agent') + }) }) describe('getToolPresentation — native titles', () => { diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 80968e87..763ccbaa 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -355,6 +355,15 @@ export const knownTools: Record , + title: () => 'Message agent', + subtitle: (opts) => { + const targets = getCodexAgentTargets(opts.input) + return targets.length > 0 ? targets.join(', ') : 'Queued message' + }, + minimal: true + }, resume_agent: { icon: () => , title: () => 'Resume agent', @@ -364,6 +373,15 @@ export const knownTools: Record , + title: () => 'Follow up agent', + subtitle: (opts) => { + const targets = getCodexAgentTargets(opts.input) + return targets.length > 0 ? targets.join(', ') : null + }, + minimal: true + }, wait_agent: { icon: () => , title: (opts) => { @@ -389,6 +407,24 @@ export const knownTools: Record , + title: () => 'Interrupt agent', + subtitle: (opts) => { + const summary = summarizeCodexAgentResult(opts.toolName, opts.result) + if (summary) return summary + const targets = getCodexAgentTargets(opts.input) + return targets.length > 0 ? targets.join(', ') : null + }, + minimal: true + }, + list_agents: { + icon: () => , + title: () => 'List agents', + subtitle: (opts) => summarizeCodexAgentResult(opts.toolName, opts.result) + ?? getInputStringAny(opts.input, ['path_prefix']), + minimal: true + }, CodexReasoning: { icon: () => , title: (opts) => getInputStringAny(opts.input, ['title']) ?? 'Reasoning', diff --git a/web/src/components/ToolCard/views/_all.tsx b/web/src/components/ToolCard/views/_all.tsx index e3c04d01..72076192 100644 --- a/web/src/components/ToolCard/views/_all.tsx +++ b/web/src/components/ToolCard/views/_all.tsx @@ -84,9 +84,13 @@ export const toolViewRegistry: Record = { CodexAgent: CodexAgentView, spawn_agent: CodexAgentView, send_input: CodexAgentView, + send_message: CodexAgentView, resume_agent: CodexAgentView, + followup_task: CodexAgentView, wait_agent: CodexAgentView, close_agent: CodexAgentView, + interrupt_agent: CodexAgentView, + list_agents: CodexAgentView, AskUserQuestion: AskUserQuestionView, ExitPlanMode: ExitPlanModeView, CursorAskQuestion: AskUserQuestionView, @@ -106,9 +110,13 @@ export const toolFullViewRegistry: Record = { Skill: SkillFullView, spawn_agent: CodexAgentView, send_input: CodexAgentView, + send_message: CodexAgentView, resume_agent: CodexAgentView, + followup_task: CodexAgentView, wait_agent: CodexAgentView, close_agent: CodexAgentView, + interrupt_agent: CodexAgentView, + list_agents: CodexAgentView, AskUserQuestion: AskUserQuestionView, ExitPlanMode: ExitPlanModeView, CursorAskQuestion: AskUserQuestionView, diff --git a/web/src/components/ToolCard/views/_results.test.tsx b/web/src/components/ToolCard/views/_results.test.tsx index f1add622..265842d4 100644 --- a/web/src/components/ToolCard/views/_results.test.tsx +++ b/web/src/components/ToolCard/views/_results.test.tsx @@ -153,6 +153,9 @@ describe('getToolResultViewComponent registry', () => { it('uses a dedicated result view for Codex agent tools', () => { expect(getToolResultViewComponent('spawn_agent')).not.toBe(getToolResultViewComponent('SomeUnknownTool')) expect(getToolResultViewComponent('wait_agent')).toBe(getToolResultViewComponent('spawn_agent')) + expect(getToolResultViewComponent('followup_task')).toBe(getToolResultViewComponent('spawn_agent')) + expect(getToolResultViewComponent('interrupt_agent')).toBe(getToolResultViewComponent('spawn_agent')) + expect(getToolResultViewComponent('list_agents')).toBe(getToolResultViewComponent('spawn_agent')) }) it('Agent falls back to GenericResultView (no dedicated view — view layer must not filter content)', () => { diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index a78fcbc0..c10d74d7 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -10,6 +10,7 @@ import { getCodexAgentActivity, getCodexAgentTargets, parseCodexCloseAgentResult, + parseCodexListAgentsResult, parseCodexSpawnAgentResult, parseCodexWaitAgentResult } from '@/components/ToolCard/codexAgents' @@ -791,6 +792,7 @@ const CodexAgentResultView: ToolViewComponent = (props: ToolViewProps) => {
{parsed.nickname ? : null} + {parsed.taskName ? : null} {parsed.agentId ? : null} {showDetails ? : null}
@@ -802,7 +804,7 @@ const CodexAgentResultView: ToolViewComponent = (props: ToolViewProps) => { const parsed = parseCodexWaitAgentResult(result) if (parsed) { if (parsed.statuses.length === 0) { - return + return } return ( @@ -840,14 +842,14 @@ const CodexAgentResultView: ToolViewComponent = (props: ToolViewProps) => { } } - if (name === 'close_agent') { + if (name === 'close_agent' || name === 'interrupt_agent') { const parsed = parseCodexCloseAgentResult(result) if (parsed) { const targets = getCodexAgentTargets(input) return (
- + {targets[0] ? : null}
@@ -862,6 +864,39 @@ const CodexAgentResultView: ToolViewComponent = (props: ToolViewProps) => { } } + if (name === 'list_agents') { + const agents = parseCodexListAgentsResult(result) + if (agents) { + return ( +
+
+ + {Object.entries(agents.reduce>((counts, agent) => { + counts[agent.state] = (counts[agent.state] ?? 0) + 1 + return counts + }, {})).map(([status, count]) => ( + + ))} +
+ {showDetails ? ( +
+ {agents.map((agent) => ( +
+
+ + {agent.agentId} +
+ {agent.text ?
{agent.text}
: null} +
+ ))} +
+ ) : null} + {showDetails ? : null} +
+ ) + } + } + const text = extractTextFromResult(result) if (text) { if (!showDetails) { @@ -980,9 +1015,13 @@ export const toolResultViewRegistry: Record = { Skill: SkillResultView, spawn_agent: CodexAgentResultView, send_input: CodexAgentResultView, + send_message: CodexAgentResultView, resume_agent: CodexAgentResultView, + followup_task: CodexAgentResultView, wait_agent: CodexAgentResultView, close_agent: CodexAgentResultView, + interrupt_agent: CodexAgentResultView, + list_agents: CodexAgentResultView, AskUserQuestion: AskUserQuestionResultView, ExitPlanMode: MarkdownResultView, ask_user_question: AskUserQuestionResultView,