feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F)
The two bridge plugins that run a user's existing Claude Code / Codex hook
config on the harness's typed interception seams, built on the shared
dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power
tool: anything it does a native cordis plugin does more powerfully — the
bridge exists only to run UNMODIFIED external hooks.
- dsh-hooks-claude: CC dialect. Seven hook points (SessionStart,
UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart,
SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/
${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher.
- dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points,
always-regex matcher, snake_case payloads (turn_id/model, no trailing
newline), no env/substitution, block-only decisions.
Both map the neutral merged outcome onto the seam's typed Decision and stamp
an explicit {kind:'plugin'} source on injected context (so it is never
mislabeled as a user prompt). Config parse-failure is contained; only command
hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop
loop-guard is deferred (TODO).
Tests: per-file 100% — config-parse unit branches + per-seam mappings
end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted
mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot
scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt
end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a
with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash
(verified on disk). The snapshot normalizer now scrubs hook/result.durationMs.
RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
This commit is contained in:
335
packages/hooks/hooks-claude/tests/bridge.spec.ts
Normal file
335
packages/hooks/hooks-claude/tests/bridge.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL
|
||||
* bash executor, and the REAL `dsh-hooks-claude` bridge runs REAL shell hook
|
||||
* scripts written to a temp dir — only the model is mocked (the "prefer the real
|
||||
* implementation" rule). Each test writes a `hooks.json` + executable scripts,
|
||||
* loads the bridge pointed at them, and asserts the hook's effect on the loop.
|
||||
*/
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
|
||||
|
||||
/** Write a hooks.json + named executable scripts into a fresh temp dir. */
|
||||
function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks }))
|
||||
for (const [name, body] of Object.entries(scripts)) {
|
||||
const path = join(dir, name)
|
||||
writeFileSync(path, body)
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
async function harness(configDir: string, adapter: MockAdapter): Promise<Context> {
|
||||
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: join(configDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
it('a UserPromptSubmit hook that exits 2 blocks the prompt (rejected turn)', async () => {
|
||||
// The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const block = join(dir, 'block.sh')
|
||||
writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n')
|
||||
chmodSync(block, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: block }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'do something' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The prompt was blocked: model never called, turn ended rejected.
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('rejected')
|
||||
// The hook ran and was recorded.
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'UserPromptSubmit')).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'hook/result' && e.data.decision === 'block')).toBe(true)
|
||||
})
|
||||
|
||||
it('a UserPromptSubmit hook printing additionalContext injects it for the model', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const ctxScript = join(dir, 'ctx.sh')
|
||||
writeFileSync(ctxScript, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"remember: be brief"}}\'\n')
|
||||
chmodSync(ctxScript, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: ctxScript }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The injected context reached the model and is recorded with the plugin source.
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('remember: be brief')
|
||||
const ctxMsg = events(agent).find(e => e.type === 'context/message')
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude bridge — PreToolUse', () => {
|
||||
it('a matching PreToolUse hook that exits 2 denies the tool (isError result), tool never runs', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const deny = join(dir, 'deny.sh')
|
||||
writeFileSync(deny, '#!/usr/bin/env bash\necho "danger tool blocked" >&2\nexit 2\n')
|
||||
chmodSync(deny, 0o755)
|
||||
// Matcher "danger" (literal) selects only the danger tool.
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'danger', hooks: [{ type: 'command', command: deny }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'use danger' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(ran).toBe(false)
|
||||
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('danger tool blocked'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook whose matcher does NOT match leaves the tool alone', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const deny = join(dir, 'deny.sh')
|
||||
writeFileSync(deny, '#!/usr/bin/env bash\nexit 2\n')
|
||||
chmodSync(deny, 0o755)
|
||||
// Matcher only targets "danger" — the "safe" tool is untouched.
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'danger', hooks: [{ type: 'command', command: deny }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'safe', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'use safe' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(ran).toBe(true)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude bridge — PostToolUse', () => {
|
||||
it('a PostToolUse hook that blocks (exit 2) turns the result into an isError with feedback', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const block = join(dir, 'block.sh')
|
||||
writeFileSync(block, '#!/usr/bin/env bash\necho "output rejected, retry" >&2\nexit 2\n')
|
||||
chmodSync(block, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: block }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
|
||||
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')
|
||||
// PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback.
|
||||
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('output rejected, retry'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PostToolUse hook printing additionalContext attaches it after the tool result', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const s = join(dir, 'ctx.sh')
|
||||
writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"note: tool was slow"}}\'\n')
|
||||
chmodSync(s, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, 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 log = events(agent)
|
||||
const resultIdx = log.findIndex(e => e.type === 'tool/result')
|
||||
const ctxIdx = log.findIndex(e => e.type === 'context/message')
|
||||
expect(ctxIdx).toBeGreaterThan(resultIdx) // context appended AFTER the tool result
|
||||
const ctxMsg = log[ctxIdx]
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const s = join(dir, 'ask.sh')
|
||||
writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"needs approval"}}\'\n')
|
||||
chmodSync(s, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, 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` degrades to deny today (FIXME permissions): the tool does not run and the result is isError.
|
||||
expect(ran).toBe(false)
|
||||
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('needs approval'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude bridge — SessionStart', () => {
|
||||
it('a SessionStart hook injects additionalContext the first request sees', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const s = join(dir, 'start.sh')
|
||||
writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"project uses tabs"}}\'\n')
|
||||
chmodSync(s, 0o755)
|
||||
// matcher 'startup' selects the startup source.
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: s }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
// session-start fires async; wait a tick for the inject before sending.
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => {
|
||||
it('runs SubagentStart and SubagentStop hooks when the subagent lifecycle events fire', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
// Each hook touches a marker file so we can assert it ran (these events are
|
||||
// observe-only — there is no decision to assert, only the side effect).
|
||||
const startMarker = join(dir, 'start-ran')
|
||||
const stopMarker = join(dir, 'stop-ran')
|
||||
const startHook = join(dir, 'start.sh')
|
||||
const stopHook = join(dir, 'stop.sh')
|
||||
writeFileSync(startHook, `#!/usr/bin/env bash\ntouch "${startMarker}"\n`)
|
||||
writeFileSync(stopHook, `#!/usr/bin/env bash\ntouch "${stopMarker}"\n`)
|
||||
chmodSync(startHook, 0o755)
|
||||
chmodSync(stopHook, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
|
||||
SubagentStart: [{ hooks: [{ type: 'command', command: startHook }] }],
|
||||
SubagentStop: [{ hooks: [{ type: 'command', command: stopHook }] }],
|
||||
} }))
|
||||
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(dir, adapter)
|
||||
// Drive the observe-only lifecycle events directly (no real child needed — the
|
||||
// bridge just listens). The agents registry is absent here, so SubagentStart's
|
||||
// child lookup yields undefined and it simply runs the hook.
|
||||
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1'), agentType: 'researcher' })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), agentType: 'researcher', stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
|
||||
// Both hooks run async (detached .then); let them settle.
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
|
||||
const { existsSync } = await import('node:fs')
|
||||
expect(existsSync(startMarker)).toBe(true)
|
||||
expect(existsSync(stopMarker)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude bridge — load resilience', () => {
|
||||
it('a missing config file registers no hooks and does not crash the loop', async () => {
|
||||
const adapter = new MockAdapter([textResponse('fine')])
|
||||
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: '/nonexistent/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)
|
||||
// The turn ran normally — no hooks, no crash.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
|
||||
const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'true' }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
|
||||
await fiber.dispose()
|
||||
// After disposing this second mount, the FIRST mount's listeners still work,
|
||||
// but the disposed one contributed none — assert no leaked listener throws.
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
|
||||
// Postmortem 0001 guard: this plugin HAS `inject = ['bash']`, so a stray
|
||||
// `export default apply` would collapse the module via `unwrapExports`
|
||||
// (`exports.default ?? exports`), DROP `inject`, and crash at load with
|
||||
// "cannot get property … without inject". Guard the shape directly.
|
||||
expect('default' in HooksClaude).toBe(false)
|
||||
expect(HooksClaude.name).toBe('hooks-claude')
|
||||
expect(HooksClaude.inject).toEqual(['bash'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(HooksClaude) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(HooksClaude)
|
||||
expect(unwrapped.name).toBe('hooks-claude')
|
||||
expect(unwrapped.inject).toEqual(['bash'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
66
packages/hooks/hooks-claude/tests/config.spec.ts
Normal file
66
packages/hooks/hooks-claude/tests/config.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts'
|
||||
|
||||
describe('substituteCommand', () => {
|
||||
it('replaces CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR (all occurrences)', () => {
|
||||
expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}/x.sh', { pluginRoot: '/p' })).toBe('/p/x.sh')
|
||||
expect(substituteCommand('${CLAUDE_PROJECT_DIR}/a ${CLAUDE_PROJECT_DIR}/b', { projectDir: '/proj' })).toBe('/proj/a /proj/b')
|
||||
expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}-${CLAUDE_PROJECT_DIR}', { pluginRoot: '/p', projectDir: '/d' })).toBe('/p-/d')
|
||||
})
|
||||
it('leaves the command untouched when no vars are supplied', () => {
|
||||
expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}/x', {})).toBe('${CLAUDE_PLUGIN_ROOT}/x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseClaudeConfig', () => {
|
||||
it('parses a bare event map and a settings-style { hooks: … } wrapper identically', () => {
|
||||
const groups = { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'x.sh' }] }] }
|
||||
const bare = parseClaudeConfig(groups)
|
||||
const wrapped = parseClaudeConfig({ hooks: groups })
|
||||
expect(bare.config).toEqual(wrapped.config)
|
||||
expect(bare.config.PreToolUse).toEqual([{ matcher: 'Bash', hooks: [{ command: 'x.sh' }] }])
|
||||
})
|
||||
|
||||
it('carries timeout → timeoutSec and substitutes the command', () => {
|
||||
const { config } = parseClaudeConfig(
|
||||
{ Stop: [{ hooks: [{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/s.sh', timeout: 30 }] }] },
|
||||
{ pluginRoot: '/p' },
|
||||
)
|
||||
expect(config.Stop).toEqual([{ hooks: [{ command: '/p/s.sh', timeoutSec: 30 }] }])
|
||||
})
|
||||
|
||||
it('skips non-command hooks (recorded) and keeps the command ones in the same group', () => {
|
||||
const { config, skipped } = parseClaudeConfig({
|
||||
PreToolUse: [{ hooks: [
|
||||
{ type: 'prompt', prompt: 'hi' },
|
||||
{ type: 'command', command: 'ok.sh' },
|
||||
{ type: 'http', url: 'http://x' },
|
||||
] }],
|
||||
})
|
||||
expect(config.PreToolUse).toEqual([{ hooks: [{ command: 'ok.sh' }] }])
|
||||
expect(skipped).toEqual([{ event: 'PreToolUse', type: 'prompt' }, { event: 'PreToolUse', type: 'http' }])
|
||||
})
|
||||
|
||||
it('treats a hook with no `type` as a command (CC default)', () => {
|
||||
const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ command: 'd.sh' }] }] })
|
||||
expect(config.Stop).toEqual([{ hooks: [{ command: 'd.sh' }] }])
|
||||
})
|
||||
|
||||
it('drops malformed entries without throwing: non-array groups, non-object group/hook, missing command, empty groups', () => {
|
||||
expect(parseClaudeConfig({ PreToolUse: 'nope' }).config).toEqual({})
|
||||
expect(parseClaudeConfig({ PreToolUse: [42, { hooks: 'no' }, { hooks: [7, { type: 'command' }] }] }).config).toEqual({})
|
||||
// a group whose only hook lacks a command string drops the whole (empty) group
|
||||
expect(parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 5 }] }] }).config).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty for a non-object / null / array top level', () => {
|
||||
expect(parseClaudeConfig(null).config).toEqual({})
|
||||
expect(parseClaudeConfig(42).config).toEqual({})
|
||||
expect(parseClaudeConfig([1, 2]).config).toEqual({})
|
||||
})
|
||||
|
||||
it('omits the matcher key when the group has none (match-all)', () => {
|
||||
const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] })
|
||||
expect('matcher' in config.Stop![0]!).toBe(false)
|
||||
})
|
||||
})
|
||||
405
packages/hooks/hooks-claude/tests/coverage.spec.ts
Normal file
405
packages/hooks/hooks-claude/tests/coverage.spec.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
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 }
|
||||
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
|
||||
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<void> {
|
||||
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] }
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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('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<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x'), agentType: 'r' })
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
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<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') })
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
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 agentType + a rejecting hook run is contained', async () => {
|
||||
const d = dir()
|
||||
// Make the SubagentStop runPoint reject by registering a session whose append
|
||||
// throws — simplest: a hook that emits invalid output is fine; force the
|
||||
// .catch by making the session's append throw via a poisoned agent is hard,
|
||||
// so instead assert the no-agentType payload path runs cleanly (no crash).
|
||||
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' }) // no agentType
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
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 default + unspawnable hook', () => {
|
||||
it('a direct apply() (schema bypass) defaults the timeout and 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
|
||||
// runtime `defaultTimeoutMs ?? 600_000` fallback is exercised.
|
||||
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
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 hook with {"continue":false} and no decision records decision "stop"', async () => {
|
||||
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)
|
||||
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.decision).toBe('stop')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
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 new Promise(r => setTimeout(r, 80))
|
||||
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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user