8870da4313
Round-1 Codex review findings on the bridges: - Stop force-continue (both bridges): a blocking Stop hook with EMPTY stderr yielded decision 'deny' + reason undefined, and the `&& reason !== undefined` guard let the turn STOP — the opposite of a blocking Stop hook. Force-continue on any deny; fall back to a generic steering line when there is no reason. - Codex payload tool_name: hardcoded "Bash" disagreed with the exec.name matcher subject, so a real Codex `matcher:"Bash"` never fired against the harness's lowercase `bash` tool. Use exec.name in both payload builders (matches the matcher subject and the sibling CC bridge). Doc/RFC updated. - Codex plain-stdout context: SessionStart/UserPromptSubmit are documented to treat a clean hook's PLAIN (non-JSON) stdout as additionalContext, but nothing folded it. runPoint now folds plain stdout into context for those two events, gated on the codec's JSON gate so structured stdout is never dumped as prose. - continue:false is deferred, not honored: the seams have no hard-halt primitive yet. TODO(hook-continue-false) at both bridges + an RFC deferred note; the two tests now assert the LOG records the halt request AND that the run is NOT actually halted (no longer misleading). - README concurrency wording: hooks run SERIALLY (deliberate — adjacent invoked/result log pairs, order-independent fold), not concurrently. Fixed the CC README claim + an RFC note. Regression guards proven red on the unfixed code, then reverted. The mismatched- hookEventName discard (also flagged) is fixed in dsh-hook-protocol and merged down.
433 lines
25 KiB
TypeScript
433 lines
25 KiB
TypeScript
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('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => {
|
|
// Regression: a blocking Stop hook (exit 2) with no stderr yields decision
|
|
// 'deny' + reason undefined; the turn must STILL force-continue (the block is
|
|
// what matters), not silently stop. Self-limit to one block so it can't 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<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 {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
|
|
// Honoring `continue:false` (hard-halt the whole run) is deferred — there is
|
|
// no such primitive on the interception seams yet. So this asserts the LOG
|
|
// faithfully records the halt request (decision "stop"), AND that the run is
|
|
// NOT actually halted: the tool still runs and the turn completes 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)
|
|
})
|
|
|
|
})
|
|
|
|
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
|
|
})
|
|
})
|