coverage-cases.ts 47 KB

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