From 867d248c2ea3147b8f25fcebf565b1d5329c283e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:37:52 +0800 Subject: [PATCH] test(hooks): split coverage suites across workers --- .../hooks-claude/tests/coverage-cases.ts | 693 ++++++++++++++++++ .../tests/coverage-config.spec.ts | 3 + .../tests/coverage-context.spec.ts | 3 + .../tests/coverage-edge-paths.spec.ts | 3 + .../hooks-claude/tests/coverage-stop.spec.ts | 3 + .../hooks/hooks-claude/tests/coverage.spec.ts | 688 ----------------- .../hooks/hooks-codex/tests/coverage-cases.ts | 574 +++++++++++++++ .../tests/coverage-post-tool.spec.ts | 3 + .../hooks-codex/tests/coverage-prompt.spec.ts | 3 + .../tests/coverage-result-shape.spec.ts | 3 + .../hooks/hooks-codex/tests/coverage.spec.ts | 560 -------------- 11 files changed, 1288 insertions(+), 1248 deletions(-) create mode 100644 packages/hooks/hooks-claude/tests/coverage-cases.ts create mode 100644 packages/hooks/hooks-claude/tests/coverage-config.spec.ts create mode 100644 packages/hooks/hooks-claude/tests/coverage-context.spec.ts create mode 100644 packages/hooks/hooks-claude/tests/coverage-edge-paths.spec.ts create mode 100644 packages/hooks/hooks-claude/tests/coverage-stop.spec.ts delete mode 100644 packages/hooks/hooks-claude/tests/coverage.spec.ts create mode 100644 packages/hooks/hooks-codex/tests/coverage-cases.ts create mode 100644 packages/hooks/hooks-codex/tests/coverage-post-tool.spec.ts create mode 100644 packages/hooks/hooks-codex/tests/coverage-prompt.spec.ts create mode 100644 packages/hooks/hooks-codex/tests/coverage-result-shape.spec.ts delete mode 100644 packages/hooks/hooks-codex/tests/coverage.spec.ts diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts new file mode 100644 index 0000000000..f47394a77f --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -0,0 +1,693 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +/** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent + * fallbacks, contextFrom-empty, and the detached-listener catch handlers. */ + +const dirs: string[] = [] +afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) + +function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d } +function sh(d: string, name: string, body: string): string { + const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p +} +function hooks(d: string, h: unknown): string { + writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') +} + +type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number } +async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + await ctx.plugin(HooksClaude, { configPath, ...opts }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) +} +function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +/** Poll until `predicate` holds or the deadline passes — robust to detached + * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ +async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') + await new Promise(r => setTimeout(r, interval)) + } +} + +export type CoverageGroup = 'config' | 'stop' | 'context' | 'edge-paths' + +/** Register independently schedulable slices of the hooks-claude coverage matrix. */ +export function defineCoverageCases(group: CoverageGroup): void { + if (group === 'config') describe('hooks-claude coverage — config option arms + substitution + skip warning', () => { + it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => { + const d = dir() + // ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker. + const marker = join(d, 'ran') + sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) + const path = hooks(d, { + PreToolUse: [{ hooks: [ + { type: 'prompt', prompt: 'skipme' }, // skipped → warn loop + { type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/h.sh' }, // substituted + ] }], + }) + const warn = vi.fn() + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) + ctx.logger.warn = warn as never + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(existsSync(marker)).toBe(true) // substituted command ran + }) + + it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => { + const d = dir() + const s = sh(d, 'u.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"rewritten"}}}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const warn = vi.fn() + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { command: 'original' }), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.logger.warn = warn as never + let sawArgs: unknown + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // updatedInput is NOT honored — the tool ran with the ORIGINAL args. + expect((sawArgs as { command?: string }).command).toBe('original') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('updatedInput')) + }) + }) + + if (group === 'config') describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () => { + it('a clean exit-0 hook with no output is a no-op (contextFrom empty → next())', async () => { + const d = dir() + const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ran')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // The prompt proceeded unchanged; no context/message injected. + expect(adapter.requests).toHaveLength(1) + expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + }) + + it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => { + const d = dir() + const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\necho "no" >&2\nexit 2\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + // Call execute() directly with NO agent — the bridge's no-agent/no-turn path. + const { CallId } = await import('@deepseek-ai/dsh-llm') + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) + expect(ran).toBe(false) + expect(result.isError).toBe(true) + }) + + it('a long stderr is truncated in the hook/result summary', async () => { + const d = dir() + // Emit >500 chars of stderr then exit 2. + const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) + expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis + }) + + it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { + const d = dir() + const path = hooks(d, {}) + for (const bad of [0, -5, 1.5, Number.NaN]) { + const adapter = new MockAdapter([]) + await expect(harness(path, adapter, { stderrSummaryMaxChars: bad })) + .rejects.toThrow(/hooks-claude: stderrSummaryMaxChars must be a positive integer/) + } + }) + + it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { + const d = dir() + const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') + }) + }) + + if (group === 'stop') describe('hooks-claude coverage — Stop continuation + subagent inject/catch', () => { + it('a Stop hook that blocks (exit 2) forces the turn to continue (CC dialect)', async () => { + const d = dir() + const marker = join(d, 'fired') + const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "continue please" >&2\nexit 2\n`) + const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please') + }) + + it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => { + // A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces + // continuation; the script self-limits to one block to avoid a loop. + const d = dir() + const marker = join(d, 'fired') + const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) + const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // A second model request ran → the empty-reason block forced continuation. + expect(adapter.requests).toHaveLength(2) + // The steering carried the fallback reason (no stderr to use). + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') + }) + + it('SubagentStart additionalContext is injected into a REGISTERED live child', async () => { + const d = dir() + const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n') + const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + // Register a fake child agent under the id the event carries. + const injected: string[] = [] + const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] + ctx.agents.register(child) + ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') }) + await waitFor(() => injected.includes('child guidance')) + expect(injected).toContain('child guidance') + }) + + it('a throwing SubagentStart/SubagentStop hook run is contained (logged)', async () => { + const d = dir() + // A hook command that does not exist makes runHook resolve a non-blocking + // error (not a throw), so to hit the .catch we make the .then throw: register + // a child whose inject throws for SubagentStart. + const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n') + const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + const warn = vi.fn(); ctx.logger.warn = warn as never + const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] + ctx.agents.register(child) + ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') }) + await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) + }) + }) + + if (group === 'stop') describe('hooks-claude coverage — default reasons + sparse payloads', () => { + it('PreToolUse deny with EMPTY stderr uses the default reason', async () => { + const d = dir() + const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') // exit 2, no stderr + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) + }) + + it('PostToolUse deny with EMPTY stderr + no context uses the default feedback', async () => { + const d = dir() + const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) + }) + + it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => { + const d = dir() + // The agents registry has no entry for the id, so the child lookup yields + // undefined and the payload falls back to base(undefined) — assert the + // observe-only SubagentStop run still executes the hook without crashing. + const marker = join(d, 'stopran') + const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) + const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) + await waitFor(() => existsSync(marker)) + expect(existsSync(marker)).toBe(true) + }) + }) + + if (group === 'edge-paths') describe('hooks-claude coverage — more default/sparse arms', () => { + it('UserPromptSubmit deny with EMPTY stderr uses the default block reason', async () => { + const d = dir() + const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('no')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook') + }) + + it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => { + const d = dir() + const s = sh(d, 'ask.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // ask (no reason) → degrades to deny with the registry's generic message. + expect(ran).toBe(false) + expect(events(agent).some(e => e.type === 'tool/result' && e.data.isError)).toBe(true) + }) + + it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => { + const d = dir() + const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) + expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) + }) + }) + + if (group === 'edge-paths') describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => { + it('a direct apply() (schema bypass) with only configPath runs', async () => { + const d = dir() + const marker = join(d, 'ran') + const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + // Direct apply with only configPath — bypasses schemastery's defaults, so + // the bridge must run on the raw minimal config (the per-hook timeout is + // the protocol lib's reference default, not a config knob). + HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(existsSync(marker)).toBe(true) + }) + + it('a non-zero non-2 hook exit (e.g. a command-not-found 127) is a non-blocking error; the tool still runs', async () => { + const d = dir() + // `bash -c` of a missing program exits 127 — a non-blocking error (not 0, not + // 2 → no decision), so the tool proceeds; the hook/result records exit 127. + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: '/nonexistent/definitely/not/a/command' }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(ran).toBe(true) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.exitCode).toBe(127) + }) + + it('a PostToolUse deny with empty stderr + no context uses the default feedback (no context arm)', async () => { + const d = dir() + const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + }) + }) + + if (group === 'context') describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => { + it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => { + // The seams cannot yet honor `continue:false` as a hard halt. The log must still record the + // stop decision while execution and the turn continue normally. + const d = dir() + const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded + expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') // ran to completion + }) + + it('a PostToolUse hook that BOTH blocks AND attaches additionalContext', async () => { + const d = dir() + const s = sh(d, 'b.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"context too"}}\'\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) + // additionalContext also injected (the block + context arm). + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) + }) + + it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => { + // The block's hookEventName (UserPromptSubmit) mismatches the firing event + // (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs. + const d = dir() + const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran + }) + + it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => { + // The default ACP wiring sets no projectDir. A stock CC hook that references + // $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace, + // not an empty string. The hook echoes the var as additionalContext. + const d = dir() + const workspace = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ran')]) + const ctx = await harness(path, adapter) // NB: no projectDir + // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). + const { SessionId } = await import('@deepseek-ai/dsh-session') + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) + handle.agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, handle.agent as ReactLoopAgent) + expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) + await handle.dispose() + }) + + it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { + // A context-only hook delegates with `next()` and folds its context, so a downstream policy + // listener can still veto the prompt. + const d = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(path, adapter) + // A later listener that blocks every prompt (registered AFTER the bridge). + const { AgentId: AId } = await import('@deepseek-ai/dsh-agent') + ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) + const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // the downstream block won: the model was never called, no user/message was + // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` + expect(adapter.requests).toHaveLength(0) + expect(events(agent).some(e => e.type === 'user/message')).toBe(false) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) + }) + + it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => { + // Both the bridge hook and a later prompt-submit listener attach context; the + // request must see BOTH (concatContext keeps the downstream one too). + const d = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + ctx.on('agent/prompt-submit', async () => ({ + kind: 'allow' as const, + content: [{ type: 'text' as const, text: 'rewritten-prompt' }], + additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const req = JSON.stringify(adapter.requests[0]!.messages) + expect(req).toContain('from-bridge') + expect(req).toContain('from-downstream') + expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved + // the original prompt was replaced by the downstream rewrite + const userMsg = events(agent).find(e => e.type === 'user/message') + expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true) + }) + + it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { + // The bridge hook adds context; a later post-execute listener accepts with a + // content rewrite. Both the rewrite and the bridge context survive. + const d = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + }) + + it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { + // The bridge hook only adds context; a later post-execute listener blocks the + // result. The block wins AND carries the bridge context (concatContext on the + // block arm). + const d = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) + // the bridge's context still landed (folded onto the block) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + }) + + }) + + if (group === 'edge-paths') describe('hooks-claude coverage — executor reject + no-open-turn', () => { + it('when the bash executor REJECTS a hook run, the hook/result omits exitCode (non-blocking)', async () => { + const d = dir() + const s = sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + // Force the executor to reject (an infrastructure fault) so runHook's catch + // yields a HookOutput with exitCode undefined → the `exitCode` spread false arm. + const bash = ctx.bash + bash.run = (() => Promise.reject(new Error('executor down'))) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) + }) + + }) + + if (group === 'edge-paths') describe('hooks-claude coverage — detached-listener catch handlers', () => { + it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => { + const d = dir() + const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') + const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + // Make inject throw, forcing the SessionStart .catch path. + const original = agent.inject.bind(agent) + let threw = false + agent.inject = (() => { threw = true; throw new Error('inject boom') }) + await waitFor(() => threw) + expect(threw).toBe(true) + agent.inject = original + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject + }) + }) + + if (group === 'stop') describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => { + it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => { + // The server launch directory and session cwd deliberately differ. The marker proves the + // bridge passes `session/new.cwd` instead of falling back to the executor default. + const serverDir = dir() + const sessionDir = dir() + const marker = join(sessionDir, 'where') + // The hook is invoked with cwd = session dir, so a relative marker path lands there. + hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + // Executor default cwd = serverDir (deliberately NOT the session cwd). + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) + await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + + const { SessionId } = await import('@deepseek-ai/dsh-session') + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + handle.agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, handle.agent as ReactLoopAgent) + + expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir + const { readFileSync } = await import('node:fs') + const where = readFileSync(marker, 'utf8').trim() + // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. + expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true) + await handle.dispose() + }) + + it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => { + // `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint` + // receives that agent and runs in the child's cwd rather than the executor default. + const serverDir = dir() + const childDir = dir() + const marker = join(childDir, 'stopwhere') + hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] }) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + // Executor default cwd = serverDir (deliberately NOT the child session cwd). + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) + await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([])) + + // Register a live child on its own session cwd; emit subagent/end with its id. + const { SessionId } = await import('@deepseek-ai/dsh-session') + const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) + ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) + + await waitFor(() => existsSync(marker)) + expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir + const { readFileSync } = await import('node:fs') + const where = readFileSync(marker, 'utf8').trim() + // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. + expect(where.endsWith(childDir.split('/').pop()!)).toBe(true) + await childHandle.dispose() + }) + }) + + if (group === 'config') describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => { + it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => { + const d = dir() + const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + const warn = vi.fn(); ctx.logger.warn = warn as never + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) + // Not surfaced: the systemMessage text never reaches the model request. + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') + }) + }) + + if (group === 'edge-paths') describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => { + it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => { + // Session-start injection is detached, so an immediate prompt need not observe it. Assert only + // the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race. + const d = dir() + const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n') + const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + // Send immediately — do NOT wait for the session-start inject. + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing + }) + }) +} diff --git a/packages/hooks/hooks-claude/tests/coverage-config.spec.ts b/packages/hooks/hooks-claude/tests/coverage-config.spec.ts new file mode 100644 index 0000000000..1afa18c4ff --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage-config.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases('config') diff --git a/packages/hooks/hooks-claude/tests/coverage-context.spec.ts b/packages/hooks/hooks-claude/tests/coverage-context.spec.ts new file mode 100644 index 0000000000..e0f3fb0ef8 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage-context.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases('context') diff --git a/packages/hooks/hooks-claude/tests/coverage-edge-paths.spec.ts b/packages/hooks/hooks-claude/tests/coverage-edge-paths.spec.ts new file mode 100644 index 0000000000..0bbcb53b03 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage-edge-paths.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases('edge-paths') diff --git a/packages/hooks/hooks-claude/tests/coverage-stop.spec.ts b/packages/hooks/hooks-claude/tests/coverage-stop.spec.ts new file mode 100644 index 0000000000..651cb1f6f0 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage-stop.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases('stop') diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts deleted file mode 100644 index f376708688..0000000000 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ /dev/null @@ -1,688 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' - -/** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent - * fallbacks, contextFrom-empty, and the detached-listener catch handlers. */ - -const dirs: string[] = [] -afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) - -function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d } -function sh(d: string, name: string, body: string): string { - const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p -} -function hooks(d: string, h: unknown): string { - writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') -} - -type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number } -async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(HooksClaude, { configPath, ...opts }) - ctx.llm.registerAdapter(['mock'], adapter) - return ctx -} -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { - return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) -} -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } -/** Poll until `predicate` holds or the deadline passes — robust to detached - * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ -async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { - const deadline = Date.now() + timeout - while (!predicate()) { - if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') - await new Promise(r => setTimeout(r, interval)) - } -} - -describe('hooks-claude coverage — config option arms + substitution + skip warning', () => { - it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => { - const d = dir() - // ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker. - const marker = join(d, 'ran') - sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) - const path = hooks(d, { - PreToolUse: [{ hooks: [ - { type: 'prompt', prompt: 'skipme' }, // skipped → warn loop - { type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/h.sh' }, // substituted - ] }], - }) - const warn = vi.fn() - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) - ctx.logger.warn = warn as never - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(existsSync(marker)).toBe(true) // substituted command ran - }) - - it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => { - const d = dir() - const s = sh(d, 'u.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"rewritten"}}}\'\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const warn = vi.fn() - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { command: 'original' }), textResponse('done')]) - const ctx = await harness(path, adapter) - ctx.logger.warn = warn as never - let sawArgs: unknown - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - // updatedInput is NOT honored — the tool ran with the ORIGINAL args. - expect((sawArgs as { command?: string }).command).toBe('original') - expect(warn).toHaveBeenCalledWith(expect.stringContaining('updatedInput')) - }) -}) - -describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () => { - it('a clean exit-0 hook with no output is a no-op (contextFrom empty → next())', async () => { - const d = dir() - const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ran')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - // The prompt proceeded unchanged; no context/message injected. - expect(adapter.requests).toHaveLength(1) - expect(events(agent).some(e => e.type === 'context/message')).toBe(false) - }) - - it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => { - const d = dir() - const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\necho "no" >&2\nexit 2\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const ctx = await harness(path, new MockAdapter([])) - let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - // Call execute() directly with NO agent — the bridge's no-agent/no-turn path. - const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) - expect(ran).toBe(false) - expect(result.isError).toBe(true) - }) - - it('a long stderr is truncated in the hook/result summary', async () => { - const d = dir() - // Emit >500 chars of stderr then exit 2. - const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) - expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis - }) - - it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { - const d = dir() - const path = hooks(d, {}) - for (const bad of [0, -5, 1.5, Number.NaN]) { - const adapter = new MockAdapter([]) - await expect(harness(path, adapter, { stderrSummaryMaxChars: bad })) - .rejects.toThrow(/hooks-claude: stderrSummaryMaxChars must be a positive integer/) - } - }) - - it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { - const d = dir() - const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') - }) -}) - -describe('hooks-claude coverage — Stop continuation + subagent inject/catch', () => { - it('a Stop hook that blocks (exit 2) forces the turn to continue (CC dialect)', async () => { - const d = dir() - const marker = join(d, 'fired') - const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "continue please" >&2\nexit 2\n`) - const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please') - }) - - it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => { - // A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces - // continuation; the script self-limits to one block to avoid a loop. - const d = dir() - const marker = join(d, 'fired') - const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) - const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - // A second model request ran → the empty-reason block forced continuation. - expect(adapter.requests).toHaveLength(2) - // The steering carried the fallback reason (no stderr to use). - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') - }) - - it('SubagentStart additionalContext is injected into a REGISTERED live child', async () => { - const d = dir() - const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n') - const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) - const ctx = await harness(path, new MockAdapter([])) - // Register a fake child agent under the id the event carries. - const injected: string[] = [] - const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] - ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') }) - await waitFor(() => injected.includes('child guidance')) - expect(injected).toContain('child guidance') - }) - - it('a throwing SubagentStart/SubagentStop hook run is contained (logged)', async () => { - const d = dir() - // A hook command that does not exist makes runHook resolve a non-blocking - // error (not a throw), so to hit the .catch we make the .then throw: register - // a child whose inject throws for SubagentStart. - const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n') - const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) - const ctx = await harness(path, new MockAdapter([])) - const warn = vi.fn(); ctx.logger.warn = warn as never - const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] - ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') }) - await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) - }) -}) - -describe('hooks-claude coverage — default reasons + sparse payloads', () => { - it('PreToolUse deny with EMPTY stderr uses the default reason', async () => { - const d = dir() - const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') // exit 2, no stderr - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) - }) - - it('PostToolUse deny with EMPTY stderr + no context uses the default feedback', async () => { - const d = dir() - const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') - const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) - }) - - it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => { - const d = dir() - // The agents registry has no entry for the id, so the child lookup yields - // undefined and the payload falls back to base(undefined) — assert the - // observe-only SubagentStop run still executes the hook without crashing. - const marker = join(d, 'stopran') - const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) - const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) - const ctx = await harness(path, new MockAdapter([])) - ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) - await waitFor(() => existsSync(marker)) - expect(existsSync(marker)).toBe(true) - }) -}) - -describe('hooks-claude coverage — more default/sparse arms', () => { - it('UserPromptSubmit deny with EMPTY stderr uses the default block reason', async () => { - const d = dir() - const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('no')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const turnEnd = events(agent).findLast(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook') - }) - - it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => { - const d = dir() - const s = sh(d, 'ask.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}\'\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - // ask (no reason) → degrades to deny with the registry's generic message. - expect(ran).toBe(false) - expect(events(agent).some(e => e.type === 'tool/result' && e.data.isError)).toBe(true) - }) - - it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => { - const d = dir() - const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) - expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) - }) -}) - -describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => { - it('a direct apply() (schema bypass) with only configPath runs', async () => { - const d = dir() - const marker = join(d, 'ran') - const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - // Direct apply with only configPath — bypasses schemastery's defaults, so - // the bridge must run on the raw minimal config (the per-hook timeout is - // the protocol lib's reference default, not a config knob). - HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) - ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(existsSync(marker)).toBe(true) - }) - - it('a non-zero non-2 hook exit (e.g. a command-not-found 127) is a non-blocking error; the tool still runs', async () => { - const d = dir() - // `bash -c` of a missing program exits 127 — a non-blocking error (not 0, not - // 2 → no decision), so the tool proceeds; the hook/result records exit 127. - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: '/nonexistent/definitely/not/a/command' }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(ran).toBe(true) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.exitCode).toBe(127) - }) - - it('a PostToolUse deny with empty stderr + no context uses the default feedback (no context arm)', async () => { - const d = dir() - const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') - const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.isError).toBe(true) - }) -}) - -describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => { - it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => { - // The seams cannot yet honor `continue:false` as a hard halt. The log must still record the - // stop decision while execution and the turn continue normally. - const d = dir() - const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded - expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) - const turnEnd = events(agent).findLast(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') // ran to completion - }) - - it('a PostToolUse hook that BOTH blocks AND attaches additionalContext', async () => { - const d = dir() - const s = sh(d, 'b.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"context too"}}\'\n') - const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.isError).toBe(true) - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) - // additionalContext also injected (the block + context arm). - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) - }) - - it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => { - // The block's hookEventName (UserPromptSubmit) mismatches the firing event - // (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs. - const d = dir() - const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran - }) - - it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => { - // The default ACP wiring sets no projectDir. A stock CC hook that references - // $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace, - // not an empty string. The hook echoes the var as additionalContext. - const d = dir() - const workspace = dir() - const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ran')]) - const ctx = await harness(path, adapter) // NB: no projectDir - // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). - const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) - expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) - await handle.dispose() - }) - - it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { - // A context-only hook delegates with `next()` and folds its context, so a downstream policy - // listener can still veto the prompt. - const d = dir() - const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('should not run')]) - const ctx = await harness(path, adapter) - // A later listener that blocks every prompt (registered AFTER the bridge). - const { AgentId: AId } = await import('@deepseek-ai/dsh-agent') - ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) - const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - // the downstream block won: the model was never called, no user/message was - // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` - expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(e => e.type === 'user/message')).toBe(false) - const turnEnd = events(agent).findLast(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) - }) - - it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => { - // Both the bridge hook and a later prompt-submit listener attach context; the - // request must see BOTH (concatContext keeps the downstream one too). - const d = dir() - const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(path, adapter) - ctx.on('agent/prompt-submit', async () => ({ - kind: 'allow' as const, - content: [{ type: 'text' as const, text: 'rewritten-prompt' }], - additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, - })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const req = JSON.stringify(adapter.requests[0]!.messages) - expect(req).toContain('from-bridge') - expect(req).toContain('from-downstream') - expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved - // the original prompt was replaced by the downstream rewrite - const userMsg = events(agent).find(e => e.type === 'user/message') - expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true) - }) - - it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { - // The bridge hook adds context; a later post-execute listener accepts with a - // content rewrite. Both the rewrite and the bridge context survive. - const d = dir() - const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') - const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) - }) - - it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { - // The bridge hook only adds context; a later post-execute listener blocks the - // result. The block wins AND carries the bridge context (concatContext on the - // block arm). - const d = dir() - const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') - const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.isError).toBe(true) - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) - // the bridge's context still landed (folded onto the block) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) - }) - -}) - -describe('hooks-claude coverage — executor reject + no-open-turn', () => { - it('when the bash executor REJECTS a hook run, the hook/result omits exitCode (non-blocking)', async () => { - const d = dir() - const s = sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - // Force the executor to reject (an infrastructure fault) so runHook's catch - // yields a HookOutput with exitCode undefined → the `exitCode` spread false arm. - const bash = ctx.bash - bash.run = (() => Promise.reject(new Error('executor down'))) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) - }) - -}) - -describe('hooks-claude coverage — detached-listener catch handlers', () => { - it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => { - const d = dir() - const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') - const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // Make inject throw, forcing the SessionStart .catch path. - const original = agent.inject.bind(agent) - let threw = false - agent.inject = (() => { threw = true; throw new Error('inject boom') }) - await waitFor(() => threw) - expect(threw).toBe(true) - agent.inject = original - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject - }) -}) - -describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => { - it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => { - // The server launch directory and session cwd deliberately differ. The marker proves the - // bridge passes `session/new.cwd` instead of falling back to the executor default. - const serverDir = dir() - const sessionDir = dir() - const marker = join(sessionDir, 'where') - // The hook is invoked with cwd = session dir, so a relative marker path lands there. - hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - // Executor default cwd = serverDir (deliberately NOT the session cwd). - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) - await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) - ctx.llm.registerAdapter(['mock'], adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - - const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) - - expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir - const { readFileSync } = await import('node:fs') - const where = readFileSync(marker, 'utf8').trim() - // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. - expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true) - await handle.dispose() - }) - - it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => { - // `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint` - // receives that agent and runs in the child's cwd rather than the executor default. - const serverDir = dir() - const childDir = dir() - const marker = join(childDir, 'stopwhere') - hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] }) - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - // Executor default cwd = serverDir (deliberately NOT the child session cwd). - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) - await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) - ctx.llm.registerAdapter(['mock'], new MockAdapter([])) - - // Register a live child on its own session cwd; emit subagent/end with its id. - const { SessionId } = await import('@deepseek-ai/dsh-session') - const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) - ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) - - await waitFor(() => existsSync(marker)) - expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir - const { readFileSync } = await import('node:fs') - const where = readFileSync(marker, 'utf8').trim() - // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. - expect(where.endsWith(childDir.split('/').pop()!)).toBe(true) - await childHandle.dispose() - }) -}) - -describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => { - it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => { - const d = dir() - const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(path, adapter) - const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) - // Not surfaced: the systemMessage text never reaches the model request. - expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') - }) -}) - -describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => { - it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => { - // Session-start injection is detached, so an immediate prompt need not observe it. Assert only - // the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race. - const d = dir() - const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n') - const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // Send immediately — do NOT wait for the session-start inject. - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing - }) -}) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts new file mode 100644 index 0000000000..b107b09856 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -0,0 +1,574 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +const dirs: string[] = [] +afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) +function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d } +function sh(d: string, name: string, body: string): string { + const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p +} +function hooks(d: string, h: unknown): string { + writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') +} + +async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise { + const ctx = new Context() + await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) +} +function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +/** Poll until `predicate` holds or the deadline passes — robust to detached + * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ +async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') + await new Promise(r => setTimeout(r, interval)) + } +} + +export type CoverageGroup = 'prompt' | 'post-tool' | 'result-shape' | 'edge-paths' | 'payload' + +/** Register independently schedulable slices of the hooks-codex coverage matrix. */ +export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGroup[]): void { + const selected = new Set(typeof groups === 'string' ? [groups] : groups) + if (selected.has('prompt')) describe('hooks-codex coverage — prompt decision mapping', () => { + it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([textResponse('no')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(0) + const te = events(agent).findLast(e => e.type === 'turn/end') + expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected') + }) + + it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') + }) + + it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { + // Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a + // downstream policy listener can still block. + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(0) + expect(events(agent).some(e => e.type === 'user/message')).toBe(false) + const te = events(agent).findLast(e => e.type === 'turn/end') + expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) + }) + + it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.on('agent/prompt-submit', async () => ({ + kind: 'allow' as const, + content: [{ type: 'text' as const, text: 'rewritten-prompt' }], + additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const req = JSON.stringify(adapter.requests[0]!.messages) + expect(req).toContain('from-bridge') + expect(req).toContain('from-downstream') + expect(req).toContain('rewritten-prompt') + }) + }) + + if (selected.has('post-tool')) describe('hooks-codex coverage — post-tool and session context mapping', () => { + it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + }) + + it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + }) + + it('SessionStart additionalContext is injected for the first request', async () => { + const d = dir() + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => events(agent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') + }) + + it('PostToolUse block (exit 2) → isError feedback; default reason', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const r = events(agent).find(e => e.type === 'tool/result') + expect(r?.type === 'tool/result' && r.data.isError).toBe(true) + expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) + }) + + it('PostToolUse additionalContext (clean exit) is attached after the result', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) + }) + }) + + if (selected.has('result-shape')) describe('hooks-codex coverage — hook result shape and configuration', () => { + it('PreToolUse for a tool call WITHOUT a command arg passes an empty command (commandOf non-object/missing arm)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pre.sh', '#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' + }) + + it('a clean exit-0 hook records exitCode 0 and omits stderrSummary', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) + expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) + }) + + it('a long stderr is truncated in the hook/result summary', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) + expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis + }) + + it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { + const d = dir() + hooks(d, {}) + for (const bad of [0, -5, 1.5, Number.NaN]) { + const adapter = new MockAdapter([]) + await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad })) + .rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/) + } + }) + + it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') + }) + + it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => { + const d = dir() + const marker = join(d, 'ran') + hooks(d, { UserPromptSubmit: [{ hooks: [ + { type: 'command', command: 'bg.sh', async: true }, // skipped → warn + { type: 'command', command: sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) }, + ] }] }) + const warn = vi.fn() + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = new Context() + await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + ctx.logger.warn = warn as never + // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. + HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(existsSync(marker)).toBe(true) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) + }) + + it('a no-op clean hook proceeds (contextFrom empty → next)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) + }) + + it('SessionStart with no additionalContext is a no-op (contextFrom empty)', async () => { + const d = dir() + // The hook touches a marker so we can wait for it to ACTUALLY FINISH before + // asserting absence — a completed turn alone would not prove the detached + // session-start hook ran, making the absence check a false pass. + const marker = join(d, 'ss-ran') + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => existsSync(marker)) // the clean no-output hook has finished + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + }) + + it('a throwing SessionStart inject is contained (logged)', async () => { + const d = dir() + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const warn = vi.fn(); ctx.logger.warn = warn as never + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.inject = (() => { throw new Error('inject boom') }) + await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed'))) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) + }) + }) + + if (selected.has('edge-paths')) describe('hooks-codex coverage — matching and no-agent edge paths', () => { + it('a clean PreToolUse with no decision allows the tool (no deny)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'ok.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) + }) + + it('a non-matching regex matcher skips the hook (matchesMatcher false → continue)', async () => { + const d = dir() + // /^Edit$/ does not match the tool name "Bash" → the group is skipped. + hooks(d, { PreToolUse: [{ matcher: '^Edit$', hooks: [{ type: 'command', command: sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded + expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) + }) + + it('a {"continue":false} hook is RECORDED as "stop" but does not halt the run (TODO(hook-continue-false))', async () => { + // Honoring `continue:false` is deferred — the seams have no hard-halt + // primitive. Assert the LOG records the halt request AND that the run is not + // actually halted (the tool still runs, the turn completes). + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded + expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) + }) + + it('PreToolUse deny with EMPTY stderr uses the default reason (?? right arm)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const r = events(agent).find(e => e.type === 'tool/result') + expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) + }) + + it('PostToolUse block AND additionalContext are surfaced together', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const r = events(agent).find(e => e.type === 'tool/result') + expect(r?.type === 'tool/result' && r.data.isError).toBe(true) + expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) + }) + + it('commandOf reads a non-string command arg as an empty command', async () => { + const d = dir() + // The tool-call arguments carry `command` as a NUMBER → commandOf's + // `typeof command === 'string'` false arm → '' (the payload's tool_input.command). + const cap = join(d, 'payload') + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } + expect(payload.tool_input.command).toBe('') + }) + + it('a no-agent direct PreToolUse run uses process.cwd() and turn 0 (no session to record)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + const { CallId } = await import('@deepseek-ai/dsh-llm') + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + expect(ran).toBe(false) // denied + expect(result.isError).toBe(true) + }) + + it('a no-agent direct PostToolUse run attaches context with no session to record', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] }) + const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const { CallId } = await import('@deepseek-ai/dsh-llm') + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + expect(result.isError).toBeFalsy() + expect(result.additionalContext?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true) + }) + + it('when the bash executor REJECTS, the hook/result omits exitCode (non-blocking)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.bash.run = (() => Promise.reject(new Error('executor down'))) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) + }) + }) + + if (selected.has('payload')) describe('hooks-codex coverage — continuation, payload, and cwd mapping', () => { + it('a blocking Stop hook with EMPTY stderr still forces continuation (no reason required)', async () => { + // Regression: an exit-2 Stop hook with no stderr yields decision 'deny' + + // reason undefined; the turn must STILL force-continue, not silently stop. + const d = dir() + const marker = join(d, 'fired') + hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] }) + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') + }) + + it('a clean UserPromptSubmit hook that prints PLAIN stdout injects it as context', async () => { + // Codex feeds a SessionStart/UserPromptSubmit hook's PLAIN (non-JSON) stdout + // as additionalContext (unlike CC, which needs a JSON hookSpecificOutput). + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') + }) + + it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => { + // SessionStart cannot block, but non-clean stdout still must not become context. The marker + // waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches + // the codec's structured-stdout rule. + const d = dir() + const marker = join(d, 'ran') + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => existsSync(marker)) // the exit-2 hook has finished + expect(events(agent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) + }) + + it('a UserPromptSubmit hook with a non-blocking error exit (1) + stdout does NOT inject it', async () => { + // Exit 1 is a non-blocking error (no decision), so the prompt is NOT blocked + // and the handler falls through to the context path — the gate must still + // suppress the error hook's stdout ("stale" never reaches the model). + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') + }) + + it('a clean SessionStart hook that prints PLAIN stdout injects it (not JSON)', async () => { + const d = dir() + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => events(agent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') + }) + + it('a clean hook that prints JSON is NOT injected as prose (plain-stdout gate)', async () => { + // A structured (JSON) stdout must go through the hookSpecificOutput path, not + // be dumped verbatim as context — the `!startsWith('{')` gate guards this. + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') + }) + + it('the PreToolUse payload carries the REAL tool name (matches the matcher subject)', async () => { + // Regression: the payload once hardcoded tool_name "Bash", disagreeing with + // the exec.name matcher subject — a config matcher on the real name would + // then never fire. Capture the payload and assert tool_name === the real name. + const d = dir() + const cap = join(d, 'payload') + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } + expect(payload.tool_name).toBe('shell') + expect(payload.tool_input.command).toBe('ls') + }) + + it('a Codex matcher on the REAL tool name fires (matcher subject === payload tool_name)', async () => { + // A regex matcher matching the real tool name must select the hook — proving + // the matcher subject and the payload tool_name agree. + const d = dir() + hooks(d, { PreToolUse: [{ matcher: 'shell', hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(false) // the matcher fired → the hook denied the tool + expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) + }) + + it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const warn = vi.fn(); ctx.logger.warn = warn as never + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') + }) + + it('runs an agent-scoped hook in the session cwd, not the executor default', async () => { + // Same regression as the CC bridge: the Codex bridge must thread the session + // cwd as the hook workdir. Executor default = serverDir; session cwd = + // sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir. + const serverDir = dir() + const sessionDir = dir() + const marker = join(sessionDir, 'where') + hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = new Context() + await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) + await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const { SessionId } = await import('@deepseek-ai/dsh-session') + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + handle.agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, handle.agent as ReactLoopAgent) + expect(existsSync(marker)).toBe(true) + expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) + await handle.dispose() + }) + }) +} diff --git a/packages/hooks/hooks-codex/tests/coverage-post-tool.spec.ts b/packages/hooks/hooks-codex/tests/coverage-post-tool.spec.ts new file mode 100644 index 0000000000..0cd39dbe20 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/coverage-post-tool.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases(['post-tool', 'payload']) diff --git a/packages/hooks/hooks-codex/tests/coverage-prompt.spec.ts b/packages/hooks/hooks-codex/tests/coverage-prompt.spec.ts new file mode 100644 index 0000000000..be18c719f6 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/coverage-prompt.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases(['prompt', 'edge-paths']) diff --git a/packages/hooks/hooks-codex/tests/coverage-result-shape.spec.ts b/packages/hooks/hooks-codex/tests/coverage-result-shape.spec.ts new file mode 100644 index 0000000000..9546872e3c --- /dev/null +++ b/packages/hooks/hooks-codex/tests/coverage-result-shape.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases('result-shape') diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts deleted file mode 100644 index c287d86b23..0000000000 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ /dev/null @@ -1,560 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' - -const dirs: string[] = [] -afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) -function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d } -function sh(d: string, name: string, body: string): string { - const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p -} -function hooks(d: string, h: unknown): string { - writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') -} - -async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise { - const ctx = new Context() - await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) - ctx.llm.registerAdapter(['mock'], adapter) - return ctx -} -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { - return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) -} -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } -/** Poll until `predicate` holds or the deadline passes — robust to detached - * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ -async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { - const deadline = Date.now() + timeout - while (!predicate()) { - if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') - await new Promise(r => setTimeout(r, interval)) - } -} - -describe('hooks-codex coverage — decision mapping paths', () => { - it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => { - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const adapter = new MockAdapter([textResponse('no')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(0) - const te = events(agent).findLast(e => e.type === 'turn/end') - expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected') - }) - - it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => { - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') - }) - - it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { - // Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a - // downstream policy listener can still block. - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('should not run')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(e => e.type === 'user/message')).toBe(false) - const te = events(agent).findLast(e => e.type === 'turn/end') - expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) - }) - - it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => { - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/prompt-submit', async () => ({ - kind: 'allow' as const, - content: [{ type: 'text' as const, text: 'rewritten-prompt' }], - additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, - })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const req = JSON.stringify(adapter.requests[0]!.messages) - expect(req).toContain('from-bridge') - expect(req).toContain('from-downstream') - expect(req).toContain('rewritten-prompt') - }) - - it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { - const d = dir() - hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) - }) - - it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { - const d = dir() - hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.isError).toBe(true) - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) - }) - - it('SessionStart additionalContext is injected for the first request', async () => { - const d = dir() - hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'context/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') - }) - - it('PostToolUse block (exit 2) → isError feedback; default reason', async () => { - const d = dir() - hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const r = events(agent).find(e => e.type === 'tool/result') - expect(r?.type === 'tool/result' && r.data.isError).toBe(true) - expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) - }) - - it('PostToolUse additionalContext (clean exit) is attached after the result', async () => { - const d = dir() - hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) - }) - - it('PreToolUse for a tool call WITHOUT a command arg passes an empty command (commandOf non-object/missing arm)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pre.sh', '#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' - }) - - it('a clean exit-0 hook records exitCode 0 and omits stderrSummary', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) - expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) - }) - - it('a long stderr is truncated in the hook/result summary', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) - expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis - }) - - it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { - const d = dir() - hooks(d, {}) - for (const bad of [0, -5, 1.5, Number.NaN]) { - const adapter = new MockAdapter([]) - await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad })) - .rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/) - } - }) - - it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') - }) - - it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => { - const d = dir() - const marker = join(d, 'ran') - hooks(d, { UserPromptSubmit: [{ hooks: [ - { type: 'command', command: 'bg.sh', async: true }, // skipped → warn - { type: 'command', command: sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) }, - ] }] }) - const warn = vi.fn() - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = new Context() - await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - ctx.logger.warn = warn as never - // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. - HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) - ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(existsSync(marker)).toBe(true) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) - }) - - it('a no-op clean hook proceeds (contextFrom empty → next)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(ran).toBe(true) - }) - - it('SessionStart with no additionalContext is a no-op (contextFrom empty)', async () => { - const d = dir() - // The hook touches a marker so we can wait for it to ACTUALLY FINISH before - // asserting absence — a completed turn alone would not prove the detached - // session-start hook ran, making the absence check a false pass. - const marker = join(d, 'ss-ran') - hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - await waitFor(() => existsSync(marker)) // the clean no-output hook has finished - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'context/message')).toBe(false) - }) - - it('a throwing SessionStart inject is contained (logged)', async () => { - const d = dir() - hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.inject = (() => { throw new Error('inject boom') }) - await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed'))) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) - }) - - it('a clean PreToolUse with no decision allows the tool (no deny)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'ok.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(ran).toBe(true) - }) - - it('a non-matching regex matcher skips the hook (matchesMatcher false → continue)', async () => { - const d = dir() - // /^Edit$/ does not match the tool name "Bash" → the group is skipped. - hooks(d, { PreToolUse: [{ matcher: '^Edit$', hooks: [{ type: 'command', command: sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded - expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) - }) - - it('a {"continue":false} hook is RECORDED as "stop" but does not halt the run (TODO(hook-continue-false))', async () => { - // Honoring `continue:false` is deferred — the seams have no hard-halt - // primitive. Assert the LOG records the halt request AND that the run is not - // actually halted (the tool still runs, the turn completes). - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded - expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) - }) - - it('PreToolUse deny with EMPTY stderr uses the default reason (?? right arm)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const r = events(agent).find(e => e.type === 'tool/result') - expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) - }) - - it('PostToolUse block AND additionalContext are surfaced together', async () => { - const d = dir() - hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const r = events(agent).find(e => e.type === 'tool/result') - expect(r?.type === 'tool/result' && r.data.isError).toBe(true) - expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) - }) - - it('commandOf reads a non-string command arg as an empty command', async () => { - const d = dir() - // The tool-call arguments carry `command` as a NUMBER → commandOf's - // `typeof command === 'string'` false arm → '' (the payload's tool_input.command). - const cap = join(d, 'payload') - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } - expect(payload.tool_input.command).toBe('') - }) - - it('a no-agent direct PreToolUse run uses process.cwd() and turn 0 (no session to record)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) - expect(ran).toBe(false) // denied - expect(result.isError).toBe(true) - }) - - it('a no-agent direct PostToolUse run attaches context with no session to record', async () => { - const d = dir() - hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] }) - const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) - expect(result.isError).toBeFalsy() - expect(result.additionalContext?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true) - }) - - it('when the bash executor REJECTS, the hook/result omits exitCode (non-blocking)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.bash.run = (() => Promise.reject(new Error('executor down'))) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) - }) - - it('a blocking Stop hook with EMPTY stderr still forces continuation (no reason required)', async () => { - // Regression: an exit-2 Stop hook with no stderr yields decision 'deny' + - // reason undefined; the turn must STILL force-continue, not silently stop. - const d = dir() - const marker = join(d, 'fired') - hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] }) - const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') - }) - - it('a clean UserPromptSubmit hook that prints PLAIN stdout injects it as context', async () => { - // Codex feeds a SessionStart/UserPromptSubmit hook's PLAIN (non-JSON) stdout - // as additionalContext (unlike CC, which needs a JSON hookSpecificOutput). - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') - }) - - it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => { - // SessionStart cannot block, but non-clean stdout still must not become context. The marker - // waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches - // the codec's structured-stdout rule. - const d = dir() - const marker = join(d, 'ran') - hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - await waitFor(() => existsSync(marker)) // the exit-2 hook has finished - expect(events(agent).some(e => e.type === 'context/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) - }) - - it('a UserPromptSubmit hook with a non-blocking error exit (1) + stdout does NOT inject it', async () => { - // Exit 1 is a non-blocking error (no decision), so the prompt is NOT blocked - // and the handler falls through to the context path — the gate must still - // suppress the error hook's stdout ("stale" never reaches the model). - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran - expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') - }) - - it('a clean SessionStart hook that prints PLAIN stdout injects it (not JSON)', async () => { - const d = dir() - hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'context/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') - }) - - it('a clean hook that prints JSON is NOT injected as prose (plain-stdout gate)', async () => { - // A structured (JSON) stdout must go through the hookSpecificOutput path, not - // be dumped verbatim as context — the `!startsWith('{')` gate guards this. - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') - }) - - it('the PreToolUse payload carries the REAL tool name (matches the matcher subject)', async () => { - // Regression: the payload once hardcoded tool_name "Bash", disagreeing with - // the exec.name matcher subject — a config matcher on the real name would - // then never fire. Capture the payload and assert tool_name === the real name. - const d = dir() - const cap = join(d, 'payload') - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } - expect(payload.tool_name).toBe('shell') - expect(payload.tool_input.command).toBe('ls') - }) - - it('a Codex matcher on the REAL tool name fires (matcher subject === payload tool_name)', async () => { - // A regex matcher matching the real tool name must select the hook — proving - // the matcher subject and the payload tool_name agree. - const d = dir() - hooks(d, { PreToolUse: [{ matcher: 'shell', hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(ran).toBe(false) // the matcher fired → the hook denied the tool - expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) - }) - - it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => { - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) - expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') - }) - - it('runs an agent-scoped hook in the session cwd, not the executor default', async () => { - // Same regression as the CC bridge: the Codex bridge must thread the session - // cwd as the hook workdir. Executor default = serverDir; session cwd = - // sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir. - const serverDir = dir() - const sessionDir = dir() - const marker = join(sessionDir, 'where') - hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = new Context() - await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) - await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) - ctx.llm.registerAdapter(['mock'], adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) - expect(existsSync(marker)).toBe(true) - expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) - await handle.dispose() - }) -})