2026-07-28 13:55:59 +08:00
import { createUserMessage } from '@deepseek-ai/dsh-llm'
2026-07-15 23:37:52 +08:00
import { afterEach , describe , expect , it , vi } from 'vitest'
2026-07-17 20:22:34 +08:00
import { mkdtempSync , rmSync , writeFileSync , chmodSync , existsSync , readFileSync } from 'node:fs'
2026-07-15 23:37:52 +08:00
import { tmpdir } from 'node:os'
import { join } from 'node:path'
build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.
Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.
The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.
Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:06 +08:00
import { Context } from '@deepseek-ai/cordis'
2026-07-18 12:21:15 +08:00
import { SessionId , type SessionEvent } from '@deepseek-ai/dsh-session'
2026-08-13 00:36:22 +08:00
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
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'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
2026-07-17 20:33:24 +08:00
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
2026-07-15 23:37:52 +08:00
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
2026-08-13 00:36:22 +08:00
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
2026-07-19 22:13:50 +08:00
import { scopeTarget } from '@deepseek-ai/dsh-scope'
2026-08-13 00:36:22 +08:00
import SubagentRuntime , { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code'
2026-07-15 23:37:52 +08:00
import { MockAdapter , textResponse , toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
2026-07-19 23:38:54 +08:00
const testToolSignal = new AbortController ( ) . signal
2026-07-15 23:37:52 +08:00
/ * * T a r g e t e d b r a n c h c o v e r a g e f o r t h e C C b r i d g e : o p t i o n a r m s , w a r n p a t h s , n o - a g e n t
* 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 } ) } )
2026-07-19 22:13:50 +08:00
function subagentCarrier ( ctx : Context ) {
2026-08-13 00:36:22 +08:00
return scopeTarget ( ctx as unknown as SubagentRuntime , undefined )
2026-07-19 22:13:50 +08:00
}
2026-07-15 23:37:52 +08:00
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 , 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-17 20:22:34 +08:00
type HarnessOpts = { pluginRoot? : string ; projectDir? : string ; stderrSummaryMaxChars? : number ; sessionRoot? : string }
2026-07-15 23:37:52 +08:00
async function harness ( configPath : string , adapter : MockAdapter , opts : HarnessOpts = { } ) : Promise < Context > {
const ctx = new Context ( )
2026-07-17 20:33:24 +08:00
await mountAgentLoopTestDependencies ( ctx )
2026-08-13 00:36:22 +08:00
if ( opts . sessionRoot !== undefined ) await ctx . plugin ( JsonlSessionPersistence , { root : opts.sessionRoot } )
2026-07-15 23:37:52 +08:00
await ctx . plugin ( AgentLoop , { agents : [ ] } )
2026-08-13 00:36:22 +08:00
await ctx . plugin ( LocalSubprocessRuntime )
2026-07-15 23:37:52 +08:00
await ctx . plugin ( LocalBashExecutor , { timeoutMs : 10_000 } )
await ctx . plugin ( HooksClaude , { configPath , . . . opts } )
ctx . llm . registerAdapter ( [ 'mock' ] , adapter )
return ctx
}
2026-07-24 21:18:48 +08:00
function waitForIdle ( _ctx : Context , agent : Agent ) : Promise < void > {
return agent . whenIdle ( )
2026-07-15 23:37:52 +08:00
}
2026-07-18 12:33:49 +08:00
function events ( agent : Agent ) : SessionEvent [ ] { return [ . . . agent . session . events ] }
2026-07-15 23:37:52 +08:00
/ * * P o l l u n t i l ` p r e d i c a t e ` h o l d s o r t h e d e a d l i n e p a s s e s — r o b u s t t o d e t a c h e d
* 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 ) )
}
}
export type CoverageGroup = 'config' | 'stop' | 'context' | 'edge-paths'
2026-08-13 00:36:22 +08:00
/** Register independently schedulable slices of the hooks-claude-code coverage matrix. */
2026-07-15 23:37:52 +08:00
export function defineCoverageCases ( group : CoverageGroup ) : void {
2026-08-13 00:36:22 +08:00
if ( group === 'config' ) describe ( 'hooks-claude-code coverage — config option arms + substitution + skip warning' , ( ) = > {
2026-07-17 20:22:34 +08:00
it ( 'uses the persistence locator for transcript_path and an empty string without one' , async ( ) = > {
async function capture ( sessionRoot? : string ) : Promise < { payload : { transcript_path : string } ; expected : string | undefined } > {
const d = dir ( )
const cap = join ( d , 'payload' )
const path = hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : sh ( d , 'capture.sh' , ` #!/usr/bin/env bash \ ncat > " ${ cap } " \ n ` ) } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( path , adapter , { . . . sessionRoot !== undefined ? { sessionRoot } : { } } )
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 ( 'transcript' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-17 20:22:34 +08:00
await waitForIdle ( ctx , agent )
return {
payload : JSON.parse ( readFileSync ( cap , 'utf8' ) ) as { transcript_path : string } ,
expected : ctx.get ( 'sessionPersistence' ) ? . locate ( agent . session . header ) ? . path ,
}
}
const located = await capture ( dir ( ) )
expect ( located . payload . transcript_path ) . toBe ( located . expected )
expect ( ( await capture ( ) ) . payload . transcript_path ) . toBe ( '' )
2026-07-22 16:37:20 +08:00
} , 15 _000 ) // Two real agent/hook subprocess loops need process startup and teardown headroom.
2026-07-17 20:22:34 +08:00
2026-07-15 23:37:52 +08:00
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
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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
expect ( existsSync ( marker ) ) . toBe ( true ) // substituted command ran
2026-07-31 22:00:39 +08:00
} , 15 _000 ) // Real agent and hook subprocess startup can exceed Vitest's default under coverage concurrency.
2026-07-15 23:37:52 +08:00
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
2026-07-21 03:08:35 +08:00
ctx . tools . register ( defineContentToolFixture ( { name : 'echo' , description : 'e' , parameters : { command : { type : 'string' } } , async execute ( args ) { sawArgs = args ; 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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
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' ) )
} )
} )
2026-08-13 00:36:22 +08:00
if ( group === 'config' ) describe ( 'hooks-claude-code coverage — empty/no-op outcomes and no-agent paths' , ( ) = > {
2026-07-15 23:37:52 +08:00
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 )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
2026-07-23 19:15:45 +08:00
// The prompt proceeded unchanged; no injected context.
2026-07-15 23:37:52 +08:00
expect ( adapter . requests ) . toHaveLength ( 1 )
2026-07-23 19:15:45 +08:00
expect ( events ( agent ) . some ( e = > e . type === 'user/message' && e . data . source . kind !== 'user' ) ) . toBe ( false )
2026-07-15 23:37:52 +08:00
} )
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
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-15 23:37:52 +08:00
const { CallId } = await import ( '@deepseek-ai/dsh-llm' )
2026-07-19 23:38:54 +08:00
const result = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c1' ) , name : 'echo' , arguments : { } } )
2026-07-15 23:37:52 +08:00
expect ( ran ) . toBe ( false )
expect ( result . isError ) . toBe ( true )
} )
it ( 'a long stderr is truncated in the hook/result summary' , async ( ) = > {
const d = dir ( )
const s = sh ( d , 'long.sh' , '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n' )
const path = hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( path , adapter )
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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
const res = events ( agent ) . find ( e = > e . type === 'hook/result' )
expect ( res ? . type === 'hook/result' && res . data . stderrSummary ? . endsWith ( '…' ) ) . toBe ( true )
expect ( res ? . type === 'hook/result' && res . data . stderrSummary ? . length ) . toBe ( 501 ) // default 500-char cap + ellipsis
} )
it ( 'rejects a non-positive or fractional stderrSummaryMaxChars at load' , async ( ) = > {
const d = dir ( )
const path = hooks ( d , { } )
for ( const bad of [ 0 , - 5 , 1.5 , Number . NaN ] ) {
const adapter = new MockAdapter ( [ ] )
await expect ( harness ( path , adapter , { stderrSummaryMaxChars : bad } ) )
2026-08-13 00:36:22 +08:00
. rejects . toThrow ( /hooks-claude-code: stderrSummaryMaxChars must be a positive integer/ )
2026-07-15 23:37:52 +08:00
}
} )
it ( 'the stderr summary cap is plugin config (stderrSummaryMaxChars)' , async ( ) = > {
const d = dir ( )
const s = sh ( d , 'long.sh' , '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n' )
const path = hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( path , adapter , { stderrSummaryMaxChars : 40 } )
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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
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-08-13 00:36:22 +08:00
if ( group === 'stop' ) describe ( 'hooks-claude-code coverage — Stop continuation + subagent inject/catch' , ( ) = > {
2026-07-15 23:37:52 +08:00
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 )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
expect ( adapter . requests ) . toHaveLength ( 2 )
expect ( JSON . stringify ( adapter . requests [ 1 ] ! . messages ) ) . toContain ( 'continue please' )
} )
it ( 'a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)' , async ( ) = > {
// A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces
// continuation; the script self-limits to one block to avoid a loop.
const d = dir ( )
const marker = join ( d , 'fired' )
const s = sh ( d , 'stop.sh' , ` #!/usr/bin/env bash \ nif [ -e " ${ marker } " ]; then exit 0; fi \ ntouch " ${ marker } " \ nexit 2 \ n ` )
const path = hooks ( d , { Stop : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'one' ) , textResponse ( 'two' ) ] )
const ctx = await harness ( path , adapter )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
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 ( [ ] ) )
const injected : string [ ] = [ ]
2026-07-24 22:38:50 +08:00
const child = {
id : SessionId ( 'child-x' ) ,
inject : ( input : { content : Array < { type : string ; text? : string } > } ) = > {
injected . push ( input . content . map ( block = > block . text ? ? '' ) . join ( '' ) )
} ,
session : { id : SessionId ( 'child-x' ) , header : { id : 'child-x' } } ,
} as unknown as Parameters < typeof ctx.agents.register > [ 0 ]
2026-07-15 23:37:52 +08:00
ctx . agents . register ( child )
2026-07-19 22:13:50 +08:00
ctx . emit ( subagentCarrier ( ctx ) , 'subagent/start' , { runId : SubagentRunId ( 'run-x' ) , provider : 'p' , id : SessionId ( 'child-x' ) , local : true } )
2026-07-15 23:37:52 +08:00
await waitFor ( ( ) = > injected . includes ( 'child guidance' ) )
expect ( injected ) . toContain ( 'child guidance' )
} )
it ( 'a throwing SubagentStart/SubagentStop hook run is contained (logged)' , async ( ) = > {
const d = dir ( )
// A hook command that does not exist makes runHook resolve a non-blocking
// error (not a throw), so to hit the .catch we make the .then throw: register
// a child whose inject throws for SubagentStart.
const s = sh ( d , 'sa.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n' )
const path = hooks ( d , { SubagentStart : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const ctx = await harness ( path , new MockAdapter ( [ ] ) )
const warn = vi . fn ( ) ; ctx . logger . warn = warn as never
2026-07-18 12:21:15 +08:00
const child = { id : SessionId ( 'child-y' ) , inject : ( ) = > { throw new Error ( 'inject boom' ) } , session : { id : SessionId ( 'child-y' ) , header : { id : 'child-y' } } } as unknown as Parameters < typeof ctx.agents.register > [ 0 ]
2026-07-15 23:37:52 +08:00
ctx . agents . register ( child )
2026-07-19 22:13:50 +08:00
ctx . emit ( subagentCarrier ( ctx ) , 'subagent/start' , { runId : SubagentRunId ( 'run-y' ) , provider : 'p' , id : SessionId ( 'child-y' ) , local : true } )
2026-07-15 23:37:52 +08:00
await waitFor ( ( ) = > warn . mock . calls . some ( c = > String ( c [ 0 ] ) . includes ( 'SubagentStart hook failed' ) ) )
expect ( warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'SubagentStart hook failed' ) )
} )
} )
2026-08-13 00:36:22 +08:00
if ( group === 'stop' ) describe ( 'hooks-claude-code coverage — default reasons + sparse payloads' , ( ) = > {
2026-07-15 23:37:52 +08:00
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 )
2026-07-21 03:08:35 +08:00
ctx . tools . register ( defineContentToolFixture ( { name : 'echo' , description : 'e' , parameters : { } , async execute() { 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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
const result = events ( agent ) . find ( e = > e . type === 'tool/result' )
2026-07-28 13:55:59 +08:00
expect ( result ? . type === 'tool/result' && result . data . message . content [ 0 ] . content . some ( b = > b . type === 'text' && b . text . includes ( 'blocked by PreToolUse hook' ) ) ) . toBe ( true )
2026-07-15 23:37:52 +08:00
} )
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 )
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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
const result = events ( agent ) . find ( e = > e . type === 'tool/result' )
2026-07-28 13:55:59 +08:00
expect ( result ? . type === 'tool/result' && result . data . message . content [ 0 ] . content . some ( b = > b . type === 'text' && b . text . includes ( 'blocked by PostToolUse hook' ) ) ) . toBe ( true )
2026-07-15 23:37:52 +08:00
} )
it ( 'SubagentStop with no registered child runs the hook cleanly (fire-and-forget)' , async ( ) = > {
const d = dir ( )
// The agents registry has no entry for the id, so the child lookup yields
// undefined and the payload falls back to base(undefined) — assert the
// observe-only SubagentStop run still executes the hook without crashing.
const marker = join ( d , 'stopran' )
const s = sh ( d , 'stop.sh' , ` #!/usr/bin/env bash \ ntouch " ${ marker } " \ n ` )
const path = hooks ( d , { SubagentStop : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const ctx = await harness ( path , new MockAdapter ( [ ] ) )
2026-07-19 22:13:50 +08:00
ctx . emit ( subagentCarrier ( ctx ) , 'subagent/end' , { runId : SubagentRunId ( 'run-z' ) , provider : 'p' , id : SessionId ( 'child-z' ) , local : false , stopReason : 'completed' } )
2026-07-15 23:37:52 +08:00
await waitFor ( ( ) = > existsSync ( marker ) )
expect ( existsSync ( marker ) ) . toBe ( true )
} )
} )
2026-08-13 00:36:22 +08:00
if ( group === 'edge-paths' ) describe ( 'hooks-claude-code coverage — more default/sparse arms' , ( ) = > {
2026-07-15 23:37:52 +08:00
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 )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
2026-08-04 13:49:35 +08:00
expect ( events ( agent ) . filter ( e = > e . type === 'turn/start' || e . type === 'hook/invoked'
|| e . type === 'hook/result' || e . type === 'turn/end' ) . map ( e = > e . type ) )
. toEqual ( [ 'turn/start' , 'hook/invoked' , 'hook/result' , 'turn/end' ] )
2026-07-15 23:37:52 +08:00
} )
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
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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
// ask (no reason) → degrades to deny with the registry's generic message.
expect ( ran ) . toBe ( false )
2026-07-28 13:55:59 +08:00
expect ( events ( agent ) . some ( e = > e . type === 'tool/result' && e . data . message . content [ 0 ] . isError ) ) . toBe ( true )
2026-07-15 23:37:52 +08:00
} )
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 )
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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
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 )
} )
} )
2026-08-13 00:36:22 +08:00
if ( group === 'edge-paths' ) describe ( 'hooks-claude-code coverage — schema-bypass apply + unspawnable hook' , ( ) = > {
2026-07-15 23:37:52 +08:00
it ( 'a direct apply() (schema bypass) with only configPath runs' , async ( ) = > {
const d = dir ( )
const marker = join ( d , 'ran' )
const s = sh ( d , 'h.sh' , ` #!/usr/bin/env bash \ ntouch " ${ marker } " \ n ` )
hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = new Context ( )
2026-07-17 20:33:24 +08:00
await mountAgentLoopTestDependencies ( ctx )
2026-07-15 23:37:52 +08:00
await ctx . plugin ( AgentLoop , { agents : [ ] } )
2026-08-13 00:36:22 +08:00
await ctx . plugin ( LocalSubprocessRuntime )
2026-07-15 23:37:52 +08:00
await ctx . plugin ( LocalBashExecutor , { timeoutMs : 10_000 } )
// Direct apply with only configPath — bypasses schemastery's defaults, so
// the bridge must run on the raw minimal config (the per-hook timeout is
// the protocol lib's reference default, not a config knob).
HooksClaude . apply ( ctx , { configPath : join ( d , 'hooks.json' ) } )
ctx . llm . registerAdapter ( [ 'mock' ] , adapter )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
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
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 : 'ok' } ] } } ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
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 )
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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
const result = events ( agent ) . find ( e = > e . type === 'tool/result' )
2026-07-28 13:55:59 +08:00
expect ( result ? . type === 'tool/result' && result . data . message . content [ 0 ] . isError ) . toBe ( true )
2026-07-15 23:37:52 +08:00
} )
} )
2026-08-13 00:36:22 +08:00
if ( group === 'context' ) describe ( 'hooks-claude-code coverage — continue:false, context arm, no-cwd' , ( ) = > {
2026-07-15 23:37:52 +08:00
it ( 'a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))' , async ( ) = > {
2026-08-09 15:34:32 +08:00
// The extension points cannot yet honor `continue:false` as a hard halt. The log must still record the
2026-07-15 23:37:52 +08:00
// stop decision while execution and the turn continue normally.
const d = dir ( )
const s = sh ( d , 'stop.sh' , '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n' )
const path = hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( path , adapter )
let ran = false
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 : 'ok' } ] } } ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
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 )
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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
const result = events ( agent ) . find ( e = > e . type === 'tool/result' )
2026-07-28 13:55:59 +08:00
expect ( result ? . type === 'tool/result' && result . data . message . content [ 0 ] . isError ) . toBe ( true )
expect ( result ? . type === 'tool/result' && result . data . message . content [ 0 ] . content . some ( b = > b . type === 'text' && b . text . includes ( 'bad' ) ) ) . toBe ( true )
2026-07-15 23:37:52 +08:00
// additionalContext also injected (the block + context arm).
2026-07-23 19:15:45 +08:00
expect ( events ( agent ) . some ( e = > e . type === 'user/message' && e . data . source . kind !== 'user' && e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'context too' ) ) ) ) . toBe ( true )
2026-07-15 23:37:52 +08:00
} )
it ( 'a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool' , async ( ) = > {
// The block's hookEventName (UserPromptSubmit) mismatches the firing event
// (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs.
const d = dir ( )
const s = sh ( d , 'x.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n' )
const path = hooks ( d , { PreToolUse : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( path , adapter )
let ran = false
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 : 'ok' } ] } } ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
expect ( ran ) . toBe ( true ) // the mismatched deny was discarded → the tool ran
} )
it ( 'defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured' , async ( ) = > {
// The default ACP wiring sets no projectDir. A stock CC hook that references
// $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace,
// not an empty string. The hook echoes the var as additionalContext.
const d = dir ( )
const workspace = dir ( )
const s = sh ( d , 'ctx.sh' , '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n' )
const path = hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ran' ) ] )
const ctx = await harness ( path , adapter ) // NB: no projectDir
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
const { SessionId } = await import ( '@deepseek-ai/dsh-session' )
2026-07-18 12:21:15 +08:00
const handle = await ctx . agents . create ( { sessionId : SessionId ( 's1' ) , meta : { cwd : workspace } , agentOptions : { provider : 'mock' , model : 'mock' } } )
2026-07-28 13:55:59 +08:00
handle . agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-18 12:33:49 +08:00
await waitForIdle ( ctx , handle . agent )
2026-07-23 19:15:45 +08:00
expect ( events ( handle . agent ) . some ( e = > e . type === 'user/message'
2026-07-15 23:37:52 +08:00
&& e . data . content . some ( b = > b . type === 'text' && b . text . includes ( ` dir= ${ workspace } ` ) ) ) ) . toBe ( true )
await handle . dispose ( )
} )
it ( 'a context-only UserPromptSubmit hook DELEGATES so a later listener can still block' , async ( ) = > {
// A context-only hook delegates with `next()` and folds its context, so a downstream policy
// listener can still veto the prompt.
const d = dir ( )
const s = sh ( d , 'ctx.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n' )
const path = hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'should not run' ) ] )
const ctx = await harness ( path , adapter )
// A later listener that blocks every prompt (registered AFTER the bridge).
2026-07-31 19:21:16 +08:00
ctx . on ( 'agent/pre-step' , async ( ) = > ( {
kind : 'reject' as const ,
2026-07-31 14:35:58 +08:00
} ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
// the downstream block won: the model was never called, no user/message was
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
expect ( adapter . requests ) . toHaveLength ( 0 )
2026-07-23 19:15:45 +08:00
expect ( events ( agent ) . some ( e = > e . type === 'user/message' && e . data . source . kind !== 'user' ) ) . toBe ( false )
2026-08-04 13:49:35 +08:00
expect ( events ( agent ) . filter ( e = > e . type === 'turn/start' || e . type === 'hook/invoked'
|| e . type === 'hook/result' || e . type === 'turn/end' ) . map ( e = > e . type ) )
. toEqual ( [ 'turn/start' , 'hook/invoked' , 'hook/result' , 'turn/end' ] )
2026-07-15 23:37:52 +08:00
} )
2026-07-17 20:22:34 +08:00
it ( 'preserves separate bridge and downstream prompt contexts with framing and metadata' , async ( ) = > {
2026-07-31 19:21:16 +08:00
// Both the bridge hook and a later pre-step listener attach context; the
2026-07-17 20:22:34 +08:00
// request must see both as separately sourced durable events.
2026-07-15 23:37:52 +08:00
const d = dir ( )
const s = sh ( d , 'ctx.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n' )
const path = hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( path , adapter )
2026-08-06 12:13:14 +08:00
ctx . on ( 'agent/pre-step' , async ( { messages } ) = > ( {
2026-07-31 19:21:16 +08:00
kind : 'enter' as const ,
2026-07-30 17:28:03 +08:00
messages : [ {
. . . messages [ 0 ] ! ,
content : [ { type : 'text' as const , text : 'rewritten-prompt' } ] ,
} , createUserMessage ( {
2026-07-17 20:22:34 +08:00
content : [ { type : 'text' as const , text : 'from-downstream' } ] ,
source : { kind : 'plugin' as const , plugin : 'policy' } ,
2026-07-28 13:55:59 +08:00
} ) ] ,
2026-07-15 23:37:52 +08:00
} ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
const req = JSON . stringify ( adapter . requests [ 0 ] ! . messages )
expect ( req ) . toContain ( 'from-bridge' )
expect ( req ) . toContain ( 'from-downstream' )
expect ( req ) . toContain ( 'rewritten-prompt' ) // downstream content rewrite preserved
// the original prompt was replaced by the downstream rewrite
const userMsg = events ( agent ) . find ( e = > e . type === 'user/message' )
expect ( userMsg ? . type === 'user/message' && userMsg . data . content . some ( b = > b . type === 'text' && b . text === 'rewritten-prompt' ) ) . toBe ( true )
2026-07-23 19:15:45 +08:00
const contexts = events ( agent ) . filter ( event = > event . type === 'user/message' && event . data . source . kind !== 'user' )
expect ( contexts . map ( event = > event . type === 'user/message' && event . data . source ) ) . toEqual ( [
2026-07-17 20:22:34 +08:00
{ kind : 'plugin' , plugin : 'policy' } ,
2026-08-13 00:36:22 +08:00
{ kind : 'plugin' , plugin : 'hooks-claude-code' } ,
2026-07-17 20:22:34 +08:00
] )
2026-07-15 23:37:52 +08:00
} )
2026-07-21 03:08:35 +08:00
it ( 'folds the bridge PostToolUse context onto a downstream canonical value replacement' , async ( ) = > {
2026-07-15 23:37:52 +08:00
// The bridge hook adds context; a later post-execute listener accepts with a
2026-07-21 03:08:35 +08:00
// canonical replacement. Both the replacement and the bridge context survive.
2026-07-15 23:37:52 +08:00
const d = dir ( )
const s = sh ( d , 'ctx.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n' )
const path = hooks ( d , { PostToolUse : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( path , adapter )
2026-07-21 03:08:35 +08:00
ctx . tools . register ( defineContentToolFixture ( { name : 'echo' , description : 'e' , parameters : { } , async execute() { return [ { type : 'text' , text : 'ok' } ] } } ) )
ctx . on ( 'tools/post-execute' , async ( ) = > ( { kind : 'accept' as const , value : [ { type : 'text' as const , text : 'rewritten-result' } ] } ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
const result = events ( agent ) . find ( e = > e . type === 'tool/result' )
2026-07-28 13:55:59 +08:00
expect ( result ? . type === 'tool/result' && result . data . message . content [ 0 ] . content . some ( b = > b . type === 'text' && b . text === 'rewritten-result' ) ) . toBe ( true )
2026-07-23 19:15:45 +08:00
expect ( events ( agent ) . some ( e = > e . type === 'user/message' && e . data . source . kind !== 'user' && e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'bridge-note' ) ) ) ) . toBe ( true )
2026-07-15 23:37:52 +08:00
} )
2026-07-17 20:22:34 +08:00
it ( 'keeps bridge and downstream PostToolUse contexts as separate sourced events' , async ( ) = > {
const d = dir ( )
const s = sh ( d , 'ctx.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n' )
const path = hooks ( d , { PostToolUse : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( path , adapter )
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-17 20:22:34 +08:00
ctx . on ( 'tools/post-execute' , async ( ) = > ( {
kind : 'accept' as const ,
2026-07-28 13:55:59 +08:00
additionalContexts : [ createUserMessage ( {
2026-07-17 20:22:34 +08:00
content : [ { type : 'text' as const , text : 'downstream-note' } ] ,
source : { kind : 'plugin' as const , plugin : 'policy' } ,
2026-07-28 13:55:59 +08:00
} ) ] ,
2026-07-17 20:22:34 +08:00
} ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-17 20:22:34 +08:00
await waitForIdle ( ctx , agent )
2026-07-23 19:15:45 +08:00
const contexts = events ( agent ) . filter ( event = > event . type === 'user/message' && event . data . source . kind !== 'user' )
expect ( contexts . map ( event = > event . type === 'user/message' && event . data . source ) ) . toEqual ( [
2026-08-13 00:36:22 +08:00
{ kind : 'plugin' , plugin : 'hooks-claude-code' } ,
2026-07-17 20:22:34 +08:00
{ kind : 'plugin' , plugin : 'policy' } ,
] )
} )
2026-07-15 23:37:52 +08:00
it ( 'folds the bridge PostToolUse context onto a downstream listener BLOCK' , async ( ) = > {
// The bridge hook only adds context; a later post-execute listener blocks the
// result. The block wins AND carries the bridge context (concatContext on the
// block arm).
const d = dir ( )
const s = sh ( d , 'ctx.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n' )
const path = hooks ( d , { PostToolUse : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = await harness ( path , adapter )
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-15 23:37:52 +08:00
ctx . on ( 'tools/post-execute' , async ( ) = > ( { kind : 'block' as const , feedback : [ { type : 'text' as const , text : 'downstream-block' } ] } ) )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
const result = events ( agent ) . find ( e = > e . type === 'tool/result' )
2026-07-28 13:55:59 +08:00
expect ( result ? . type === 'tool/result' && result . data . message . content [ 0 ] . isError ) . toBe ( true )
expect ( result ? . type === 'tool/result' && result . data . message . content [ 0 ] . content . some ( b = > b . type === 'text' && b . text . includes ( 'downstream-block' ) ) ) . toBe ( true )
2026-07-15 23:37:52 +08:00
// the bridge's context still landed (folded onto the block)
2026-07-23 19:15:45 +08:00
expect ( events ( agent ) . some ( e = > e . type === 'user/message' && e . data . source . kind !== 'user' && e . data . content . some ( b = > b . type === 'text' && b . text . includes ( 'bridge-note' ) ) ) ) . toBe ( true )
2026-07-15 23:37:52 +08:00
} )
} )
2026-08-13 00:36:22 +08:00
if ( group === 'edge-paths' ) describe ( 'hooks-claude-code coverage — executor reject + no-open-turn' , ( ) = > {
2026-07-15 23:37:52 +08:00
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.
2026-08-13 00:36:22 +08:00
const bash = ctx . shell
2026-07-15 23:37:52 +08:00
bash . run = ( ( ) = > Promise . reject ( new Error ( 'executor down' ) ) )
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-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
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-08-13 00:36:22 +08:00
if ( group === 'edge-paths' ) describe ( 'hooks-claude-code coverage — detached-listener catch handlers' , ( ) = > {
2026-07-15 23:37:52 +08:00
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 )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-15 23:37:52 +08:00
// Make inject throw, forcing the SessionStart .catch path.
const original = agent . inject . bind ( agent )
let threw = false
agent . inject = ( ( ) = > { threw = true ; throw new Error ( 'inject boom' ) } )
await waitFor ( ( ) = > threw )
expect ( threw ) . toBe ( true )
agent . inject = original
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
expect ( adapter . requests ) . toHaveLength ( 1 ) // loop survived the thrown inject
} )
} )
2026-08-13 00:36:22 +08:00
if ( group === 'stop' ) describe ( 'hooks-claude-code coverage — hook runs in the session cwd, not the server cwd' , ( ) = > {
2026-07-15 23:37:52 +08:00
it ( 'runs an agent-scoped hook in the session workspace even when the executor default differs' , async ( ) = > {
// The server launch directory and session cwd deliberately differ. The marker proves the
// bridge passes `session/new.cwd` instead of falling back to the executor default.
const serverDir = dir ( )
const sessionDir = dir ( )
const marker = join ( sessionDir , 'where' )
// The hook is invoked with cwd = session dir, so a relative marker path lands there.
hooks ( serverDir , { PreToolUse : [ { hooks : [ { type : 'command' , command : 'pwd > where' } ] } ] } )
const adapter = new MockAdapter ( [ toolCallResponse ( 'c1' , 'echo' , { } ) , textResponse ( 'done' ) ] )
const ctx = new Context ( )
2026-07-17 20:33:24 +08:00
await mountAgentLoopTestDependencies ( ctx )
2026-07-15 23:37:52 +08:00
await ctx . plugin ( AgentLoop , { agents : [ ] } )
// Executor default cwd = serverDir (deliberately NOT the session cwd).
2026-08-13 00:36:22 +08:00
await ctx . plugin ( LocalSubprocessRuntime )
2026-07-15 23:37:52 +08:00
await ctx . plugin ( LocalBashExecutor , { timeoutMs : 10_000 , cwd : serverDir } )
await ctx . plugin ( HooksClaude , { configPath : join ( serverDir , 'hooks.json' ) } )
ctx . llm . registerAdapter ( [ 'mock' ] , 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-15 23:37:52 +08:00
const { SessionId } = await import ( '@deepseek-ai/dsh-session' )
2026-07-18 12:21:15 +08:00
const handle = await ctx . agents . create ( { sessionId : SessionId ( 's1' ) , meta : { cwd : sessionDir } , agentOptions : { provider : 'mock' , model : 'mock' } } )
2026-07-28 13:55:59 +08:00
handle . agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-18 12:33:49 +08:00
await waitForIdle ( ctx , handle . agent )
2026-07-15 23:37:52 +08:00
expect ( existsSync ( marker ) ) . toBe ( true ) // the marker landed in the SESSION dir
const { readFileSync } = await import ( 'node:fs' )
const where = readFileSync ( marker , 'utf8' ) . trim ( )
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
expect ( where . endsWith ( sessionDir . split ( '/' ) . pop ( ) ! ) ) . toBe ( true )
await handle . dispose ( )
} )
it ( 'runs a SubagentStop hook in the CHILD session workspace, not the server cwd' , async ( ) = > {
const serverDir = dir ( )
const childDir = dir ( )
const marker = join ( childDir , 'stopwhere' )
2026-07-30 21:21:14 +08:00
const payload = join ( childDir , 'stoppayload' )
hooks ( serverDir , { SubagentStop : [ { hooks : [ { type : 'command' , command : 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' } ] } ] } )
2026-07-15 23:37:52 +08:00
const ctx = new Context ( )
2026-07-17 20:33:24 +08:00
await mountAgentLoopTestDependencies ( ctx )
2026-07-15 23:37:52 +08:00
await ctx . plugin ( AgentLoop , { agents : [ ] } )
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
2026-08-13 00:36:22 +08:00
await ctx . plugin ( LocalSubprocessRuntime )
2026-07-15 23:37:52 +08:00
await ctx . plugin ( LocalBashExecutor , { timeoutMs : 10_000 , cwd : serverDir } )
await ctx . plugin ( HooksClaude , { configPath : join ( serverDir , 'hooks.json' ) } )
ctx . llm . registerAdapter ( [ 'mock' ] , new MockAdapter ( [ ] ) )
const { SessionId } = await import ( '@deepseek-ai/dsh-session' )
2026-07-18 12:21:15 +08:00
const childHandle = await ctx . agents . create ( { sessionId : SessionId ( 'child-stop-session' ) , meta : { cwd : childDir } , agentOptions : { provider : 'mock' , model : 'mock' } } )
2026-07-30 21:21:14 +08:00
const runId = SubagentRunId ( 'run-stop' )
const identity = { runId , provider : 'inproc' , id : childHandle.agent.id , local : true }
// Start is the registry-backed capture edge; end deliberately follows
// handle disposal, matching continuable Activation settlement.
ctx . emit ( subagentCarrier ( ctx ) , 'subagent/start' , identity )
await childHandle . dispose ( )
expect ( ctx . agents . get ( childHandle . agent . id ) ) . toBeUndefined ( )
ctx . emit ( subagentCarrier ( ctx ) , 'subagent/end' , { . . . identity , stopReason : 'completed' } )
2026-07-15 23:37:52 +08:00
await waitFor ( ( ) = > existsSync ( marker ) )
expect ( existsSync ( marker ) ) . toBe ( true ) // the marker landed in the CHILD dir
const where = readFileSync ( marker , 'utf8' ) . trim ( )
2026-07-30 21:21:14 +08:00
const input = JSON . parse ( readFileSync ( payload , 'utf8' ) ) as { cwd : string ; session_id : string }
2026-07-15 23:37:52 +08:00
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
expect ( where . endsWith ( childDir . split ( '/' ) . pop ( ) ! ) ) . toBe ( true )
2026-07-30 21:21:14 +08:00
expect ( input ) . toMatchObject ( { cwd : childDir , session_id : childHandle.agent.id } )
2026-07-15 23:37:52 +08:00
} )
} )
2026-08-13 00:36:22 +08:00
if ( group === 'config' ) describe ( 'hooks-claude-code coverage — systemMessage is warned, not surfaced' , ( ) = > {
2026-07-15 23:37:52 +08:00
it ( 'a hook emitting a systemMessage is logged as not-yet-surfaced' , async ( ) = > {
const d = dir ( )
const s = sh ( d , 'sm.sh' , '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n' )
const path = hooks ( d , { UserPromptSubmit : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( path , adapter )
const warn = vi . fn ( ) ; ctx . logger . warn = warn as never
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
expect ( warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'systemMessage' ) )
// Not surfaced: the systemMessage text never reaches the model request.
expect ( JSON . stringify ( adapter . requests [ 0 ] ! . messages ) ) . not . toContain ( 'heads up' )
} )
} )
2026-08-13 00:36:22 +08:00
if ( group === 'edge-paths' ) describe ( 'hooks-claude-code coverage — SessionStart timing is best-effort (no-wait)' , ( ) = > {
2026-07-15 23:37:52 +08:00
it ( 'does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)' , async ( ) = > {
// Session-start injection is detached, so an immediate prompt need not observe it. Assert only
// the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race.
const d = dir ( )
const s = sh ( d , 'start.sh' , '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n' )
const path = hooks ( d , { SessionStart : [ { hooks : [ { type : 'command' , command : s } ] } ] } )
const adapter = new MockAdapter ( [ textResponse ( 'ok' ) ] )
const ctx = await harness ( path , adapter )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'a1' ) , { provider : 'mock' , model : 'mock' } )
2026-07-15 23:37:52 +08:00
// Send immediately — do NOT wait for the session-start inject.
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'go' } ] , source : { kind : 'user' } } ) )
2026-07-15 23:37:52 +08:00
await waitForIdle ( ctx , agent )
expect ( adapter . requests ) . toHaveLength ( 1 ) // the turn ran regardless of hook timing
} )
} )
}