coverage-cases.ts 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763
  1. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  8. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  9. import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  12. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  13. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  14. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  15. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  16. import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
  17. import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code'
  18. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  19. const testToolSignal = new AbortController().signal
  20. /** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent
  21. * fallbacks, contextFrom-empty, and the detached-listener catch handlers. */
  22. const dirs: string[] = []
  23. const contexts: Context[] = []
  24. afterEach(async () => {
  25. for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
  26. for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
  27. })
  28. function subagentCarrier(ctx: Context) {
  29. return scopeTarget(ctx as unknown as SubagentRuntime, undefined)
  30. }
  31. function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d }
  32. function sh(d: string, name: string, body: string): string {
  33. const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p
  34. }
  35. function hooks(d: string, h: unknown): string {
  36. writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
  37. }
  38. type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number; sessionRoot?: string }
  39. async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
  40. const ctx = new Context()
  41. contexts.push(ctx)
  42. await mountAgentLoopTestDependencies(ctx)
  43. if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
  44. await ctx.plugin(AgentLoop, { agents: [] })
  45. await ctx.plugin(LocalSubprocessRuntime)
  46. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  47. await ctx.plugin(HooksClaude, { configPath, ...opts })
  48. ctx.llm.registerAdapter(['mock'], adapter)
  49. return ctx
  50. }
  51. function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
  52. return agent.whenIdle()
  53. }
  54. function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() }
  55. /** Poll until `predicate` holds or the deadline passes — robust to detached
  56. * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
  57. async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
  58. const deadline = Date.now() + timeout
  59. while (!predicate()) {
  60. if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
  61. await new Promise(r => setTimeout(r, interval))
  62. }
  63. }
  64. export type CoverageGroup = 'config' | 'stop' | 'context' | 'edge-paths'
  65. /** Register independently schedulable slices of the hooks-claude-code coverage matrix. */
  66. export function defineCoverageCases(group: CoverageGroup): void {
  67. if (group === 'config') describe('hooks-claude-code coverage — config option arms + substitution + skip warning', () => {
  68. it('degrades transcript_path to the empty string even with persistence mounted', async () => {
  69. const d = dir()
  70. const cap = join(d, 'payload')
  71. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
  72. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  73. const ctx = await harness(path, adapter, { sessionRoot: dir() })
  74. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  75. const agent = await ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
  76. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  77. await waitForIdle(ctx, agent)
  78. // The persistence seam exposes no artifact paths, so the field stays ''
  79. // even with a persistence backend mounted.
  80. expect((JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string }).transcript_path).toBe('')
  81. }, 15_000) // The real agent/hook subprocess loop needs process startup and teardown headroom.
  82. it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
  83. const d = dir()
  84. // ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker.
  85. const marker = join(d, 'ran')
  86. sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
  87. const path = hooks(d, {
  88. PreToolUse: [{ hooks: [
  89. { type: 'prompt', prompt: 'skipme' }, // skipped → warn loop
  90. { type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/h.sh' }, // substituted
  91. ] }],
  92. })
  93. const warn = vi.fn()
  94. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  95. const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d })
  96. ctx.logger.warn = warn as never
  97. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  98. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  99. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  100. await waitForIdle(ctx, agent)
  101. expect(existsSync(marker)).toBe(true) // substituted command ran
  102. }, 15_000) // Real agent and hook subprocess startup can exceed Vitest's default under coverage concurrency.
  103. it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => {
  104. const d = dir()
  105. const s = sh(d, 'u.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"rewritten"}}}\'\n')
  106. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  107. const warn = vi.fn()
  108. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { command: 'original' }), textResponse('done')])
  109. const ctx = await harness(path, adapter)
  110. ctx.logger.warn = warn as never
  111. let sawArgs: unknown
  112. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
  113. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  114. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  115. await waitForIdle(ctx, agent)
  116. // updatedInput is NOT honored — the tool ran with the ORIGINAL args.
  117. expect((sawArgs as { command?: string }).command).toBe('original')
  118. expect(warn).toHaveBeenCalledWith(expect.stringContaining('updatedInput'))
  119. })
  120. })
  121. if (group === 'config') describe('hooks-claude-code coverage — empty/no-op outcomes and no-agent paths', () => {
  122. it('a clean exit-0 hook with no output is a no-op (contextFrom empty → next())', async () => {
  123. const d = dir()
  124. const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n')
  125. const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
  126. const adapter = new MockAdapter([textResponse('ran')])
  127. const ctx = await harness(path, adapter)
  128. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  129. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  130. await waitForIdle(ctx, agent)
  131. // The prompt proceeded unchanged; no injected context.
  132. expect(adapter.requests).toHaveLength(1)
  133. expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
  134. })
  135. it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => {
  136. const d = dir()
  137. const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\necho "no" >&2\nexit 2\n')
  138. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  139. const ctx = await harness(path, new MockAdapter([]))
  140. let ran = false
  141. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
  142. const { ToolCallId } = await import('@deepseek-ai/dsh-llm')
  143. const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {} })
  144. expect(ran).toBe(false)
  145. expect(result.isError).toBe(true)
  146. })
  147. it('a long stderr is truncated in the hook/result summary', async () => {
  148. const d = dir()
  149. const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
  150. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  151. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  152. const ctx = await harness(path, adapter)
  153. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  154. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  155. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  156. await waitForIdle(ctx, agent)
  157. const res = events(agent).find(e => e.type === 'hook/result')
  158. expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
  159. expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
  160. })
  161. it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
  162. const d = dir()
  163. const path = hooks(d, {})
  164. for (const bad of [0, -5, 1.5, Number.NaN]) {
  165. const adapter = new MockAdapter([])
  166. await expect(harness(path, adapter, { stderrSummaryMaxChars: bad }))
  167. .rejects.toThrow(/hooks-claude-code: stderrSummaryMaxChars must be a positive integer/)
  168. }
  169. })
  170. it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
  171. const d = dir()
  172. const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
  173. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  174. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  175. const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
  176. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  177. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  178. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  179. await waitForIdle(ctx, agent)
  180. const res = events(agent).find(e => e.type === 'hook/result')
  181. expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
  182. })
  183. })
  184. if (group === 'stop') describe('hooks-claude-code coverage — Stop continuation + subagent inject/catch', () => {
  185. it('a Stop hook that blocks (exit 2) forces the turn to continue (CC dialect)', async () => {
  186. const d = dir()
  187. const marker = join(d, 'fired')
  188. 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`)
  189. const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
  190. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  191. const ctx = await harness(path, adapter)
  192. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  193. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  194. await waitForIdle(ctx, agent)
  195. expect(adapter.requests).toHaveLength(2)
  196. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please')
  197. })
  198. it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => {
  199. // A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces
  200. // continuation; the script self-limits to one block to avoid a loop.
  201. const d = dir()
  202. const marker = join(d, 'fired')
  203. const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`)
  204. const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
  205. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  206. const ctx = await harness(path, adapter)
  207. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  208. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  209. await waitForIdle(ctx, agent)
  210. // A second model request ran → the empty-reason block forced continuation.
  211. expect(adapter.requests).toHaveLength(2)
  212. // The steering carried the fallback reason (no stderr to use).
  213. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
  214. })
  215. it('SubagentStart additionalContext is injected into a REGISTERED live child', async () => {
  216. const d = dir()
  217. const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n')
  218. const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
  219. const ctx = await harness(path, new MockAdapter([]))
  220. const injected: string[] = []
  221. const child = {
  222. id: SessionId('child-x'),
  223. inject: (input: { content: Array<{ type: string; text?: string }> }) => {
  224. injected.push(input.content.map(block => block.text ?? '').join(''))
  225. },
  226. session: { id: SessionId('child-x'), header: { id: 'child-x' } },
  227. } as unknown as Parameters<typeof ctx.agents.register>[0]
  228. await ctx.agents.register(child)
  229. ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true })
  230. await waitFor(() => injected.includes('child guidance'))
  231. expect(injected).toContain('child guidance')
  232. })
  233. it('a throwing SubagentStart/SubagentStop hook run is contained (logged)', async () => {
  234. const d = dir()
  235. // A hook command that does not exist makes runHook resolve a non-blocking
  236. // error (not a throw), so to hit the .catch we make the .then throw: register
  237. // a child whose inject throws for SubagentStart.
  238. const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n')
  239. const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
  240. const ctx = await harness(path, new MockAdapter([]))
  241. const warn = vi.fn(); ctx.logger.warn = warn as never
  242. 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]
  243. await ctx.agents.register(child)
  244. ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true })
  245. await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
  246. expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
  247. })
  248. })
  249. if (group === 'stop') describe('hooks-claude-code coverage — default reasons + sparse payloads', () => {
  250. it('PreToolUse deny with EMPTY stderr uses the default reason', async () => {
  251. const d = dir()
  252. const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') // exit 2, no stderr
  253. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  254. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  255. const ctx = await harness(path, adapter)
  256. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
  257. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  258. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  259. await waitForIdle(ctx, agent)
  260. const result = events(agent).find(e => e.type === 'tool/result')
  261. 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)
  262. })
  263. it('PostToolUse deny with EMPTY stderr + no context uses the default feedback', async () => {
  264. const d = dir()
  265. const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
  266. const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  267. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  268. const ctx = await harness(path, adapter)
  269. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  270. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  271. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  272. await waitForIdle(ctx, agent)
  273. const result = events(agent).find(e => e.type === 'tool/result')
  274. 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)
  275. })
  276. it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => {
  277. const d = dir()
  278. // The agents registry has no entry for the id, so the child lookup yields
  279. // undefined and the payload falls back to base(undefined) — assert the
  280. // observe-only SubagentStop run still executes the hook without crashing.
  281. const marker = join(d, 'stopran')
  282. const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
  283. const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
  284. const ctx = await harness(path, new MockAdapter([]))
  285. ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' })
  286. await waitFor(() => existsSync(marker))
  287. expect(existsSync(marker)).toBe(true)
  288. })
  289. })
  290. if (group === 'edge-paths') describe('hooks-claude-code coverage — more default/sparse arms', () => {
  291. it('UserPromptSubmit deny with EMPTY stderr uses the default block reason', async () => {
  292. const d = dir()
  293. const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
  294. const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
  295. const adapter = new MockAdapter([textResponse('no')])
  296. const ctx = await harness(path, adapter)
  297. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  298. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  299. await waitForIdle(ctx, agent)
  300. expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
  301. || e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
  302. .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
  303. })
  304. it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => {
  305. const d = dir()
  306. const s = sh(d, 'ask.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}\'\n')
  307. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  308. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  309. const ctx = await harness(path, adapter)
  310. let ran = false
  311. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
  312. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  313. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  314. await waitForIdle(ctx, agent)
  315. // ask (no reason) → degrades to deny with the registry's generic message.
  316. expect(ran).toBe(false)
  317. expect(events(agent).some(e => e.type === 'tool/result' && e.data.message.content[0].isError)).toBe(true)
  318. })
  319. it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => {
  320. const d = dir()
  321. const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n')
  322. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  323. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  324. const ctx = await harness(path, adapter)
  325. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  326. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  327. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  328. await waitForIdle(ctx, agent)
  329. const res = events(agent).find(e => e.type === 'hook/result')
  330. expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
  331. expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
  332. })
  333. })
  334. if (group === 'edge-paths') describe('hooks-claude-code coverage — schema-bypass apply + unspawnable hook', () => {
  335. it('a direct apply() (schema bypass) with only configPath runs', async () => {
  336. const d = dir()
  337. const marker = join(d, 'ran')
  338. const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
  339. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
  340. const adapter = new MockAdapter([textResponse('ok')])
  341. const ctx = new Context()
  342. await mountAgentLoopTestDependencies(ctx)
  343. await ctx.plugin(AgentLoop, { agents: [] })
  344. await ctx.plugin(LocalSubprocessRuntime)
  345. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  346. // Direct apply with only configPath — bypasses schemastery's defaults, so
  347. // the bridge must run on the raw minimal config (the per-hook timeout is
  348. // the protocol lib's reference default, not a config knob).
  349. HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
  350. ctx.llm.registerAdapter(['mock'], adapter)
  351. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  352. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  353. await waitForIdle(ctx, agent)
  354. expect(existsSync(marker)).toBe(true)
  355. })
  356. 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 () => {
  357. const d = dir()
  358. // `bash -c` of a missing program exits 127 — a non-blocking error (not 0, not
  359. // 2 → no decision), so the tool proceeds; the hook/result records exit 127.
  360. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: '/nonexistent/definitely/not/a/command' }] }] })
  361. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  362. const ctx = await harness(path, adapter)
  363. let ran = false
  364. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  365. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  366. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  367. await waitForIdle(ctx, agent)
  368. expect(ran).toBe(true)
  369. const res = events(agent).find(e => e.type === 'hook/result')
  370. expect(res?.type === 'hook/result' && res.data.exitCode).toBe(127)
  371. })
  372. it('a PostToolUse deny with empty stderr + no context uses the default feedback (no context arm)', async () => {
  373. const d = dir()
  374. const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
  375. const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  376. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  377. const ctx = await harness(path, adapter)
  378. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  379. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  380. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  381. await waitForIdle(ctx, agent)
  382. const result = events(agent).find(e => e.type === 'tool/result')
  383. expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
  384. })
  385. })
  386. if (group === 'context') describe('hooks-claude-code coverage — continue:false, context arm, no-cwd', () => {
  387. it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
  388. // The extension points cannot yet honor `continue:false` as a hard halt. The log must still record the
  389. // stop decision while execution and the turn continue normally.
  390. const d = dir()
  391. const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n')
  392. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  393. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  394. const ctx = await harness(path, adapter)
  395. let ran = false
  396. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  397. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  398. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  399. await waitForIdle(ctx, agent)
  400. const res = events(agent).find(e => e.type === 'hook/result')
  401. expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
  402. expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
  403. const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
  404. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') // ran to completion
  405. })
  406. it('a PostToolUse hook that BOTH blocks AND attaches additionalContext', async () => {
  407. const d = dir()
  408. const s = sh(d, 'b.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"context too"}}\'\n')
  409. const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  410. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  411. const ctx = await harness(path, adapter)
  412. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  413. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  414. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  415. await waitForIdle(ctx, agent)
  416. const result = events(agent).find(e => e.type === 'tool/result')
  417. expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
  418. expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
  419. // additionalContext also injected (the block + context arm).
  420. 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)
  421. })
  422. it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => {
  423. // The block's hookEventName (UserPromptSubmit) mismatches the firing event
  424. // (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs.
  425. const d = dir()
  426. const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n')
  427. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  428. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  429. const ctx = await harness(path, adapter)
  430. let ran = false
  431. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  432. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  433. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  434. await waitForIdle(ctx, agent)
  435. expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
  436. })
  437. it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => {
  438. // The default ACP wiring sets no projectDir. A stock CC hook that references
  439. // $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace,
  440. // not an empty string. The hook echoes the var as additionalContext.
  441. const d = dir()
  442. const workspace = dir()
  443. const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n')
  444. const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
  445. const adapter = new MockAdapter([textResponse('ran')])
  446. const ctx = await harness(path, adapter) // NB: no projectDir
  447. // The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
  448. const { SessionId } = await import('@deepseek-ai/dsh-session')
  449. const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } })
  450. handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  451. await waitForIdle(ctx, handle.agent)
  452. expect(events(handle.agent).some(e => e.type === 'user/message'
  453. && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
  454. await handle.dispose()
  455. })
  456. it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
  457. // A context-only hook delegates with `next()` and folds its context, so a downstream policy
  458. // listener can still veto the prompt.
  459. const d = dir()
  460. const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n')
  461. const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
  462. const adapter = new MockAdapter([textResponse('should not run')])
  463. const ctx = await harness(path, adapter)
  464. // A later listener that blocks every prompt (registered AFTER the bridge).
  465. ctx.on('agent/pre-step', async () => ({
  466. kind: 'reject' as const,
  467. }))
  468. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  469. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  470. await waitForIdle(ctx, agent)
  471. // the downstream block won: the model was never called, no user/message was
  472. // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
  473. expect(adapter.requests).toHaveLength(0)
  474. expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
  475. expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
  476. || e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
  477. .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
  478. })
  479. it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {
  480. // Both the bridge hook and a later pre-step listener attach context; the
  481. // request must see both as separately sourced durable events.
  482. const d = dir()
  483. const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n')
  484. const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
  485. const adapter = new MockAdapter([textResponse('ok')])
  486. const ctx = await harness(path, adapter)
  487. ctx.on('agent/pre-step', async ({ messages }) => ({
  488. kind: 'enter' as const,
  489. messages: [{
  490. ...messages[0]!,
  491. content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
  492. }, createUserMessage({
  493. content: [{ type: 'text' as const, text: 'from-downstream' }],
  494. source: { kind: 'plugin' as const, plugin: 'policy' },
  495. })],
  496. }))
  497. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  498. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  499. await waitForIdle(ctx, agent)
  500. const req = JSON.stringify(adapter.requests[0]!.messages)
  501. expect(req).toContain('from-bridge')
  502. expect(req).toContain('from-downstream')
  503. expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved
  504. // the original prompt was replaced by the downstream rewrite
  505. const userMsg = events(agent).find(e => e.type === 'user/message')
  506. expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true)
  507. const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
  508. expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
  509. { kind: 'plugin', plugin: 'policy' },
  510. { kind: 'plugin', plugin: 'hooks-claude-code' },
  511. ])
  512. })
  513. it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => {
  514. // The bridge hook adds context; a later post-execute listener accepts with a
  515. // canonical replacement. Both the replacement and the bridge context survive.
  516. const d = dir()
  517. const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
  518. const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  519. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  520. const ctx = await harness(path, adapter)
  521. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  522. ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
  523. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  524. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  525. await waitForIdle(ctx, agent)
  526. const result = events(agent).find(e => e.type === 'tool/result')
  527. expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
  528. 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)
  529. })
  530. it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => {
  531. const d = dir()
  532. const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
  533. const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  534. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  535. const ctx = await harness(path, adapter)
  536. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  537. ctx.on('tools/post-execute', async () => ({
  538. kind: 'accept' as const,
  539. additionalContexts: [createUserMessage({
  540. content: [{ type: 'text' as const, text: 'downstream-note' }],
  541. source: { kind: 'plugin' as const, plugin: 'policy' },
  542. })],
  543. }))
  544. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  545. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  546. await waitForIdle(ctx, agent)
  547. const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
  548. expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
  549. { kind: 'plugin', plugin: 'hooks-claude-code' },
  550. { kind: 'plugin', plugin: 'policy' },
  551. ])
  552. })
  553. it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
  554. // The bridge hook only adds context; a later post-execute listener blocks the
  555. // result. The block wins AND carries the bridge context (concatContext on the
  556. // block arm).
  557. const d = dir()
  558. const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
  559. const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  560. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  561. const ctx = await harness(path, adapter)
  562. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  563. ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
  564. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  565. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  566. await waitForIdle(ctx, agent)
  567. const result = events(agent).find(e => e.type === 'tool/result')
  568. expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
  569. expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
  570. // the bridge's context still landed (folded onto the block)
  571. 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)
  572. })
  573. })
  574. if (group === 'edge-paths') describe('hooks-claude-code coverage — executor reject + no-open-turn', () => {
  575. it('when the bash executor REJECTS a hook run, the hook/result omits exitCode (non-blocking)', async () => {
  576. const d = dir()
  577. const s = sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n')
  578. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
  579. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  580. const ctx = await harness(path, adapter)
  581. // Force the executor to reject (an infrastructure fault) so runHook's catch
  582. // yields a HookOutput with exitCode undefined → the `exitCode` spread false arm.
  583. const bash = ctx.shell
  584. bash.run = (() => Promise.reject(new Error('executor down')))
  585. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  586. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  587. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  588. await waitForIdle(ctx, agent)
  589. const res = events(agent).find(e => e.type === 'hook/result')
  590. expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
  591. })
  592. })
  593. if (group === 'edge-paths') describe('hooks-claude-code coverage — lifecycle error handling', () => {
  594. it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => {
  595. const d = dir()
  596. const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n')
  597. const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
  598. const adapter = new MockAdapter([textResponse('ok')])
  599. const ctx = await harness(path, adapter)
  600. const warn = vi.fn(); ctx.logger.warn = warn as never
  601. try {
  602. const { agent } = await ctx.agents.create({
  603. sessionId: SessionId('a1'),
  604. agentOptions: { provider: 'mock', model: 'mock' },
  605. setup(_agentCtx, agent) {
  606. vi.spyOn(agent, 'inject').mockImplementationOnce(() => { throw new Error('inject boom') })
  607. },
  608. })
  609. expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
  610. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  611. await waitForIdle(ctx, agent)
  612. expect(adapter.requests).toHaveLength(1)
  613. } finally {
  614. await ctx.fiber.dispose()
  615. }
  616. })
  617. })
  618. if (group === 'stop') describe('hooks-claude-code coverage — hook runs in the session cwd, not the server cwd', () => {
  619. it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => {
  620. // The server launch directory and session cwd deliberately differ. The marker proves the
  621. // bridge passes `session/new.cwd` instead of falling back to the executor default.
  622. const serverDir = dir()
  623. const sessionDir = dir()
  624. const marker = join(sessionDir, 'where')
  625. // The hook is invoked with cwd = session dir, so a relative marker path lands there.
  626. hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
  627. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  628. const ctx = new Context()
  629. await mountAgentLoopTestDependencies(ctx)
  630. await ctx.plugin(AgentLoop, { agents: [] })
  631. // Executor default cwd = serverDir (deliberately NOT the session cwd).
  632. await ctx.plugin(LocalSubprocessRuntime)
  633. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
  634. await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
  635. ctx.llm.registerAdapter(['mock'], adapter)
  636. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  637. const { SessionId } = await import('@deepseek-ai/dsh-session')
  638. const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
  639. handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  640. await waitForIdle(ctx, handle.agent)
  641. expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
  642. const { readFileSync } = await import('node:fs')
  643. const where = readFileSync(marker, 'utf8').trim()
  644. // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
  645. expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true)
  646. await handle.dispose()
  647. })
  648. it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
  649. const serverDir = dir()
  650. const childDir = dir()
  651. const marker = join(childDir, 'stopwhere')
  652. const payload = join(childDir, 'stoppayload')
  653. hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] })
  654. const ctx = new Context()
  655. await mountAgentLoopTestDependencies(ctx)
  656. await ctx.plugin(AgentLoop, { agents: [] })
  657. // Executor default cwd = serverDir (deliberately NOT the child session cwd).
  658. await ctx.plugin(LocalSubprocessRuntime)
  659. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
  660. await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
  661. ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
  662. const { SessionId } = await import('@deepseek-ai/dsh-session')
  663. const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } })
  664. const runId = SubagentRunId('run-stop')
  665. const identity = { runId, provider: 'inproc', id: childHandle.agent.id, local: true }
  666. // Start is the registry-backed capture edge; end deliberately follows
  667. // handle disposal, matching continuable Activation settlement.
  668. ctx.emit(subagentCarrier(ctx), 'subagent/start', identity)
  669. await childHandle.dispose()
  670. expect(ctx.agents.get(childHandle.agent.id)).toBeUndefined()
  671. ctx.emit(subagentCarrier(ctx), 'subagent/end', { ...identity, stopReason: 'completed' })
  672. await waitFor(() => existsSync(marker))
  673. expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
  674. const where = readFileSync(marker, 'utf8').trim()
  675. const input = JSON.parse(readFileSync(payload, 'utf8')) as { cwd: string; session_id: string }
  676. // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
  677. expect(where.endsWith(childDir.split('/').pop()!)).toBe(true)
  678. expect(input).toMatchObject({ cwd: childDir, session_id: childHandle.agent.id })
  679. })
  680. })
  681. if (group === 'config') describe('hooks-claude-code coverage — systemMessage is warned, not surfaced', () => {
  682. it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => {
  683. const d = dir()
  684. const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n')
  685. const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
  686. const adapter = new MockAdapter([textResponse('ok')])
  687. const ctx = await harness(path, adapter)
  688. const warn = vi.fn(); ctx.logger.warn = warn as never
  689. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  690. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  691. await waitForIdle(ctx, agent)
  692. expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
  693. // Not surfaced: the systemMessage text never reaches the model request.
  694. expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
  695. })
  696. })
  697. if (group === 'edge-paths') describe('hooks-claude-code coverage — SessionStart timing is best-effort (no-wait)', () => {
  698. it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => {
  699. // Session-start injection is detached, so an immediate prompt need not observe it. Assert only
  700. // the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race.
  701. const d = dir()
  702. const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n')
  703. const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
  704. const adapter = new MockAdapter([textResponse('ok')])
  705. const ctx = await harness(path, adapter)
  706. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  707. // Send immediately — do NOT wait for the session-start inject.
  708. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  709. await waitForIdle(ctx, agent)
  710. expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
  711. })
  712. })
  713. }