mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-13 20:09:55 +00:00
feat(codex): refine tool activity display
This commit is contained in:
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,8 +11,16 @@ import { parseCodexCliOverrides, stripCodexCliOverrides } from './utils/codexCli
|
||||
import { buildCodexPermissionModeCliArgs } from './utils/permissionModeConfig';
|
||||
import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher';
|
||||
import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './utils/codexTranscriptLocator';
|
||||
import { CodexToolHookBridge, isCodexToolHookEvent } from './utils/codexToolHookBridge';
|
||||
import { countHookCoveredExecCalls } from './utils/codexExecWrapper';
|
||||
|
||||
type ProposedPlanMessage = Extract<CodexMessage, { type: 'proposed_plan' }>;
|
||||
type ToolCallMessage = Extract<CodexMessage, { type: 'tool-call' }>;
|
||||
|
||||
type PendingExecWrapper = {
|
||||
message: ToolCallMessage;
|
||||
turnId?: string;
|
||||
};
|
||||
|
||||
export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
|
||||
const resumeSessionId = session.sessionId;
|
||||
@@ -25,6 +33,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
let transcriptLocator: CodexTranscriptLocator | null = null;
|
||||
let scannerTranscriptPath: string | null = null;
|
||||
const pendingPlansByTurnId = new Map<string, ProposedPlanMessage>();
|
||||
const pendingExecWrappers = new Map<string, PendingExecWrapper>();
|
||||
const toolHookBridge = new CodexToolHookBridge();
|
||||
const permissionMode = session.getPermissionMode();
|
||||
const managedPermissionMode = permissionMode === 'read-only' || permissionMode === 'safe-yolo' || permissionMode === 'yolo'
|
||||
? permissionMode
|
||||
@@ -97,6 +107,30 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
}
|
||||
};
|
||||
|
||||
const flushPendingExecWrapper = (callId: string, result?: CodexMessage): void => {
|
||||
const pending = pendingExecWrappers.get(callId);
|
||||
if (!pending) return;
|
||||
pendingExecWrappers.delete(callId);
|
||||
session.sendAgentMessage(pending.message);
|
||||
if (result) {
|
||||
session.sendAgentMessage(result);
|
||||
}
|
||||
};
|
||||
|
||||
const flushAllPendingExecWrappers = (): void => {
|
||||
for (const [callId, pending] of pendingExecWrappers) {
|
||||
session.sendAgentMessage(pending.message);
|
||||
session.sendAgentMessage({
|
||||
type: 'tool-call-result',
|
||||
callId,
|
||||
output: { error: 'Codex ended before the exec wrapper returned a result.' },
|
||||
is_error: true,
|
||||
id: `${pending.message.id}:incomplete`
|
||||
});
|
||||
}
|
||||
pendingExecWrappers.clear();
|
||||
};
|
||||
|
||||
const bindPrimarySession = (sessionId: string, transcriptPath: string, allowSwitch = false): void => {
|
||||
if (primarySessionId && primarySessionId !== sessionId && !allowSwitch) {
|
||||
logger.debug(`[codex-local]: Ignoring non-primary SessionStart hook ${sessionId}; primary is ${primarySessionId}`);
|
||||
@@ -155,12 +189,32 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
if (message.type === 'proposed_plan') {
|
||||
// Codex may complete the Plan item before emitting its final text preface.
|
||||
pendingPlansByTurnId.set(message.turnId, message);
|
||||
} else if (message.type === 'tool-call' && message.name === 'exec') {
|
||||
if (countHookCoveredExecCalls(message.input) === null) {
|
||||
session.sendAgentMessage(message);
|
||||
} else {
|
||||
pendingExecWrappers.set(message.callId, {
|
||||
message,
|
||||
...(converted?.turnId ? { turnId: converted.turnId } : {})
|
||||
});
|
||||
}
|
||||
} else if (message.type === 'tool-call-result' && pendingExecWrappers.has(message.callId)) {
|
||||
const pending = pendingExecWrappers.get(message.callId);
|
||||
const turnId = pending?.turnId ?? converted?.turnId;
|
||||
if (pending && toolHookBridge.hasCompletedAllObservedNestedTools(turnId)) {
|
||||
pendingExecWrappers.delete(message.callId);
|
||||
} else {
|
||||
flushPendingExecWrapper(message.callId, message);
|
||||
}
|
||||
} else {
|
||||
session.sendAgentMessage(message);
|
||||
}
|
||||
}
|
||||
if (converted?.finishedTurnId) {
|
||||
flushPendingPlan(converted.finishedTurnId);
|
||||
for (const message of toolHookBridge.finishTurn(converted.finishedTurnId)) {
|
||||
session.sendAgentMessage(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -217,6 +271,15 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
if (isCodexToolHookEvent(data)) {
|
||||
if (primarySessionId && primarySessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
for (const message of toolHookBridge.handle(data)) {
|
||||
session.sendAgentMessage(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
handleSessionHook(sessionId, data);
|
||||
}
|
||||
});
|
||||
@@ -297,6 +360,10 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
if (activeScanner) {
|
||||
await activeScanner.cleanup();
|
||||
}
|
||||
flushAllPendingExecWrappers();
|
||||
for (const message of toolHookBridge.finish()) {
|
||||
session.sendAgentMessage(message);
|
||||
}
|
||||
flushAllPendingPlans();
|
||||
happyServer.stop();
|
||||
if (!hookReady) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -684,6 +684,20 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
|| toolName === 'close_agent';
|
||||
};
|
||||
|
||||
const isLegacyCodexAgentToolCall = (toolName: string | null, input: unknown): boolean => {
|
||||
if (!isCodexAgentToolName(toolName)) return false;
|
||||
|
||||
const inputRecord = asRecord(input);
|
||||
if (toolName === 'spawn_agent') {
|
||||
return !asString(inputRecord?.task_name ?? inputRecord?.taskName);
|
||||
}
|
||||
if (toolName === 'wait_agent') {
|
||||
return Array.isArray(inputRecord?.targets);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const isTerminalAgentRunStatus = (status: string | null | undefined): boolean => {
|
||||
return status === 'completed'
|
||||
|| status === 'failed'
|
||||
@@ -1707,7 +1721,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
const callId = asString(msg.call_id ?? msg.callId);
|
||||
const name = asString(msg.name);
|
||||
if (callId && name) {
|
||||
if (isCodexAgentToolName(name)) {
|
||||
if (isLegacyCodexAgentToolCall(name, msg.input)) {
|
||||
const error = 'Nested agent calls are disabled for child agents.';
|
||||
runtime.blockedNestedAgent = true;
|
||||
emitAgentRunTraceMessage(agentId, {
|
||||
@@ -2988,20 +3002,20 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
const callId = asString(msg.call_id ?? msg.callId);
|
||||
const name = asString(msg.name);
|
||||
if (callId && name) {
|
||||
if (isCodexAgentToolName(name)) {
|
||||
const input = msg.input ?? {};
|
||||
pendingAgentToolInputByCallId.set(callId, { name, input });
|
||||
if (name === 'spawn_agent') {
|
||||
emitAgentRunStart(callId, input);
|
||||
} else {
|
||||
for (const agentId of extractAgentTargets(input)) {
|
||||
if (!agentCardByAgentId.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
const activity = name === 'wait_agent'
|
||||
? 'Waiting for agent'
|
||||
: name === 'send_input'
|
||||
? 'Sending input'
|
||||
if (isLegacyCodexAgentToolCall(name, msg.input)) {
|
||||
const input = msg.input ?? {};
|
||||
pendingAgentToolInputByCallId.set(callId, { name, input });
|
||||
if (name === 'spawn_agent') {
|
||||
emitAgentRunStart(callId, input);
|
||||
} else {
|
||||
for (const agentId of extractAgentTargets(input)) {
|
||||
if (!agentCardByAgentId.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
const activity = name === 'wait_agent'
|
||||
? 'Waiting for agent'
|
||||
: name === 'send_input'
|
||||
? 'Sending input'
|
||||
: name === 'resume_agent'
|
||||
? 'Resuming agent'
|
||||
: name === 'close_agent'
|
||||
@@ -3030,7 +3044,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
const callId = asString(msg.call_id ?? msg.callId);
|
||||
const name = asString(msg.name) ?? pendingAgentToolInputByCallId.get(callId ?? '')?.name ?? null;
|
||||
if (callId) {
|
||||
if (name && isCodexAgentToolName(name)) {
|
||||
if (name && pendingAgentToolInputByCallId.has(callId)) {
|
||||
handleAgentToolEnd(callId, name, msg.output, Boolean(msg.is_error ?? msg.isError));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -359,6 +359,145 @@ describe('AppServerEventConverter', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
it('maps raw MultiAgent V2 calls without inventing collab tool variants', () => {
|
||||
const converter = new AppServerEventConverter();
|
||||
|
||||
expect(converter.handleNotification('rawResponseItem/completed', {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
item: {
|
||||
type: 'function_call',
|
||||
namespace: 'collaboration',
|
||||
name: 'followup_task',
|
||||
arguments: JSON.stringify({ target: '/root/review', message: 'Continue with tests' }),
|
||||
call_id: 'call-followup'
|
||||
}
|
||||
})).toEqual([{
|
||||
thread_id: 'thread-1',
|
||||
turn_id: 'turn-1',
|
||||
type: 'codex_tool_call_begin',
|
||||
call_id: 'call-followup',
|
||||
name: 'followup_task',
|
||||
input: {
|
||||
message: 'Continue with tests',
|
||||
target: '/root/review'
|
||||
}
|
||||
}]);
|
||||
|
||||
expect(converter.handleNotification('rawResponseItem/completed', {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
item: {
|
||||
type: 'function_call_output',
|
||||
call_id: 'call-followup',
|
||||
output: ''
|
||||
}
|
||||
})).toEqual([{
|
||||
thread_id: 'thread-1',
|
||||
turn_id: 'turn-1',
|
||||
type: 'codex_tool_call_end',
|
||||
call_id: 'call-followup',
|
||||
name: 'followup_task',
|
||||
output: '',
|
||||
is_error: false
|
||||
}]);
|
||||
|
||||
expect(converter.handleNotification('item/started', {
|
||||
item: {
|
||||
id: 'call-followup',
|
||||
type: 'collabAgentToolCall',
|
||||
tool: 'followupTask'
|
||||
}
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps MultiAgent V1 calls on the collab item stream', () => {
|
||||
const converter = new AppServerEventConverter();
|
||||
|
||||
expect(converter.handleNotification('rawResponseItem/completed', {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
item: {
|
||||
type: 'function_call',
|
||||
namespace: 'multi_agent_v1',
|
||||
name: 'spawn_agent',
|
||||
arguments: JSON.stringify({ message: 'Do side work' }),
|
||||
call_id: 'call-v1-spawn'
|
||||
}
|
||||
})).toEqual([]);
|
||||
|
||||
expect(converter.handleNotification('rawResponseItem/completed', {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
item: {
|
||||
type: 'function_call_output',
|
||||
call_id: 'call-v1-spawn',
|
||||
output: '{"agent_id":"agent-1","nickname":null}'
|
||||
}
|
||||
})).toEqual([]);
|
||||
|
||||
expect(converter.handleNotification('item/started', {
|
||||
item: {
|
||||
id: 'call-v1-spawn',
|
||||
type: 'collabAgentToolCall',
|
||||
tool: 'spawnAgent',
|
||||
prompt: 'Do side work'
|
||||
}
|
||||
})).toEqual([expect.objectContaining({
|
||||
type: 'codex_tool_call_begin',
|
||||
call_id: 'call-v1-spawn',
|
||||
name: 'spawn_agent'
|
||||
})]);
|
||||
});
|
||||
|
||||
it('maps shared MultiAgent V2 names only when their V2 argument shape is present', () => {
|
||||
const converter = new AppServerEventConverter();
|
||||
|
||||
expect(converter.handleNotification('rawResponseItem/completed', {
|
||||
item: {
|
||||
type: 'function_call',
|
||||
namespace: 'collaboration',
|
||||
name: 'spawn_agent',
|
||||
arguments: JSON.stringify({ task_name: 'review', message: 'Review this' }),
|
||||
call_id: 'call-v2-spawn'
|
||||
}
|
||||
})).toEqual([expect.objectContaining({
|
||||
type: 'codex_tool_call_begin',
|
||||
call_id: 'call-v2-spawn',
|
||||
name: 'spawn_agent',
|
||||
input: { task_name: 'review', message: 'Review this' }
|
||||
})]);
|
||||
|
||||
expect(converter.handleNotification('rawResponseItem/completed', {
|
||||
item: {
|
||||
type: 'function_call',
|
||||
namespace: 'collaboration',
|
||||
name: 'wait_agent',
|
||||
arguments: JSON.stringify({ timeout_ms: 10_000 }),
|
||||
call_id: 'call-v2-wait'
|
||||
}
|
||||
})).toEqual([expect.objectContaining({
|
||||
type: 'codex_tool_call_begin',
|
||||
call_id: 'call-v2-wait',
|
||||
name: 'wait_agent',
|
||||
input: { timeout_ms: 10_000 }
|
||||
})]);
|
||||
|
||||
expect(converter.handleNotification('rawResponseItem/completed', {
|
||||
item: {
|
||||
type: 'function_call',
|
||||
namespace: 'agents',
|
||||
name: 'list_agents',
|
||||
arguments: '{}',
|
||||
call_id: 'call-v2-custom-namespace'
|
||||
}
|
||||
})).toEqual([expect.objectContaining({
|
||||
type: 'codex_tool_call_begin',
|
||||
call_id: 'call-v2-custom-namespace',
|
||||
name: 'list_agents'
|
||||
})]);
|
||||
});
|
||||
|
||||
it('maps reasoning deltas', () => {
|
||||
const converter = new AppServerEventConverter();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = '*';
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user