import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' type HookChainsModule = typeof import('./hookChains.js') const tempDirs: string[] = [] const originalHookChainsEnabled = process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS async function makeConfigFile(config: unknown): Promise { const dir = await mkdtemp(join(tmpdir(), 'openclaude-hook-chains-')) tempDirs.push(dir) const filePath = join(dir, 'hook-chains.json') await writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8') return filePath } async function importHookChainsModule(options?: { allowRemoteSessions?: boolean }): Promise { mock.restore() const allowRemoteSessions = options?.allowRemoteSessions ?? true mock.module('../services/analytics/index.js', () => ({ logEvent: () => {}, })) mock.module('./telemetry/events.js', () => ({ logOTelEvent: async () => {}, })) mock.module('../services/policyLimits/index.js', () => ({ isPolicyAllowed: () => allowRemoteSessions, })) return import(`./hookChains.js?test=${Date.now()}-${Math.random()}`) } beforeEach(() => { process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = '1' }) afterEach(async () => { mock.restore() if (originalHookChainsEnabled === undefined) { delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS } else { process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = originalHookChainsEnabled } await Promise.all( tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), ) }) describe('hookChains schema validation', () => { test('returns disabled config when env gate is unset', async () => { delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS const mod = await importHookChainsModule() const configPath = await makeConfigFile({ version: 1, enabled: true, rules: [ { id: 'env-gated-rule', trigger: { event: 'TaskCompleted', outcome: 'failed' }, actions: [{ type: 'spawn_fallback_agent' }], }, ], }) const loaded = mod.loadHookChainsConfig({ pathOverride: configPath }) expect(loaded.exists).toBe(false) expect(loaded.config.enabled).toBe(false) expect(loaded.config.rules).toHaveLength(0) }) test('loads valid config and memoizes by mtime/size', async () => { const mod = await importHookChainsModule() const configPath = await makeConfigFile({ version: 1, enabled: true, maxChainDepth: 3, defaultCooldownMs: 5000, defaultDedupWindowMs: 5000, rules: [ { id: 'task-failure-fallback', trigger: { event: 'TaskCompleted', outcome: 'failed' }, actions: [ { type: 'spawn_fallback_agent', description: 'Fallback recovery agent', }, ], }, ], }) const first = mod.loadHookChainsConfig({ pathOverride: configPath }) expect(first.exists).toBe(true) expect(first.error).toBeUndefined() expect(first.fromCache).toBe(false) expect(first.config.enabled).toBe(true) expect(first.config.rules).toHaveLength(1) expect(first.config.rules[0]?.id).toBe('task-failure-fallback') const second = mod.loadHookChainsConfig({ pathOverride: configPath }) expect(second.exists).toBe(true) expect(second.error).toBeUndefined() expect(second.fromCache).toBe(true) expect(second.config.rules).toHaveLength(1) }) test('accepts wrapped { hookChains: ... } config shape', async () => { const mod = await importHookChainsModule() const configPath = await makeConfigFile({ hookChains: { version: 1, enabled: true, rules: [ { id: 'wrapped-shape', trigger: { event: 'PostToolUseFailure', outcomes: ['failed'] }, actions: [{ type: 'notify_team' }], }, ], }, }) const loaded = mod.loadHookChainsConfig({ pathOverride: configPath }) expect(loaded.error).toBeUndefined() expect(loaded.config.enabled).toBe(true) expect(loaded.config.rules[0]?.id).toBe('wrapped-shape') }) test('returns disabled config for invalid schema', async () => { const mod = await importHookChainsModule() const configPath = await makeConfigFile({ version: 1, enabled: true, rules: [ { id: 'invalid-rule', trigger: { event: 'TaskCompleted', outcome: 'failed', outcomes: ['failed'], }, actions: [{ type: 'spawn_fallback_agent' }], }, ], }) const loaded = mod.loadHookChainsConfig({ pathOverride: configPath }) expect(loaded.exists).toBe(true) expect(loaded.error).toBeDefined() expect(loaded.config.enabled).toBe(false) expect(loaded.config.rules).toHaveLength(0) }) }) describe('evaluateHookChainRules', () => { test('matches by event + outcome + condition', async () => { const mod = await importHookChainsModule() const rules = [ { id: 'post-tool-failure-rule', trigger: { event: 'PostToolUseFailure', outcome: 'failed' }, condition: { toolNames: ['Edit'], errorIncludes: ['permission'], eventFieldEquals: { 'meta.source': 'scheduler' }, }, actions: [{ type: 'spawn_fallback_agent' }], }, ] const matches = mod.evaluateHookChainRules(rules as never, { eventName: 'PostToolUseFailure', outcome: 'failed', payload: { tool_name: 'Edit', error: 'Permission denied by policy', meta: { source: 'scheduler' }, }, }) expect(matches).toHaveLength(1) expect(matches[0]?.rule.id).toBe('post-tool-failure-rule') }) test('does not match when event/condition fail', async () => { const mod = await importHookChainsModule() const rules = [ { id: 'rule-no-match', trigger: { event: 'PostToolUseFailure', outcomes: ['failed'] }, condition: { toolNames: ['Write'] }, actions: [{ type: 'spawn_fallback_agent' }], }, ] const wrongEvent = mod.evaluateHookChainRules(rules as never, { eventName: 'TaskCompleted', outcome: 'failed', payload: { tool_name: 'Write' }, }) expect(wrongEvent).toHaveLength(0) const wrongCondition = mod.evaluateHookChainRules(rules as never, { eventName: 'PostToolUseFailure', outcome: 'failed', payload: { tool_name: 'Edit' }, }) expect(wrongCondition).toHaveLength(0) }) }) describe('dispatchHookChainsForEvent guard logic', () => { test('dedup skips duplicate event/action within dedup window', async () => { const mod = await importHookChainsModule() const configPath = await makeConfigFile({ version: 1, enabled: true, maxChainDepth: 4, defaultCooldownMs: 0, defaultDedupWindowMs: 60_000, rules: [ { id: 'dedup-rule', trigger: { event: 'TaskCompleted', outcome: 'failed' }, cooldownMs: 0, dedupWindowMs: 60_000, actions: [{ id: 'spawn-1', type: 'spawn_fallback_agent' }], }, ], }) const spawn = mock(async () => ({ launched: true, agentId: 'agent-1' })) const first = await mod.dispatchHookChainsForEvent({ configPathOverride: configPath, event: { eventName: 'TaskCompleted', outcome: 'failed', payload: { task_id: 'task-123', error: 'boom' }, }, runtime: { onSpawnFallbackAgent: spawn }, }) const second = await mod.dispatchHookChainsForEvent({ configPathOverride: configPath, event: { eventName: 'TaskCompleted', outcome: 'failed', payload: { task_id: 'task-123', error: 'boom' }, }, runtime: { onSpawnFallbackAgent: spawn }, }) expect(first.actionResults[0]?.status).toBe('executed') expect(second.actionResults[0]?.status).toBe('skipped') expect(second.actionResults[0]?.reason).toContain('dedup') expect(spawn).toHaveBeenCalledTimes(1) }) test('cooldown skips second dispatch when rule cooldown is active', async () => { const mod = await importHookChainsModule() const configPath = await makeConfigFile({ version: 1, enabled: true, maxChainDepth: 4, defaultCooldownMs: 60_000, defaultDedupWindowMs: 0, rules: [ { id: 'cooldown-rule', trigger: { event: 'TaskCompleted', outcome: 'failed' }, cooldownMs: 60_000, dedupWindowMs: 0, actions: [{ type: 'spawn_fallback_agent' }], }, ], }) const spawn = mock(async () => ({ launched: true, agentId: 'agent-2' })) const first = await mod.dispatchHookChainsForEvent({ configPathOverride: configPath, event: { eventName: 'TaskCompleted', outcome: 'failed', payload: { task_id: 'task-456' }, }, runtime: { onSpawnFallbackAgent: spawn }, }) const second = await mod.dispatchHookChainsForEvent({ configPathOverride: configPath, event: { eventName: 'TaskCompleted', outcome: 'failed', payload: { task_id: 'task-789' }, }, runtime: { onSpawnFallbackAgent: spawn }, }) expect(first.actionResults[0]?.status).toBe('executed') expect(second.actionResults[0]?.status).toBe('skipped') expect(second.actionResults[0]?.reason).toContain('cooldown') expect(spawn).toHaveBeenCalledTimes(1) }) test('depth limit blocks dispatch when chain depth reaches max', async () => { const mod = await importHookChainsModule() const configPath = await makeConfigFile({ version: 1, enabled: true, maxChainDepth: 1, defaultCooldownMs: 0, defaultDedupWindowMs: 0, rules: [ { id: 'depth-rule', trigger: { event: 'TaskCompleted', outcome: 'failed' }, actions: [{ type: 'spawn_fallback_agent' }], }, ], }) const spawn = mock(async () => ({ launched: true, agentId: 'agent-3' })) const result = await mod.dispatchHookChainsForEvent({ configPathOverride: configPath, event: { eventName: 'TaskCompleted', outcome: 'failed', payload: { task_id: 'task-depth' }, }, runtime: { chainDepth: 1, onSpawnFallbackAgent: spawn, }, }) expect(result.enabled).toBe(true) expect(result.matchedRuleIds).toHaveLength(0) expect(result.actionResults).toHaveLength(0) expect(spawn).not.toHaveBeenCalled() }) }) describe('action dispatch skip scenarios', () => { test('fails spawn_fallback_agent when launcher callback is missing', async () => { const mod = await importHookChainsModule() const configPath = await makeConfigFile({ version: 1, enabled: true, maxChainDepth: 3, defaultCooldownMs: 0, defaultDedupWindowMs: 0, rules: [ { id: 'missing-launcher', trigger: { event: 'TaskCompleted', outcome: 'failed' }, actions: [{ type: 'spawn_fallback_agent' }], }, ], }) const result = await mod.dispatchHookChainsForEvent({ configPathOverride: configPath, event: { eventName: 'TaskCompleted', outcome: 'failed', payload: { task_id: 'task-missing-launcher' }, }, runtime: {}, }) expect(result.actionResults[0]?.status).toBe('failed') expect(result.actionResults[0]?.reason).toContain('launcher') }) test('skips disabled action and does not execute callback', async () => { const mod = await importHookChainsModule() const configPath = await makeConfigFile({ version: 1, enabled: true, maxChainDepth: 3, defaultCooldownMs: 0, defaultDedupWindowMs: 0, rules: [ { id: 'disabled-action-rule', trigger: { event: 'TaskCompleted', outcome: 'failed' }, actions: [ { type: 'spawn_fallback_agent', enabled: false, }, ], }, ], }) const spawn = mock(async () => ({ launched: true, agentId: 'agent-4' })) const result = await mod.dispatchHookChainsForEvent({ configPathOverride: configPath, event: { eventName: 'TaskCompleted', outcome: 'failed', payload: { task_id: 'task-disabled' }, }, runtime: { onSpawnFallbackAgent: spawn }, }) expect(result.actionResults[0]?.status).toBe('skipped') expect(result.actionResults[0]?.reason).toContain('disabled') expect(spawn).not.toHaveBeenCalled() }) test('skips warm_remote_capacity when policy denies remote sessions', async () => { const mod = await importHookChainsModule({ allowRemoteSessions: false }) const configPath = await makeConfigFile({ version: 1, enabled: true, maxChainDepth: 3, defaultCooldownMs: 0, defaultDedupWindowMs: 0, rules: [ { id: 'policy-denied-remote-warm', trigger: { event: 'TaskCompleted', outcome: 'failed' }, actions: [{ type: 'warm_remote_capacity' }], }, ], }) const warm = mock(async () => ({ warmed: true, environmentId: 'env-123', })) const result = await mod.dispatchHookChainsForEvent({ configPathOverride: configPath, event: { eventName: 'TaskCompleted', outcome: 'failed', payload: { task_id: 'task-policy-denied' }, }, runtime: { onWarmRemoteCapacity: warm }, }) expect(result.actionResults[0]?.status).toBe('skipped') expect(result.actionResults[0]?.reason).toContain('policy') expect(warm).not.toHaveBeenCalled() }) })