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
358 changes: 179 additions & 179 deletions plugin/scripts/worker-service.cjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/cli/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const codexAdapter: PlatformAdapter = {
hookEventName: outputEvent,
};

if (hookSpecific.additionalContext) {
if (typeof hookSpecific.additionalContext === 'string') {
specific.additionalContext = hookSpecific.additionalContext;
}

Expand Down
22 changes: 20 additions & 2 deletions src/cli/hook-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readJsonFromStdin } from './stdin-reader.js';
import { getPlatformAdapter } from './adapters/index.js';
import { AdapterRejectedInput } from './adapters/errors.js';
import { getEventHandler } from './handlers/index.js';
import type { HookResult } from './types.js';
import { HOOK_EXIT_CODES } from '../shared/hook-constants.js';
import {
installHookStderrBuffer,
Expand All @@ -22,6 +23,23 @@ export interface HookCommandOptions {
skipExit?: boolean;
}

/**
* No-op result for hooks that must exit before their handler ran (adapter
* rejected input, transcript path missing). `context` is the sole handler
* key that produces SessionStart output on every platform; a bare
* `{continue:true}` fallback for it — with no hookSpecificOutput — is what
* Codex's strict SessionStart validator rejects as "invalid session start
* JSON output" (issue #2972). Attaching the minimal valid payload keeps the
* no-op harmless everywhere else too.
*/
export function buildNoOpResult(event: string): HookResult {
const result: HookResult = { continue: true, suppressOutput: true };
if (event === 'context') {
result.hookSpecificOutput = { hookEventName: 'SessionStart', additionalContext: '' };
}
return result;
}

export function isWorkerUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
Expand Down Expand Up @@ -108,13 +126,13 @@ export async function hookCommand(platform: string, event: string, options: Hook
} catch (error) {
if (error instanceof AdapterRejectedInput) {
logger.warn('HOOK', `Adapter rejected input (${error.reason}), skipping hook`);
emitModelContext(adapter, { continue: true, suppressOutput: true });
emitModelContext(adapter, buildNoOpResult(event));
exitGraceful(options);
return HOOK_EXIT_CODES.SUCCESS;
}
if (isNonBlockingHookInputError(error)) {
logger.warn('HOOK', `Hook input unavailable, skipping hook: ${error instanceof Error ? error.message : error}`);
emitModelContext(adapter, { continue: true, suppressOutput: true });
emitModelContext(adapter, buildNoOpResult(event));
exitGraceful(options);
return HOOK_EXIT_CODES.SUCCESS;
}
Expand Down
20 changes: 19 additions & 1 deletion tests/hook-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { describe, it, expect } from 'bun:test';
import { isNonBlockingHookInputError, isWorkerUnavailableError } from '../src/cli/hook-command.js';
import { buildNoOpResult, isNonBlockingHookInputError, isWorkerUnavailableError } from '../src/cli/hook-command.js';

describe('buildNoOpResult', () => {
it('attaches a valid SessionStart hookSpecificOutput for the context event (#2972)', () => {
const result = buildNoOpResult('context');

expect(result).toEqual({
continue: true,
suppressOutput: true,
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' },
});
});

it('omits hookSpecificOutput for every other event', () => {
for (const event of ['session-init', 'observation', 'summarize', 'user-message', 'file-edit', 'file-context']) {
expect(buildNoOpResult(event)).toEqual({ continue: true, suppressOutput: true });
}
});
});

describe('isNonBlockingHookInputError', () => {
it('classifies missing transcript paths as non-blocking hook input errors', () => {
Expand Down
14 changes: 14 additions & 0 deletions tests/hook-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,20 @@ describe('Codex CLI Compatibility (#744)', () => {

expect(output).toEqual({ continue: true });
});

it('preserves an explicit empty-string additionalContext instead of dropping the key (#3127)', async () => {
const { codexAdapter } = await import('../src/cli/adapters/codex.js');
const output = codexAdapter.formatOutput({
continue: true,
suppressOutput: true,
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' },
}) as any;

expect(output).toEqual({
continue: true,
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' },
});
});
});

describe('session-init handler undefined prompt', () => {
Expand Down
Loading