mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
fix: remove unrecognized fields from Claude Code Stop hook output (#1291)
* fix: remove unrecognized fields from Claude Code Stop hook output
Claude Code validates Stop hook JSON output against its hook contract
schema which only accepts {decision?, reason?, systemMessage?}. The
formatOutput() function was returning {continue, suppressOutput} which
are not part of the Claude Code hook API, causing "JSON validation
failed" errors on every session stop.
Return an empty object {} for the default case (no hookSpecificOutput),
preserving only systemMessage when present. This is valid for all hook
event types and eliminates the schema validation error.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add unhappy-path tests for formatOutput per PR review
Add edge case coverage for malformed input (undefined/null), falsy
systemMessage values, non-contract field stripping, and contract key
allowlist. Also add defensive null guard to formatOutput matching
normalizeInput pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Alex Worland <alexworland@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,13 +16,20 @@ export const claudeCodeAdapter: PlatformAdapter = {
|
||||
};
|
||||
},
|
||||
formatOutput(result) {
|
||||
if (result.hookSpecificOutput) {
|
||||
const r = result ?? ({} as HookResult);
|
||||
if (r.hookSpecificOutput) {
|
||||
const output: Record<string, unknown> = { 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<string, unknown> = {};
|
||||
if (r.systemMessage) {
|
||||
output.systemMessage = r.systemMessage;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}) as Record<string, unknown>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user