diff --git a/packages/providers/src/claude/provider.test.ts b/packages/providers/src/claude/provider.test.ts index 16641b1555..2e072be29c 100644 --- a/packages/providers/src/claude/provider.test.ts +++ b/packages/providers/src/claude/provider.test.ts @@ -865,6 +865,35 @@ describe('ClaudeProvider', () => { expect(callArgs.options.sandbox).toEqual(sandbox); }); + test('passes hooks to SDK via nodeConfig without crashing on warning extraction', async () => { + // Regression for a TypeError that surfaced whenever a workflow node declared + // `hooks:` in its nodeConfig: applyNodeConfig ran twice — once against a + // throwaway Options `{}` for warning extraction, and once against the real + // Options — and the first pass crashed because it wrote into an undefined + // `options.hooks` map. + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'sid' }; + }); + + for await (const _ of client.sendQuery('test', '/tmp', undefined, { + nodeConfig: { + hooks: { + PreToolUse: [{ matcher: 'Write', response: { decision: 'approve' } }], + }, + }, + })) { + // consume + } + + expect(mockQuery).toHaveBeenCalledTimes(1); + const callArgs = mockQuery.mock.calls[0][0] as { + options: { hooks?: Record> }; + }; + // Node hooks land alongside the provider's own PostToolUse capture hook. + expect(callArgs.options.hooks?.PreToolUse?.[0]?.matcher).toBe('Write'); + expect(callArgs.options.hooks?.PostToolUse).toBeDefined(); + }); + test('ignores empty text blocks', async () => { mockQuery.mockImplementation(async function* () { yield { diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index 26935bf373..3202b5336e 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -381,6 +381,12 @@ async function applyNodeConfig( if (Object.keys(builtHooks).length > 0) { // Merge with existing hooks (PostToolUse capture hook) const existingHooks = options.hooks as SDKHooksMap | undefined; + // sendQuery's warning-extraction path passes `{} as Options` (no `hooks` + // field), so direct property assignment below would crash with + // "undefined is not an object". Ensure the map exists before writing. + if (!options.hooks) { + options.hooks = {} as SDKHooksMap; + } for (const [event, matchers] of Object.entries(builtHooks)) { if (!matchers) continue; const existing = existingHooks?.[event] as HookCallbackMatcher[] | undefined;