2026-07-01 04:22:00 +08:00
import { afterEach , describe , expect , it , vi } from 'vitest'
import { mkdtempSync , rmSync , writeFileSync , chmodSync , existsSync , readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore , { type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry , { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry , { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop , { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import { MockAdapter , textResponse , toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const dirs : string [ ] = [ ]
afterEach ( ( ) = > { for ( const d of dirs . splice ( 0 ) ) rmSync ( d , { recursive : true , force : true } ) } )
function dir ( ) : string { const d = mkdtempSync ( join ( tmpdir ( ) , 'dsh-hx-cov-' ) ) ; dirs . push ( d ) ; return d }
function sh ( d : string , name : string , body : string ) : string {
const p = join ( d , name ) ; writeFileSync ( p , body ) ; chmodSync ( p , 0 o755 ) ; return p
}
function hooks ( d : string , h : unknown ) : string {
writeFileSync ( join ( d , 'hooks.json' ) , JSON . stringify ( { hooks : h } ) ) ; return join ( d , 'hooks.json' )
}
2026-07-04 17:37:23 +08:00
async function harness ( configPath : string , adapter : MockAdapter , opts : { stderrSummaryMaxChars? : number } = { } ) : Promise < Context > {
2026-07-01 04:22:00 +08:00
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 } )
2026-07-04 17:37:23 +08:00
await ctx . plugin ( HooksCodex , { configPath , model : 'm' , . . . opts } )
2026-07-01 04:22:00 +08:00
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 ] }
2026-07-01 11:25:54 +08:00
/** Poll until `predicate` holds or the deadline passes — robust to detached
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
async function waitFor ( predicate : ( ) = > boolean , timeout = 5000 , interval = 10 ) : Promise < 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-codex coverage — decision mapping paths' , ( ) = > {
it ( 'UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr' , async ( ) = > {
const d = dir ( )
hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : sh ( d , 'b.sh' , '#!/usr/bin/env bash\nexit 2\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'no' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( adapter . requests ) . toHaveLength ( 0 )
const te = events ( agent ) . findLast ( e = > e . type === 'turn/end' )
expect ( te ? . type === 'turn/end' && te . data . reason . kind ) . toBe ( 'rejected' )
} )
it ( 'UserPromptSubmit additionalContext is injected; a no-op hook proceeds' , async ( ) = > {
const d = dir ( )
hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : sh ( d , 'c.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( JSON . stringify ( adapter . requests [ 0 ] ! . messages ) ) . toContain ( 'ctx-x' )
} )
2026-07-02 18:35:53 +08:00
it ( 'a context-only UserPromptSubmit hook DELEGATES so a later listener can still block' , async ( ) = > {
2026-07-13 23:27:00 +08:00
// Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a
// downstream policy listener can still block.
2026-07-02 18:35:53 +08:00
const d = dir ( )
hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : sh ( d , 'c.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'should not run' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . on ( 'agent/prompt-submit' , async ( ) = > ( { kind : 'block' as const , reason : 'policy veto' } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( adapter . requests ) . toHaveLength ( 0 )
expect ( events ( agent ) . some ( e = > e . type === 'user/message' ) ) . toBe ( false )
const te = events ( agent ) . findLast ( e = > e . type === 'turn/end' )
expect ( te ? . type === 'turn/end' && te . data . reason ) . toMatchObject ( { kind : 'rejected' , reason : 'policy veto' } )
} )
it ( 'folds the bridge additionalContext WITH a downstream listener that also adds context' , async ( ) = > {
const d = dir ( )
hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : sh ( d , 'c.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . on ( 'agent/prompt-submit' , async ( ) = > ( {
kind : 'allow' as const ,
content : [ { type : 'text' as const , text : 'rewritten-prompt' } ] ,
additionalContext : { content : [ { type : 'text' as const , text : 'from-downstream' } ] , source : { kind : 'plugin' as const , plugin : 'policy' } } ,
} ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const req = JSON . stringify ( adapter . requests [ 0 ] ! . messages )
expect ( req ) . toContain ( 'from-bridge' )
expect ( req ) . toContain ( 'from-downstream' )
expect ( req ) . toContain ( 'rewritten-prompt' )
} )
it ( 'folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content' , async ( ) = > {
const d = dir ( )
hooks ( d , { PostToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'pc.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . tools . register ( defineTool ( { name : 'echo' , description : 'e' , parameters : { } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
ctx . on ( 'tools/post-execute' , async ( ) = > ( { kind : 'accept' as const , content : [ { type : 'text' as const , text : 'rewritten-result' } ] } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const result = events ( agent ) . find ( e = > e . type === 'tool/result' )
expect ( result ? . type === 'tool/result' && result . data . content . some ( b = > b . type === 'text' && b . text === 'rewritten-result' ) ) . toBe ( true )
expect ( events ( agent ) . some ( e = > e . type === 'context/message' && e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'bridge-note' ) ) ) ) . toBe ( true )
} )
it ( 'folds the bridge PostToolUse context onto a downstream listener BLOCK' , async ( ) = > {
const d = dir ( )
hooks ( d , { PostToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'pc.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . tools . register ( defineTool ( { name : 'echo' , description : 'e' , parameters : { } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
ctx . on ( 'tools/post-execute' , async ( ) = > ( { kind : 'block' as const , feedback : [ { type : 'text' as const , text : 'downstream-block' } ] } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const result = events ( agent ) . find ( e = > e . type === 'tool/result' )
expect ( result ? . type === 'tool/result' && result . data . isError ) . toBe ( true )
expect ( result ? . type === 'tool/result' && result . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'downstream-block' ) ) ) . toBe ( true )
expect ( events ( agent ) . some ( e = > e . type === 'context/message' && e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'bridge-note' ) ) ) ) . toBe ( true )
} )
2026-07-01 04:22:00 +08:00
it ( 'SessionStart additionalContext is injected for the first request' , async ( ) = > {
const d = dir ( )
hooks ( d , { SessionStart : [ { hooks : [ { type : 'command' , command : sh ( d , 's.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
2026-07-01 11:25:54 +08:00
await waitFor ( ( ) = > events ( agent ) . some ( e = > e . type === 'context/message'
&& e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'start-ctx' ) ) ) )
2026-07-01 04:22:00 +08:00
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( JSON . stringify ( adapter . requests [ 0 ] ! . messages ) ) . toContain ( 'start-ctx' )
} )
it ( 'PostToolUse block (exit 2) → isError feedback; default reason' , async ( ) = > {
const d = dir ( )
hooks ( d , { PostToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'p.sh' , '#!/usr/bin/env bash\nexit 2\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'ls' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const r = events ( agent ) . find ( e = > e . type === 'tool/result' )
expect ( r ? . type === 'tool/result' && r . data . isError ) . toBe ( true )
expect ( r ? . type === 'tool/result' && r . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'blocked by PostToolUse hook' ) ) ) . toBe ( true )
} )
it ( 'PostToolUse additionalContext (clean exit) is attached after the result' , async ( ) = > {
const d = dir ( )
hooks ( d , { PostToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'pc.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'ls' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( events ( agent ) . some ( e = > e . type === 'context/message' && e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'post-ctx' ) ) ) ) . toBe ( true )
} )
it ( 'PreToolUse for a tool call WITHOUT a command arg passes an empty command (commandOf non-object/missing arm)' , async ( ) = > {
const d = dir ( )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'pre.sh' , '#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { } ) , textResponse ( 'done' ) ] ) // no command arg
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
let ran = false
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { } , async execute() { ran = true ; return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( ran ) . toBe ( true ) // clean-exit hook allows; commandOf returned ''
} )
it ( 'a clean exit-0 hook records exitCode 0 and omits stderrSummary' , async ( ) = > {
const d = dir ( )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'n.sh' , '#!/usr/bin/env bash\nexit 0\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const res = events ( agent ) . find ( e = > e . type === 'hook/result' )
expect ( res ? . type === 'hook/result' && res . data . exitCode ) . toBe ( 0 )
expect ( res ? . type === 'hook/result' && 'stderrSummary' in res . data ) . toBe ( false )
} )
it ( 'a long stderr is truncated in the hook/result summary' , async ( ) = > {
const d = dir ( )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'l.sh' , '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const res = events ( agent ) . find ( e = > e . type === 'hook/result' )
expect ( res ? . type === 'hook/result' && res . data . stderrSummary ? . endsWith ( '…' ) ) . toBe ( true )
2026-07-04 17:37:23 +08:00
expect ( res ? . type === 'hook/result' && res . data . stderrSummary ? . length ) . toBe ( 501 ) // default 500-char cap + ellipsis
} )
2026-07-04 18:06:35 +08:00
it ( 'rejects a non-positive or fractional stderrSummaryMaxChars at load' , async ( ) = > {
const d = dir ( )
hooks ( d , { } )
for ( const bad of [ 0 , - 5 , 1.5 , Number . NaN ] ) {
const adapter = new MockAdapter ( [ ] )
await expect ( harness ( join ( d , 'hooks.json' ) , adapter , { stderrSummaryMaxChars : bad } ) )
. rejects . toThrow ( /hooks-codex: stderrSummaryMaxChars must be a positive integer/ )
}
} )
2026-07-04 17:37:23 +08:00
it ( 'the stderr summary cap is plugin config (stderrSummaryMaxChars)' , async ( ) = > {
const d = dir ( )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'l.sh' , '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter , { stderrSummaryMaxChars : 40 } )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const res = events ( agent ) . find ( e = > e . type === 'hook/result' )
expect ( res ? . type === 'hook/result' && res . data . stderrSummary ) . toBe ( 'x' . repeat ( 40 ) + '…' )
2026-07-01 04:22:00 +08:00
} )
2026-07-04 15:44:26 +08:00
it ( 'warns on a skipped async hook and a direct apply() (schema bypass) runs' , async ( ) = > {
2026-07-01 04:22:00 +08:00
const d = dir ( )
const marker = join ( d , 'ran' )
hooks ( d , { UserPromptSubmit : [ { hooks : [
{ type : 'command' , command : 'bg.sh' , async : true } , // skipped → warn
{ type : 'command' , command : sh ( d , 'h.sh' , ` #!/usr/bin/env bash \ ntouch " ${ marker } " \ n ` ) } ,
] } ] } )
const warn = vi . fn ( )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = new Context ( )
await ctx . plugin ( LlmService ) ; await ctx . plugin ( SessionStore ) ; await ctx . plugin ( SystemPrompt )
await ctx . plugin ( ToolRegistry ) ; await ctx . plugin ( AgentRegistry ) ; await ctx . plugin ( AgentLoop , { agents : [ ] } )
await ctx . plugin ( LocalBashExecutor , { timeoutMs : 10_000 } )
ctx . logger . warn = warn as never
2026-07-04 15:44:26 +08:00
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
2026-07-01 04:22:00 +08:00
HooksCodex . apply ( ctx , { configPath : join ( d , 'hooks.json' ) } )
ctx . llm . registerAdapter ( [ 'mock' ] , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( existsSync ( marker ) ) . toBe ( true )
expect ( warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'async hook' ) )
} )
it ( 'a no-op clean hook proceeds (contextFrom empty → next)' , async ( ) = > {
const d = dir ( )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'n.sh' , '#!/usr/bin/env bash\nexit 0\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
let ran = false
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { ran = true ; return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( ran ) . toBe ( true )
} )
it ( 'SessionStart with no additionalContext is a no-op (contextFrom empty)' , async ( ) = > {
const d = dir ( )
2026-07-01 12:03:23 +08:00
// The hook touches a marker so we can wait for it to ACTUALLY FINISH before
// asserting absence — a completed turn alone would not prove the detached
// session-start hook ran, making the absence check a false pass.
const marker = join ( d , 'ss-ran' )
hooks ( d , { SessionStart : [ { hooks : [ { type : 'command' , command : sh ( d , 's.sh' , ` #!/usr/bin/env bash \ ntouch " ${ marker } " \ nexit 0 \ n ` ) } ] } ] } )
2026-07-01 04:22:00 +08:00
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
2026-07-01 12:03:23 +08:00
await waitFor ( ( ) = > existsSync ( marker ) ) // the clean no-output hook has finished
2026-07-01 04:22:00 +08:00
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( events ( agent ) . some ( e = > e . type === 'context/message' ) ) . toBe ( false )
} )
it ( 'a throwing SessionStart inject is contained (logged)' , async ( ) = > {
const d = dir ( )
hooks ( d , { SessionStart : [ { hooks : [ { type : 'command' , command : sh ( d , 's.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const warn = vi . fn ( ) ; ctx . logger . warn = warn as never
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . inject = ( ( ) = > { throw new Error ( 'inject boom' ) } )
2026-07-01 11:25:54 +08:00
await waitFor ( ( ) = > warn . mock . calls . some ( c = > String ( c [ 0 ] ) . includes ( 'SessionStart hook failed' ) ) )
2026-07-01 04:22:00 +08:00
expect ( warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'SessionStart hook failed' ) )
} )
it ( 'a clean PreToolUse with no decision allows the tool (no deny)' , async ( ) = > {
const d = dir ( )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'ok.sh' , '#!/usr/bin/env bash\nexit 0\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
let ran = false
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { ran = true ; return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( ran ) . toBe ( true )
} )
it ( 'a non-matching regex matcher skips the hook (matchesMatcher false → continue)' , async ( ) = > {
const d = dir ( )
// /^Edit$/ does not match the tool name "Bash" → the group is skipped.
hooks ( d , { PreToolUse : [ { matcher : '^Edit$' , hooks : [ { type : 'command' , command : sh ( d , 'deny.sh' , '#!/usr/bin/env bash\nexit 2\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
let ran = false
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { ran = true ; return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( ran ) . toBe ( true ) // matcher didn't match → no hook ran → tool proceeded
expect ( events ( agent ) . some ( e = > e . type === 'hook/invoked' ) ) . toBe ( false )
} )
2026-07-01 10:48:23 +08:00
it ( 'a {"continue":false} hook is RECORDED as "stop" but does not halt the run (TODO(hook-continue-false))' , async ( ) = > {
// Honoring `continue:false` is deferred — the seams have no hard-halt
// primitive. Assert the LOG records the halt request AND that the run is not
// actually halted (the tool still runs, the turn completes).
2026-07-01 04:22:00 +08:00
const d = dir ( )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 's.sh' , '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
2026-07-01 10:48:23 +08:00
let ran = false
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { ran = true ; return [ { type : 'text' , text : 'ok' } ] } } ) )
2026-07-01 04:22:00 +08:00
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' )
2026-07-01 10:48:23 +08:00
expect ( res ? . type === 'hook/result' && res . data . decision ) . toBe ( 'stop' ) // recorded
expect ( ran ) . toBe ( true ) // NOT honored: the tool still ran (halt is deferred)
2026-07-01 04:22:00 +08:00
} )
it ( 'PreToolUse deny with EMPTY stderr uses the default reason (?? right arm)' , async ( ) = > {
const d = dir ( )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'd.sh' , '#!/usr/bin/env bash\nexit 2\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const r = events ( agent ) . find ( e = > e . type === 'tool/result' )
expect ( r ? . type === 'tool/result' && r . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'blocked by PreToolUse hook' ) ) ) . toBe ( true )
} )
it ( 'PostToolUse block AND additionalContext are surfaced together' , async ( ) = > {
const d = dir ( )
hooks ( d , { PostToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'bc.sh' , '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const r = events ( agent ) . find ( e = > e . type === 'tool/result' )
expect ( r ? . type === 'tool/result' && r . data . isError ) . toBe ( true )
expect ( r ? . type === 'tool/result' && r . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'bad' ) ) ) . toBe ( true )
expect ( events ( agent ) . some ( e = > e . type === 'context/message' && e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'ctx too' ) ) ) ) . toBe ( true )
} )
it ( 'commandOf reads a non-string command arg as an empty command' , async ( ) = > {
const d = dir ( )
// The tool-call arguments carry `command` as a NUMBER → commandOf's
// `typeof command === 'string'` false arm → '' (the payload's tool_input.command).
const cap = join ( d , 'payload' )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'cap.sh' , ` #!/usr/bin/env bash \ ncat > " ${ cap } " \ nexit 0 \ n ` ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 7 } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'number' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const payload = JSON . parse ( readFileSync ( cap , 'utf8' ) ) as { tool_input : { command : string } }
expect ( payload . tool_input . command ) . toBe ( '' )
} )
it ( 'a no-agent direct PreToolUse run uses process.cwd() and turn 0 (no session to record)' , async ( ) = > {
const d = dir ( )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'd.sh' , '#!/usr/bin/env bash\nexit 2\n' ) } ] } ] } )
const ctx = await harness ( join ( d , 'hooks.json' ) , new MockAdapter ( [ ] ) )
let ran = false
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { ran = true ; return [ { type : 'text' , text : 'x' } ] } } ) )
const { CallId } = await import ( '@deepseek-ai/dsh-llm' )
const result = await ctx . tools . execute ( { callId : CallId ( 'c1' ) , name : 'Bash' , arguments : { command : 'x' } } )
expect ( ran ) . toBe ( false ) // denied
expect ( result . isError ) . toBe ( true )
} )
it ( 'a no-agent direct PostToolUse run attaches context with no session to record' , async ( ) = > {
const d = dir ( )
hooks ( d , { PostToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'pc.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n' ) } ] } ] } )
const ctx = await harness ( join ( d , 'hooks.json' ) , new MockAdapter ( [ ] ) )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const { CallId } = await import ( '@deepseek-ai/dsh-llm' )
const result = await ctx . tools . execute ( { callId : CallId ( 'c1' ) , name : 'Bash' , arguments : { command : 'x' } } )
expect ( result . isError ) . toBeFalsy ( )
expect ( result . additionalContext ? . content . some ( b = > b . type === 'text' && b . text === 'x' ) ) . toBe ( true )
} )
it ( 'when the bash executor REJECTS, the hook/result omits exitCode (non-blocking)' , async ( ) = > {
const d = dir ( )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'h.sh' , '#!/usr/bin/env bash\nexit 0\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . bash . run = ( ( ) = > Promise . reject ( new Error ( 'executor down' ) ) )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const res = events ( agent ) . find ( e = > e . type === 'hook/result' )
expect ( res ? . type === 'hook/result' && 'exitCode' in res . data ) . toBe ( false )
} )
2026-07-01 10:48:23 +08:00
it ( 'a blocking Stop hook with EMPTY stderr still forces continuation (no reason required)' , async ( ) = > {
// Regression: an exit-2 Stop hook with no stderr yields decision 'deny' +
// reason undefined; the turn must STILL force-continue, not silently stop.
const d = dir ( )
const marker = join ( d , 'fired' )
hooks ( d , { Stop : [ { hooks : [ { type : 'command' , command : sh ( d , 's.sh' , ` #!/usr/bin/env bash \ nif [ -e " ${ marker } " ]; then exit 0; fi \ ntouch " ${ marker } " \ nexit 2 \ n ` ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'one' ) , textResponse ( 'two' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( adapter . requests ) . toHaveLength ( 2 ) // empty-reason block forced continuation
expect ( JSON . stringify ( adapter . requests [ 1 ] ! . messages ) ) . toContain ( 'blocked by Stop hook' )
} )
it ( 'a clean UserPromptSubmit hook that prints PLAIN stdout injects it as context' , async ( ) = > {
// Codex feeds a SessionStart/UserPromptSubmit hook's PLAIN (non-JSON) stdout
// as additionalContext (unlike CC, which needs a JSON hookSpecificOutput).
const d = dir ( )
hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : sh ( d , 'ctx.sh' , '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( JSON . stringify ( adapter . requests [ 0 ] ! . messages ) ) . toContain ( 'extra guidance from a plain hook' )
} )
2026-07-01 12:03:23 +08:00
it ( 'a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context' , async ( ) = > {
2026-07-13 23:27:00 +08:00
// SessionStart cannot block, but non-clean stdout still must not become context. The marker
// waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches
// the codec's structured-stdout rule.
2026-07-01 12:03:23 +08:00
const d = dir ( )
const marker = join ( d , 'ran' )
hooks ( d , { SessionStart : [ { hooks : [ { type : 'command' , command : sh ( d , 'b.sh' , ` #!/usr/bin/env bash \ ntouch " ${ marker } " \ necho "stale" \ nexit 2 \ n ` ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
await waitFor ( ( ) = > existsSync ( marker ) ) // the exit-2 hook has finished
expect ( events ( agent ) . some ( e = > e . type === 'context/message'
&& e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'stale' ) ) ) ) . toBe ( false )
} )
it ( 'a UserPromptSubmit hook with a non-blocking error exit (1) + stdout does NOT inject it' , async ( ) = > {
// Exit 1 is a non-blocking error (no decision), so the prompt is NOT blocked
// and the handler falls through to the context path — the gate must still
// suppress the error hook's stdout ("stale" never reaches the model).
const d = dir ( )
hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : sh ( d , 'e.sh' , '#!/usr/bin/env bash\necho "stale"\nexit 1\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( adapter . requests ) . toHaveLength ( 1 ) // exit 1 is non-blocking → the turn ran
expect ( JSON . stringify ( adapter . requests [ 0 ] ! . messages ) ) . not . toContain ( 'stale' )
} )
2026-07-01 10:48:23 +08:00
it ( 'a clean SessionStart hook that prints PLAIN stdout injects it (not JSON)' , async ( ) = > {
const d = dir ( )
hooks ( d , { SessionStart : [ { hooks : [ { type : 'command' , command : sh ( d , 'ss.sh' , '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
2026-07-01 11:25:54 +08:00
await waitFor ( ( ) = > events ( agent ) . some ( e = > e . type === 'context/message'
&& e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'session preamble' ) ) ) )
2026-07-01 10:48:23 +08:00
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( JSON . stringify ( adapter . requests [ 0 ] ! . messages ) ) . toContain ( 'session preamble' )
} )
it ( 'a clean hook that prints JSON is NOT injected as prose (plain-stdout gate)' , async ( ) = > {
// A structured (JSON) stdout must go through the hookSpecificOutput path, not
// be dumped verbatim as context — the `!startsWith('{')` gate guards this.
const d = dir ( )
hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : sh ( d , 'j.sh' , '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( JSON . stringify ( adapter . requests [ 0 ] ! . messages ) ) . not . toContain ( 'unrelated' )
} )
it ( 'the PreToolUse payload carries the REAL tool name (matches the matcher subject)' , async ( ) = > {
// Regression: the payload once hardcoded tool_name "Bash", disagreeing with
// the exec.name matcher subject — a config matcher on the real name would
// then never fire. Capture the payload and assert tool_name === the real name.
const d = dir ( )
const cap = join ( d , 'payload' )
hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'cap.sh' , ` #!/usr/bin/env bash \ ncat > " ${ cap } " \ nexit 0 \ n ` ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'shell' , { command : 'ls' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
ctx . tools . register ( defineTool ( { name : 'shell' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
const payload = JSON . parse ( readFileSync ( cap , 'utf8' ) ) as { tool_name : string ; tool_input : { command : string } }
expect ( payload . tool_name ) . toBe ( 'shell' )
expect ( payload . tool_input . command ) . toBe ( 'ls' )
} )
it ( 'a Codex matcher on the REAL tool name fires (matcher subject === payload tool_name)' , async ( ) = > {
// A regex matcher matching the real tool name must select the hook — proving
// the matcher subject and the payload tool_name agree.
const d = dir ( )
hooks ( d , { PreToolUse : [ { matcher : 'shell' , hooks : [ { type : 'command' , command : sh ( d , 'd.sh' , '#!/usr/bin/env bash\nexit 2\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'shell' , { command : 'ls' } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
let ran = false
ctx . tools . register ( defineTool ( { name : 'shell' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { ran = true ; return [ { type : 'text' , text : 'ok' } ] } } ) )
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( ran ) . toBe ( false ) // the matcher fired → the hook denied the tool
expect ( events ( agent ) . some ( e = > e . type === 'hook/invoked' && e . data . point === 'PreToolUse' ) ) . toBe ( true )
} )
2026-07-01 16:34:28 +08:00
it ( 'a hook emitting a systemMessage is warned as not-yet-surfaced' , async ( ) = > {
const d = dir ( )
hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : sh ( d , 'sm.sh' , '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n' ) } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( join ( d , 'hooks.json' ) , adapter )
const warn = vi . fn ( ) ; ctx . logger . warn = warn as never
const agent = ctx . agentLoop . create ( AgentId ( 'a1' ) , { model : 'mock' } )
agent . send ( [ { type : 'text' , text : 'go' } ] ) ; await waitForIdle ( ctx , agent )
expect ( warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'systemMessage' ) )
expect ( JSON . stringify ( adapter . requests [ 0 ] ! . messages ) ) . not . toContain ( 'heads up' )
} )
it ( 'runs an agent-scoped hook in the session cwd, not the executor default' , async ( ) = > {
// Same regression as the CC bridge: the Codex bridge must thread the session
// cwd as the hook workdir. Executor default = serverDir; session cwd =
// sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir.
const serverDir = dir ( )
const sessionDir = dir ( )
const marker = join ( sessionDir , 'where' )
hooks ( serverDir , { PreToolUse : [ { hooks : [ { type : 'command' , command : 'pwd > where' } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'Bash' , { command : 'x' } ) , textResponse ( 'done' ) ] )
const ctx = new Context ( )
await ctx . plugin ( LlmService ) ; await ctx . plugin ( SessionStore ) ; await ctx . plugin ( SystemPrompt )
await ctx . plugin ( ToolRegistry ) ; await ctx . plugin ( AgentRegistry ) ; await ctx . plugin ( AgentLoop , { agents : [ ] } )
await ctx . plugin ( LocalBashExecutor , { timeoutMs : 10_000 , cwd : serverDir } )
await ctx . plugin ( HooksCodex , { configPath : join ( serverDir , 'hooks.json' ) , model : 'm' } )
ctx . llm . registerAdapter ( [ 'mock' ] , adapter )
ctx . tools . register ( defineTool ( { name : 'Bash' , description : 'b' , parameters : { command : { type : 'string' } } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
const { SessionId } = await import ( '@deepseek-ai/dsh-session' )
2026-07-11 22:55:26 +08:00
const handle = await ctx . agents . create ( { agentId : AgentId ( 'a1' ) , sessionId : SessionId ( 's1' ) , meta : { cwd : sessionDir } , agentOptions : { model : 'mock' } } )
2026-07-01 16:34:28 +08:00
handle . agent . send ( [ { type : 'text' , text : 'go' } ] )
await waitForIdle ( ctx , handle . agent as ReactLoopAgent )
expect ( existsSync ( marker ) ) . toBe ( true )
expect ( readFileSync ( marker , 'utf8' ) . trim ( ) . endsWith ( sessionDir . split ( '/' ) . pop ( ) ! ) ) . toBe ( true )
await handle . dispose ( )
} )
2026-07-01 04:22:00 +08:00
} )