diff --git a/src/cli/adapters/claude-code.ts b/src/cli/adapters/claude-code.ts index 6bf6ee9a..b674c04f 100644 --- a/src/cli/adapters/claude-code.ts +++ b/src/cli/adapters/claude-code.ts @@ -16,13 +16,20 @@ export const claudeCodeAdapter: PlatformAdapter = { }; }, formatOutput(result) { - if (result.hookSpecificOutput) { + const r = result ?? ({} as HookResult); + if (r.hookSpecificOutput) { const output: Record = { hookSpecificOutput: result.hookSpecificOutput }; - if (result.systemMessage) { - output.systemMessage = result.systemMessage; + if (r.systemMessage) { + output.systemMessage = r.systemMessage; } return output; } - return { continue: result.continue ?? true, suppressOutput: result.suppressOutput ?? true }; + // Only emit fields in the Claude Code hook contract — unrecognized fields + // cause "JSON validation failed" in Stop hooks. + const output: Record = {}; + if (r.systemMessage) { + output.systemMessage = r.systemMessage; + } + return output; } }; diff --git a/tests/hook-lifecycle.test.ts b/tests/hook-lifecycle.test.ts index d24a7aab..2f48a65e 100644 --- a/tests/hook-lifecycle.test.ts +++ b/tests/hook-lifecycle.test.ts @@ -256,41 +256,74 @@ describe('Cursor IDE Compatibility (#838, #1049)', () => { // --- Platform Adapter Tests --- describe('Hook Lifecycle - Claude Code Adapter', () => { - it('should default suppressOutput to true when not explicitly set', async () => { + const fmt = async (input: any) => { const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js'); + return claudeCodeAdapter.formatOutput(input); + }; - // Result with no suppressOutput field - const output = claudeCodeAdapter.formatOutput({ continue: true }); - expect(output).toEqual({ continue: true, suppressOutput: true }); + // --- Happy paths --- + + it('should return empty object for empty result', async () => { + expect(await fmt({})).toEqual({}); }); - it('should default both continue and suppressOutput to true for empty result', async () => { - const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js'); - - const output = claudeCodeAdapter.formatOutput({}); - expect(output).toEqual({ continue: true, suppressOutput: true }); + it('should include systemMessage when present', async () => { + expect(await fmt({ systemMessage: 'test message' })).toEqual({ systemMessage: 'test message' }); }); - it('should respect explicit suppressOutput: false', async () => { - const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js'); - - const output = claudeCodeAdapter.formatOutput({ continue: true, suppressOutput: false }); - expect(output).toEqual({ continue: true, suppressOutput: false }); - }); - - it('should use hookSpecificOutput format for context injection', async () => { - const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js'); - - const result = { + it('should use hookSpecificOutput format with systemMessage', async () => { + const output = await fmt({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'test context' }, systemMessage: 'test message' - }; - const output = claudeCodeAdapter.formatOutput(result) as Record; + }) as Record; expect(output.hookSpecificOutput).toEqual({ hookEventName: 'SessionStart', additionalContext: 'test context' }); expect(output.systemMessage).toBe('test message'); - // Should NOT have continue/suppressOutput when using hookSpecificOutput - expect(output.continue).toBeUndefined(); - expect(output.suppressOutput).toBeUndefined(); + }); + + it('should return hookSpecificOutput without systemMessage when absent', async () => { + expect(await fmt({ + hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'ctx' }, + })).toEqual({ + hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'ctx' }, + }); + }); + + // --- Edge cases / unhappy paths (addresses PR #1291 review) --- + + it('should return empty object for malformed input (undefined/null)', async () => { + expect(await fmt(undefined)).toEqual({}); + expect(await fmt(null)).toEqual({}); + }); + + it('should exclude falsy systemMessage values', async () => { + expect(await fmt({ systemMessage: '' })).toEqual({}); + expect(await fmt({ systemMessage: null })).toEqual({}); + expect(await fmt({ systemMessage: 0 })).toEqual({}); + }); + + it('should strip all non-contract fields', async () => { + expect(await fmt({ + continue: false, + suppressOutput: false, + systemMessage: 'msg', + exitCode: 2, + hookSpecificOutput: undefined, + })).toEqual({ systemMessage: 'msg' }); + }); + + it('should only emit keys from the Claude Code hook contract', async () => { + const allowedKeys = new Set(['hookSpecificOutput', 'systemMessage', 'decision', 'reason']); + const cases = [ + {}, + { systemMessage: 'x' }, + { continue: true, suppressOutput: true, systemMessage: 'x', exitCode: 1 }, + { hookSpecificOutput: { hookEventName: 'E', additionalContext: 'C' }, systemMessage: 'x' }, + ]; + for (const input of cases) { + for (const key of Object.keys(await fmt(input) as object)) { + expect(allowedKeys.has(key)).toBe(true); + } + } }); });