2026-07-06 23:27:54 +08:00
import { afterEach , describe , expect , it , vi } from 'vitest'
2026-07-07 00:02:10 +08:00
import { chmodSync , existsSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from 'node:fs'
2026-07-01 04:22:00 +08:00
import { tmpdir } from 'node:os'
import { join } from 'node:path'
2026-07-06 23:27:54 +08:00
import { Context , type Fiber } from 'cordis'
2026-07-01 04:22:00 +08:00
import Loader from '@cordisjs/plugin-loader'
2026-07-18 12:21:15 +08:00
import { SessionId , type SessionEvent } from '@deepseek-ai/dsh-session'
2026-07-21 03:08:35 +08:00
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
2026-07-18 12:33:49 +08:00
import type { Agent } from '@deepseek-ai/dsh-agent'
2026-07-14 02:32:35 +08:00
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
2026-07-16 17:39:53 +08:00
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
2026-07-01 04:22:00 +08:00
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
2026-07-26 12:43:14 +08:00
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
2026-07-19 22:13:50 +08:00
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import SubagentService , { SubagentRunId } from '@deepseek-ai/dsh-subagent'
2026-07-01 04:22:00 +08:00
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 } ) } )
2026-07-19 22:13:50 +08:00
function subagentCarrier ( ctx : Context ) {
return scopeTarget ( ctx as unknown as SubagentService , undefined )
}
2026-07-01 04:22:00 +08:00
/** 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 , 0 o755 )
}
return dir
}
2026-07-28 02:43:51 -07:00
async function harness ( configDir : string , adapter : MockAdapter , beforeHooks ? : ( ctx : Context ) = > void ) : Promise < Context > {
return ( await harnessWithFiber ( configDir , adapter , beforeHooks ) ) . ctx
2026-07-06 23:27:54 +08:00
}
/** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */
2026-07-28 02:43:51 -07:00
async function harnessWithFiber (
configDir : string ,
adapter : MockAdapter ,
beforeHooks ? : ( ctx : Context ) = > void ,
) : Promise < { ctx : Context ; hooks : Fiber } > {
2026-07-01 04:22:00 +08:00
const ctx = new Context ( )
2026-07-16 17:39:53 +08:00
await mountAgentLoopTestDependencies ( ctx )
2026-07-01 04:22:00 +08:00
await ctx . plugin ( AgentLoop , { agents : [ ] } )
2026-07-26 12:43:14 +08:00
await ctx . plugin ( LocalSubprocessService )
2026-07-01 04:22:00 +08:00
await ctx . plugin ( LocalBashExecutor , { timeoutMs : 10_000 } )
2026-07-28 02:43:51 -07:00
beforeHooks ? . ( ctx )
2026-07-06 23:27:54 +08:00
const hooks = await ctx . plugin ( HooksClaude , { configPath : join ( configDir , 'hooks.json' ) } )
2026-07-01 04:22:00 +08:00
ctx . llm . registerAdapter ( [ 'mock' ] , adapter )
2026-07-06 23:27:54 +08:00
return { ctx , hooks }
2026-07-01 04:22:00 +08:00
}
2026-07-24 21:18:48 +08:00
function waitForIdle ( _ctx : Context , agent : Agent ) : Promise < void > {
return agent . whenIdle ( )
2026-07-01 04:22:00 +08:00
}
2026-07-14 02:32:35 +08:00
function events ( agent : Agent ) : SessionEvent [ ] {
2026-07-01 04:22:00 +08:00
return [ . . . agent . session . events ]
}
2026-07-01 11:25:54 +08:00
/**
* Poll `predicate` until it returns true or the deadline passes. Detached
* emit-listener hooks (session-start, subagent) fire on a `.then` the test can't
* await directly; polling for the observable EFFECT is robust under load, where a
* single fixed sleep flakes ("async state is not synchronous state").
*/
async function waitFor ( predicate : ( ) = > boolean , timeout = 5000 , interval = 10 ) : Promise < void > {
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 ) )
}
}
2026-07-01 04:22:00 +08:00
describe ( 'hooks-claude bridge — UserPromptSubmit' , ( ) = > {
2026-07-24 21:18:48 +08:00
it ( 'a UserPromptSubmit hook that exits 2 rejects admission without a turn' , async ( ) = > {
2026-07-01 04:22:00 +08:00
// 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 , 0 o755 )
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 )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-24 22:38:50 +08:00
agent . followup ( { content : [ { type : 'text' , text : 'do something' } ] , source : { kind : 'user' } } )
2026-07-01 04:22:00 +08:00
await waitForIdle ( ctx , agent )
2026-07-24 21:18:48 +08:00
// The prompt was blocked before the model and before a turn opened.
2026-07-01 04:22:00 +08:00
expect ( adapter . requests ) . toHaveLength ( 0 )
2026-07-24 21:18:48 +08:00
expect ( events ( agent ) . some ( e = > e . type === 'turn/start' ) ) . toBe ( false )
2026-07-24 22:38:50 +08:00
// Admission has no open turn in which turn-scoped hook provenance could live.
expect ( events ( agent ) . some ( e = > e . type === 'hook/invoked' || e . type === 'hook/result' ) ) . toBe ( false )
2026-07-01 04:22:00 +08:00
} )
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 , 0 o755 )
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 )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-24 22:38:50 +08:00
agent . followup ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } )
2026-07-01 04:22:00 +08:00
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' )
2026-07-23 19:15:45 +08:00
const ctxMsg = events ( agent ) . find ( e = > e . type === 'user/message' && e . data . source . kind !== 'user' )
expect ( ctxMsg ? . type === 'user/message' && ctxMsg . data . source ) . toEqual ( { kind : 'plugin' , plugin : 'hooks-claude' } )
2026-07-01 04:22:00 +08:00
} )
} )
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 , 0 o755 )
// 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
2026-07-21 03:08:35 +08:00
ctx . tools . register ( defineContentToolFixture ( { name : 'danger' , description : 'd' , parameters : { } , async execute() { ran = true ; return [ { type : 'text' , text : 'should not run' } ] } } ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-24 22:38:50 +08:00
agent . followup ( { content : [ { type : 'text' , text : 'use danger' } ] , source : { kind : 'user' } } )
2026-07-01 04:22:00 +08:00
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 , 0 o755 )
// 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
2026-07-21 03:08:35 +08:00
ctx . tools . register ( defineContentToolFixture ( { name : 'safe' , description : 's' , parameters : { } , async execute() { ran = true ; return [ { type : 'text' , text : 'ran ok' } ] } } ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-24 22:38:50 +08:00
agent . followup ( { content : [ { type : 'text' , text : 'use safe' } ] , source : { kind : 'user' } } )
2026-07-01 04:22:00 +08:00
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 , 0 o755 )
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 )
2026-07-21 03:08:35 +08:00
ctx . tools . register ( defineContentToolFixture ( { name : 'echo' , description : 'e' , parameters : { } , async execute() { return [ { type : 'text' , text : 'raw output' } ] } } ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-24 22:38:50 +08:00
agent . followup ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } )
2026-07-01 04:22:00 +08:00
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 , 0 o755 )
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 )
2026-07-21 03:08:35 +08:00
ctx . tools . register ( defineContentToolFixture ( { name : 'echo' , description : 'e' , parameters : { } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-24 22:38:50 +08:00
agent . followup ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } )
2026-07-01 04:22:00 +08:00
await waitForIdle ( ctx , agent )
const log = events ( agent )
const resultIdx = log . findIndex ( e = > e . type === 'tool/result' )
2026-07-23 19:15:45 +08:00
const ctxIdx = log . findIndex ( e = > e . type === 'user/message' && e . data . source . kind !== 'user' )
2026-07-01 04:22:00 +08:00
expect ( ctxIdx ) . toBeGreaterThan ( resultIdx ) // context appended AFTER the tool result
const ctxMsg = log [ ctxIdx ]
2026-07-23 19:15:45 +08:00
expect ( ctxMsg ? . type === 'user/message' && ctxMsg . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'tool was slow' ) ) ) . toBe ( true )
2026-07-01 04:22:00 +08:00
} )
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 , 0 o755 )
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
2026-07-21 03:08:35 +08:00
ctx . tools . register ( defineContentToolFixture ( { name : 'echo' , description : 'e' , parameters : { } , async execute() { ran = true ; return [ { type : 'text' , text : 'x' } ] } } ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-24 22:38:50 +08:00
agent . followup ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } )
2026-07-01 04:22:00 +08:00
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 , 0 o755 )
// 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 )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-01 11:25:54 +08:00
// session-start fires async (detached .then → agent.inject); wait for the
2026-07-24 22:38:50 +08:00
// injected user/message to actually land before sending, rather than a
2026-07-01 11:25:54 +08:00
// fixed sleep that flakes under load.
2026-07-23 19:15:45 +08:00
await waitFor ( ( ) = > events ( agent ) . some ( e = > e . type === 'user/message'
2026-07-01 11:25:54 +08:00
&& e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'project uses tabs' ) ) ) )
2026-07-24 22:38:50 +08:00
agent . followup ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } )
2026-07-01 04:22:00 +08:00
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 , 0 o755 )
chmodSync ( stopHook , 0 o755 )
writeFileSync ( join ( dir , 'hooks.json' ) , JSON . stringify ( { hooks : {
SubagentStart : [ { hooks : [ { type : 'command' , command : startHook } ] } ] ,
SubagentStop : [ { hooks : [ { type : 'command' , command : stopHook } ] } ] ,
} } ) )
const adapter = new MockAdapter ( [ ] )
2026-07-06 23:27:54 +08:00
const { ctx , hooks } = await harnessWithFiber ( dir , adapter )
2026-07-01 04:22:00 +08:00
// Drive the observe-only lifecycle events directly (no real child needed — the
2026-07-06 23:27:54 +08:00
// bridge just listens). No child agent is registered, so SubagentStart's
2026-07-01 04:22:00 +08:00
// child lookup yields undefined and it simply runs the hook.
2026-07-19 22:13:50 +08:00
ctx . emit ( subagentCarrier ( ctx ) , 'subagent/start' , { runId : SubagentRunId ( 'run-1' ) , provider : 'inproc' , id : SessionId ( 'child-1' ) , local : false } )
ctx . emit ( subagentCarrier ( ctx ) , 'subagent/end' , { runId : SubagentRunId ( 'run-1' ) , provider : 'inproc' , id : SessionId ( 'child-1' ) , local : false , stopReason : 'completed' , lastAssistantMessage : [ { type : 'text' , text : 'done' } ] } )
2026-07-01 04:22:00 +08:00
2026-07-01 11:25:54 +08:00
// Both hooks run async (detached .then); poll for their marker files rather
// than a fixed sleep that flakes under load.
await waitFor ( ( ) = > existsSync ( startMarker ) && existsSync ( stopMarker ) )
2026-07-01 04:22:00 +08:00
expect ( existsSync ( startMarker ) ) . toBe ( true )
expect ( existsSync ( stopMarker ) ) . toBe ( true )
2026-07-06 23:27:54 +08:00
// The markers prove the hook PROCESSES ran, not that the detached `.then`
// continuations did (`touch` lands before the process exits). Dispose drains
// them, so the no-context arm of the SubagentStart continuation — covered
// only here — executes before this file's coverage snapshot instead of
// racing it (the arm went uncovered on a loaded CI runner and failed the
// per-file 100% branch gate).
await hooks . dispose ( )
} )
it ( 'disposing the bridge aborts a still-running hook and drains to quiescence' , async ( ) = > {
const dir = mkdtempSync ( join ( tmpdir ( ) , 'dsh-hooks-claude-' ) )
dirs . push ( dir )
2026-07-07 00:02:10 +08:00
const pidFile = join ( dir , 'pid' )
2026-07-06 23:27:54 +08:00
const marker = join ( dir , 'started' )
const slowHook = join ( dir , 'slow.sh' )
2026-07-07 00:02:10 +08:00
// Record the hook shell's PID and touch the marker FIRST so the test can
// tell "the hook is genuinely mid-run", then sleep far past the suite
// timeout. Dispose must KILL the process (the tracker's abort signal), not
// await its exit or its 10-minute default hook timeout.
writeFileSync ( slowHook , ` #!/usr/bin/env bash \ necho $ $ > " ${ pidFile } " \ ntouch " ${ marker } " \ nsleep 30 \ n ` )
2026-07-06 23:27:54 +08:00
chmodSync ( slowHook , 0 o755 )
writeFileSync ( join ( dir , 'hooks.json' ) , JSON . stringify ( { hooks : {
SubagentStart : [ { hooks : [ { type : 'command' , command : slowHook } ] } ] ,
} } ) )
const { ctx , hooks } = await harnessWithFiber ( dir , new MockAdapter ( [ ] ) )
const warn = vi . fn ( )
ctx . logger . warn = warn as never
2026-07-19 22:13:50 +08:00
ctx . emit ( subagentCarrier ( ctx ) , 'subagent/start' , { runId : SubagentRunId ( 'run-1' ) , provider : 'inproc' , id : SessionId ( 'child-1' ) , local : false } )
2026-07-06 23:27:54 +08:00
await waitFor ( ( ) = > existsSync ( marker ) )
2026-07-07 00:02:10 +08:00
const pid = Number ( readFileSync ( pidFile , 'utf8' ) . trim ( ) )
2026-07-06 23:27:54 +08:00
await hooks . dispose ( )
2026-07-07 00:02:10 +08:00
// Quiescence, not just promptness: the drain resolves only after the run
// settled, and the run settles only after the killed process was reaped —
// so by the time dispose returns, the PID must be GONE (kill(pid, 0)
// throws ESRCH). An untracked fire-and-forget regression would leave the
// process alive (or unreaped) and fail this deterministically.
expect ( ( ) = > process . kill ( pid , 0 ) ) . toThrow ( )
2026-07-06 23:27:54 +08:00
// The aborted run resolves as a non-blocking error (runHook never rejects),
// so the drained continuation must NOT have logged a failure.
expect ( warn ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'SubagentStart hook failed' ) )
2026-07-01 04:22:00 +08:00
} )
} )
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 ( )
2026-07-16 17:39:53 +08:00
await mountAgentLoopTestDependencies ( ctx )
2026-07-01 04:22:00 +08:00
await ctx . plugin ( AgentLoop , { agents : [ ] } )
2026-07-26 12:43:14 +08:00
await ctx . plugin ( LocalSubprocessService )
2026-07-01 04:22:00 +08:00
await ctx . plugin ( LocalBashExecutor , { timeoutMs : 10_000 } )
await ctx . plugin ( HooksClaude , { configPath : '/nonexistent/hooks.json' } )
ctx . llm . registerAdapter ( [ 'mock' ] , adapter )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-24 22:38:50 +08:00
agent . followup ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } )
2026-07-01 04:22:00 +08:00
await waitForIdle ( ctx , agent )
// The turn ran normally — no hooks, no crash.
expect ( adapter . requests ) . toHaveLength ( 1 )
} )
2026-07-28 02:43:51 -07:00
it ( 'an invalid regex matcher is reported and registers no hooks' , async ( ) = > {
const dir = writeConfig ( {
UserPromptSubmit : [ { hooks : [ { type : 'command' , command : 'exit 2' } ] } ] ,
PreToolUse : [ { matcher : '(' , hooks : [ { type : 'command' , command : 'exit 2' } ] } ] ,
} )
const adapter = new MockAdapter ( [ textResponse ( 'fine' ) ] )
const warn = vi . fn ( )
const ctx = await harness ( dir , adapter , ( ctx ) = > { ctx . logger . warn = warn as never } )
const agent = ctx . agentLoop . create ( SessionId ( 'invalid-claude-matcher' ) , { provider : 'mock' , model : 'mock' } )
agent . followup ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } )
await waitForIdle ( ctx , agent )
expect ( adapter . requests ) . toHaveLength ( 1 )
expect ( events ( agent ) . some ( event = > event . type === 'hook/invoked' ) ) . toBe ( false )
expect ( warn ) . toHaveBeenCalledWith ( expect . stringContaining (
'invalid claude regex matcher "(" on event "PreToolUse"' ,
) )
} )
2026-07-01 04:22:00 +08:00
it ( 'disposing the bridge fiber removes its listeners (HMR safety)' , async ( ) = > {
2026-07-01 12:03:23 +08:00
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it
// would veto the prompt (0 model requests) and log a hook/invoked. Build the
// ctx WITHOUT the harness's own bridge mount so this is the ONLY mount, then
// dispose it — a leaked listener fails the test (a no-op `true` hook would
// pass even leaked, so it proved nothing).
const dir = writeConfig ( { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : 'exit 2' } ] } ] } )
2026-07-01 04:22:00 +08:00
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
2026-07-01 12:03:23 +08:00
const ctx = new Context ( )
2026-07-16 17:39:53 +08:00
await mountAgentLoopTestDependencies ( ctx )
2026-07-01 12:03:23 +08:00
await ctx . plugin ( AgentLoop , { agents : [ ] } )
2026-07-26 12:43:14 +08:00
await ctx . plugin ( LocalSubprocessService )
2026-07-01 12:03:23 +08:00
await ctx . plugin ( LocalBashExecutor , { timeoutMs : 10_000 } )
2026-07-01 04:22:00 +08:00
const fiber = await ctx . plugin ( HooksClaude , { configPath : join ( dir , 'hooks.json' ) } )
await fiber . dispose ( )
2026-07-01 12:03:23 +08:00
ctx . llm . registerAdapter ( [ 'mock' ] , adapter )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-24 22:38:50 +08:00
agent . followup ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } )
2026-07-01 04:22:00 +08:00
await waitForIdle ( ctx , agent )
2026-07-01 12:03:23 +08:00
expect ( adapter . requests ) . toHaveLength ( 1 ) // not blocked → the listener is gone
expect ( events ( agent ) . some ( e = > e . type === 'hook/invoked' ) ) . toBe ( false ) // no hook ran
2026-07-01 04:22:00 +08:00
} )
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' )
} )
} )