Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/codex-tool-payload-any-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': patch
---

Accept any JSON `tool_input` / `tool_response` on Codex `PreToolUse` / `PostToolUse` hook input, matching the pinned rust-v0.147.0 generated schemas (`"tool_input": true`, `"tool_response": true`). The generated Codex hook wrapper and the event-route envelope validator now require presence only for Codex, so string, number, boolean, and null tool payloads reach the handler instead of failing with `must be an object`; Claude keeps its documented object requirement.
11 changes: 9 additions & 2 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,9 +1156,16 @@ export const nativeHookWrapperSource = (
' if (canonicalEvent === "sessionStart") { requireString(input, "source"); return; }',
' if (canonicalEvent === "beforeTool" || canonicalEvent === "afterTool") {',
' requireString(input, "tool_name");',
' if (!isRecord(input.tool_input)) fail(`native ${nativeEvent} tool_input must be an object`);',
// The pinned rust-v0.147.0 pre-tool-use/post-tool-use input schemas declare
// `"tool_input": true` and `"tool_response": true` (any JSON value), so Codex
// only guarantees presence; Claude documents both as objects.
' if (target === "codex") { if (input.tool_input === undefined) fail(`native ${nativeEvent} tool_input is required`); }',
' else if (!isRecord(input.tool_input)) fail(`native ${nativeEvent} tool_input must be an object`);',
' requireString(input, "tool_use_id");',
' if (canonicalEvent === "afterTool" && !isRecord(input.tool_response)) fail("native PostToolUse tool_response must be an object");',
' if (canonicalEvent === "afterTool") {',
' if (target === "codex") { if (input.tool_response === undefined) fail("native PostToolUse tool_response is required"); }',
' else if (!isRecord(input.tool_response)) fail("native PostToolUse tool_response must be an object");',
' }',
' return;',
' }',
' if (canonicalEvent === "agentStart" || canonicalEvent === "agentStop") {',
Expand Down
24 changes: 18 additions & 6 deletions packages/agent-bundle/src/events/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,15 +282,27 @@ export const validateNativeEventEnvelope = (
if (canonicalEvent === 'session/start') requireNativeString(native, 'source');
if (canonicalEvent === 'tool/before' || canonicalEvent === 'tool/after') {
requireNativeString(native, 'tool_name');
if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) {
// The pinned rust-v0.147.0 pre-tool-use/post-tool-use input schemas declare
// `"tool_input": true` and `"tool_response": true` (any JSON value), so Codex
// only guarantees presence; Claude documents both as objects.
if (target === 'codex') {
if (!Object.hasOwn(native, 'tool_input') || native.tool_input === undefined) {
return nativeEventError('native tool_input is required');
}
} else if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) {
return nativeEventError('native tool_input must be an object');
}
requireNativeString(native, 'tool_use_id');
if (
canonicalEvent === 'tool/after'
&& (typeof native.tool_response !== 'object' || native.tool_response === null || Array.isArray(native.tool_response))
) {
return nativeEventError('native tool_response must be an object');
if (canonicalEvent === 'tool/after') {
if (target === 'codex') {
if (!Object.hasOwn(native, 'tool_response') || native.tool_response === undefined) {
return nativeEventError('native tool_response is required');
}
} else if (
typeof native.tool_response !== 'object' || native.tool_response === null || Array.isArray(native.tool_response)
) {
return nativeEventError('native tool_response must be an object');
}
}
}
if (canonicalEvent === 'agent/start' || canonicalEvent === 'agent/stop') {
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-bundle/tests/event-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ it('validates native event envelopes with the generated wrapper error contract',
.toThrow('Agent Bundle event route error: stdin JSON value must be an object');
});

it('accepts any JSON tool_input/tool_response for Codex tool events per the pinned generated schemas', () => {
const options = { canonicalEvent: 'tool/after' as const, nativeEvent: 'PostToolUse', target: 'codex' };
const base = {
cwd: '/tmp/lifecycle-replay',
hook_event_name: 'PostToolUse',
model: 'gpt-5-codex',
permission_mode: 'default',
session_id: 'session-1',
tool_name: 'Write',
tool_use_id: 'tool-1',
transcript_path: null,
turn_id: 'turn-1',
};

for (const value of ['text', 7, true, null, [1, 2], { ok: true }]) {
const native = { ...base, tool_input: value, tool_response: value };
expect(validateNativeEventEnvelope(native, options)).toBe(native);
}
expect(() => validateNativeEventEnvelope({ ...base, tool_input: {} }, options))
.toThrow('Agent Bundle event route error: native tool_response is required');
expect(() => validateNativeEventEnvelope({ ...base, tool_response: {} }, options))
.toThrow('Agent Bundle event route error: native tool_input is required');
expect(() => validateNativeEventEnvelope(
{ ...base, hook_event_name: 'PreToolUse', tool_input: 'text' },
{ canonicalEvent: 'tool/before', nativeEvent: 'PreToolUse', target: 'codex' },
)).not.toThrow();
});

it('validates Cursor workspaceOpen without inventing an agent session', () => {
const options = {
canonicalEvent: 'workspace/open' as const,
Expand Down
26 changes: 22 additions & 4 deletions packages/agent-bundle/tests/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,18 @@ it('runs the embedded Codex and Claude native codecs through their published wra
cwd: '/workspace', hook_event_name: 'Stop', last_assistant_message: 'done', session_id: 'session-1', stop_hook_active: false, transcript_path: '/workspace/transcript.json',
})).resolves.toEqual({ code: 0, stderr: '', stdout: '' });
}

// The pinned rust-v0.147.0 post-tool-use input schema types tool_response
// (and tool_input) as any JSON value, so scalar payloads reach the handler.
for (const toolResponse of ['observed', 42, false, null]) {
await expect(runNativeHook(join(outputRoot, 'codex', 'hooks', 'after-tool-record-87785f02.mjs'), {
cwd: '/workspace', hook_event_name: 'PostToolUse', model: 'gpt-5-codex', permission_mode: 'default', session_id: 'session-1', tool_input: 'raw', tool_name: 'Write', tool_response: toolResponse, tool_use_id: 'use-2', transcript_path: null, turn_id: 'turn-1',
})).resolves.toEqual({
code: 0,
stderr: '',
stdout: `{"hookSpecificOutput":{"additionalContext":"${String(toolResponse)}","hookEventName":"PostToolUse"}}`,
});
}
} finally {
await rm(root, { force: true, recursive: true });
}
Expand Down Expand Up @@ -1243,18 +1255,24 @@ it('rejects malformed event-specific native input before calling generated Codex
stderr: 'Agent Bundle hook error: native source must be a string\n',
stdout: '',
});
// Codex pins tool_input/tool_response as any JSON value (presence only);
// Claude documents both as objects.
const toolInputError = target === 'codex' ? 'tool_input is required' : 'tool_input must be an object';
const toolResponseError = target === 'codex' ? 'tool_response is required' : 'tool_response must be an object';
await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), {
...common, hook_event_name: 'PreToolUse', tool_input: [], tool_name: 'Bash', tool_use_id: 'use-1',
...common, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'use-1',
...(target === 'codex' ? {} : { tool_input: [] }),
})).resolves.toEqual({
code: 1,
stderr: 'Agent Bundle hook error: native PreToolUse tool_input must be an object\n',
stderr: `Agent Bundle hook error: native PreToolUse ${toolInputError}\n`,
stdout: '',
});
await expect(runNativeHook(join(hooksRoot, 'after-tool-record-87785f02.mjs'), {
...common, hook_event_name: 'PostToolUse', tool_input: {}, tool_name: 'Write', tool_response: 'observed', tool_use_id: 'use-2',
...common, hook_event_name: 'PostToolUse', tool_input: {}, tool_name: 'Write', tool_use_id: 'use-2',
...(target === 'codex' ? {} : { tool_response: 'observed' }),
})).resolves.toEqual({
code: 1,
stderr: 'Agent Bundle hook error: native PostToolUse tool_response must be an object\n',
stderr: `Agent Bundle hook error: native PostToolUse ${toolResponseError}\n`,
stdout: '',
});
await expect(runNativeHook(join(hooksRoot, 'stop-stop-bb2d7935.mjs'), {
Expand Down
Loading