coverage.spec.ts 41 KB

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