bridge.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { Context, type Fiber } from '@deepseek-ai/cordis'
  7. import Loader from '@deepseek-ai/cordis-plugin-loader'
  8. import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  9. import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  12. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  13. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  14. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  15. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  16. import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
  17. import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code'
  18. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  19. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  20. /**
  21. * Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL
  22. * bash executor, and the REAL `dsh-hooks-claude-code` bridge runs REAL shell hook
  23. * scripts written to a temp dir — only the model is mocked (the "prefer the real
  24. * implementation" rule). Each test writes a `hooks.json` + executable scripts,
  25. * loads the bridge pointed at them, and asserts the hook's effect on the loop.
  26. */
  27. const dirs: string[] = []
  28. afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
  29. function subagentCarrier(ctx: Context) {
  30. return scopeTarget(ctx as unknown as SubagentRuntime, undefined)
  31. }
  32. /** Write a hooks.json + named executable scripts into a fresh temp dir. */
  33. function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): string {
  34. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  35. dirs.push(dir)
  36. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks }))
  37. for (const [name, body] of Object.entries(scripts)) {
  38. const path = join(dir, name)
  39. writeFileSync(path, body)
  40. chmodSync(path, 0o755)
  41. }
  42. return dir
  43. }
  44. async function harness(configDir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
  45. return (await harnessWithFiber(configDir, adapter, beforeHooks)).ctx
  46. }
  47. /** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */
  48. async function harnessWithFiber(
  49. configDir: string,
  50. adapter: MockAdapter,
  51. beforeHooks?: (ctx: Context) => void,
  52. ): Promise<{ ctx: Context; hooks: Fiber }> {
  53. const ctx = new Context()
  54. await mountAgentLoopTestDependencies(ctx)
  55. await ctx.plugin(SessionProjectionRegistry)
  56. await ctx.plugin(AgentLoop, { agents: [] })
  57. await ctx.plugin(LocalSubprocessRuntime)
  58. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  59. beforeHooks?.(ctx)
  60. const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
  61. ctx.llm.registerAdapter(['mock'], adapter)
  62. return { ctx, hooks }
  63. }
  64. function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
  65. return agent.whenIdle()
  66. }
  67. function events(agent: Agent): readonly SessionEvent[] {
  68. return agent.session.snapshotEvents()
  69. }
  70. /**
  71. * Poll `predicate` until it returns true or the deadline passes. Detached
  72. * emit-listener hooks (session-start, subagent) fire on a `.then` the test can't
  73. * await directly; polling for the observable EFFECT is robust under load, where a
  74. * single fixed sleep flakes ("async state is not synchronous state").
  75. */
  76. async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
  77. const deadline = Date.now() + timeout
  78. while (!predicate()) {
  79. if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
  80. await new Promise(r => setTimeout(r, interval))
  81. }
  82. }
  83. describe('hooks-claude-code bridge — UserPromptSubmit', () => {
  84. it('a UserPromptSubmit hook that exits 2 closes a blocked turn without a step', async () => {
  85. // UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks
  86. // with the reason on stderr.
  87. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  88. dirs.push(dir)
  89. const block = join(dir, 'block.sh')
  90. writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n')
  91. chmodSync(block, 0o755)
  92. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: block }] }] } }))
  93. const adapter = new MockAdapter([textResponse('should not run')])
  94. const ctx = await harness(dir, adapter)
  95. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  96. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } }))
  97. await waitForIdle(ctx, agent)
  98. // The prompt was blocked inside its turn before any model step.
  99. expect(adapter.requests).toHaveLength(0)
  100. expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
  101. || e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
  102. .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
  103. })
  104. it('a UserPromptSubmit hook printing additionalContext injects it for the model', async () => {
  105. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  106. dirs.push(dir)
  107. const ctxScript = join(dir, 'ctx.sh')
  108. writeFileSync(ctxScript, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"remember: be brief"}}\'\n')
  109. chmodSync(ctxScript, 0o755)
  110. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: ctxScript }] }] } }))
  111. const adapter = new MockAdapter([textResponse('ok')])
  112. const ctx = await harness(dir, adapter)
  113. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  114. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  115. await waitForIdle(ctx, agent)
  116. // The injected context reached the model and is recorded with the plugin source.
  117. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('remember: be brief')
  118. const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind !== 'user')
  119. expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude-code' })
  120. })
  121. })
  122. describe('hooks-claude-code bridge — PreToolUse', () => {
  123. it('a matching PreToolUse hook that exits 2 denies the tool (isError result), tool never runs', async () => {
  124. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  125. dirs.push(dir)
  126. const deny = join(dir, 'deny.sh')
  127. writeFileSync(deny, '#!/usr/bin/env bash\necho "danger tool blocked" >&2\nexit 2\n')
  128. chmodSync(deny, 0o755)
  129. // Matcher "danger" (literal) selects only the danger tool.
  130. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'danger', hooks: [{ type: 'command', command: deny }] }] } }))
  131. const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('done')])
  132. const ctx = await harness(dir, adapter)
  133. let ran = false
  134. ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
  135. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  136. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'use danger' }], source: { kind: 'user' } }))
  137. await waitForIdle(ctx, agent)
  138. expect(ran).toBe(false)
  139. const result = events(agent).find(e => e.type === 'tool/result')
  140. expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
  141. expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('danger tool blocked'))).toBe(true)
  142. })
  143. it('a PreToolUse hook whose matcher does NOT match leaves the tool alone', async () => {
  144. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  145. dirs.push(dir)
  146. const deny = join(dir, 'deny.sh')
  147. writeFileSync(deny, '#!/usr/bin/env bash\nexit 2\n')
  148. chmodSync(deny, 0o755)
  149. // Matcher only targets "danger" — the "safe" tool is untouched.
  150. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'danger', hooks: [{ type: 'command', command: deny }] }] } }))
  151. const adapter = new MockAdapter([toolCallResponse('c1', 'safe', {}), textResponse('done')])
  152. const ctx = await harness(dir, adapter)
  153. let ran = false
  154. ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
  155. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  156. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'use safe' }], source: { kind: 'user' } }))
  157. await waitForIdle(ctx, agent)
  158. expect(ran).toBe(true)
  159. const result = events(agent).find(e => e.type === 'tool/result')
  160. expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(false)
  161. })
  162. })
  163. describe('hooks-claude-code bridge — PostToolUse', () => {
  164. it('a PostToolUse hook that blocks (exit 2) turns the result into an isError with feedback', async () => {
  165. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  166. dirs.push(dir)
  167. const block = join(dir, 'block.sh')
  168. writeFileSync(block, '#!/usr/bin/env bash\necho "output rejected, retry" >&2\nexit 2\n')
  169. chmodSync(block, 0o755)
  170. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: block }] }] } }))
  171. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  172. const ctx = await harness(dir, adapter)
  173. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
  174. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  175. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  176. await waitForIdle(ctx, agent)
  177. const result = events(agent).find(e => e.type === 'tool/result')
  178. // PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback.
  179. expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
  180. expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('output rejected, retry'))).toBe(true)
  181. })
  182. it('a PostToolUse hook printing additionalContext attaches it after the tool result', async () => {
  183. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  184. dirs.push(dir)
  185. const s = join(dir, 'ctx.sh')
  186. writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"note: tool was slow"}}\'\n')
  187. chmodSync(s, 0o755)
  188. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] } }))
  189. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  190. const ctx = await harness(dir, adapter)
  191. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  192. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  193. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  194. await waitForIdle(ctx, agent)
  195. const log = events(agent)
  196. const resultIdx = log.findIndex(e => e.type === 'tool/result')
  197. const ctxIdx = log.findIndex(e => e.type === 'user/message' && e.data.source.kind !== 'user')
  198. expect(ctxIdx).toBeGreaterThan(resultIdx) // context appended AFTER the tool result
  199. const ctxMsg = log[ctxIdx]
  200. expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true)
  201. })
  202. it('a PreToolUse permissionDecision:ask fails closed without an approval service', async () => {
  203. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  204. dirs.push(dir)
  205. const s = join(dir, 'ask.sh')
  206. writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"needs approval"}}\'\n')
  207. chmodSync(s, 0o755)
  208. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] } }))
  209. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  210. const ctx = await harness(dir, adapter)
  211. let ran = false
  212. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
  213. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  214. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  215. await waitForIdle(ctx, agent)
  216. // No approval service is mounted, so `ask` fails closed: the tool does not run and the result is isError.
  217. expect(ran).toBe(false)
  218. const result = events(agent).find(e => e.type === 'tool/result')
  219. expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
  220. expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('needs approval'))).toBe(true)
  221. })
  222. })
  223. describe('hooks-claude-code bridge — SessionStart', () => {
  224. it('a SessionStart hook injects additionalContext the first request sees', async () => {
  225. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  226. dirs.push(dir)
  227. const s = join(dir, 'start.sh')
  228. writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"project uses tabs"}}\'\n')
  229. chmodSync(s, 0o755)
  230. // matcher 'startup' selects the startup source.
  231. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: s }] }] } }))
  232. const adapter = new MockAdapter([textResponse('ok')])
  233. const ctx = await harness(dir, adapter)
  234. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  235. // session-start fires async (detached .then → agent.inject); injection now
  236. // enters the next-step inbox directly and becomes a user/message only after
  237. // step entry, so synchronize on the pending inbox item before sending.
  238. await waitFor(() => agent.inbox.nextStep.some(message =>
  239. message.content.some(block => block.type === 'text' && block.text.includes('project uses tabs'))))
  240. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  241. await waitForIdle(ctx, agent)
  242. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs')
  243. })
  244. })
  245. describe('hooks-claude-code bridge — SubagentStart / SubagentStop (observe)', () => {
  246. it('runs SubagentStart and SubagentStop hooks when the subagent lifecycle events fire', async () => {
  247. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  248. dirs.push(dir)
  249. // Each hook touches a marker file so we can assert it ran (these events are
  250. // observe-only — there is no decision to assert, only the side effect).
  251. const startMarker = join(dir, 'start-ran')
  252. const stopMarker = join(dir, 'stop-ran')
  253. const startHook = join(dir, 'start.sh')
  254. const stopHook = join(dir, 'stop.sh')
  255. writeFileSync(startHook, `#!/usr/bin/env bash\ntouch "${startMarker}"\n`)
  256. writeFileSync(stopHook, `#!/usr/bin/env bash\ntouch "${stopMarker}"\n`)
  257. chmodSync(startHook, 0o755)
  258. chmodSync(stopHook, 0o755)
  259. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
  260. SubagentStart: [{ hooks: [{ type: 'command', command: startHook }] }],
  261. SubagentStop: [{ hooks: [{ type: 'command', command: stopHook }] }],
  262. } }))
  263. const adapter = new MockAdapter([])
  264. const { ctx, hooks } = await harnessWithFiber(dir, adapter)
  265. // Drive the observe-only lifecycle events directly. SubagentStart must run
  266. // the hook even when no registered child resolves from the notification.
  267. ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false })
  268. ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
  269. // Both hooks run async (detached .then); poll for their marker files rather
  270. // than a fixed sleep that flakes under load.
  271. await waitFor(() => existsSync(startMarker) && existsSync(stopMarker))
  272. expect(existsSync(startMarker)).toBe(true)
  273. expect(existsSync(stopMarker)).toBe(true)
  274. // The markers prove the hook PROCESSES ran, not that the detached `.then`
  275. // continuations did (`touch` lands before the process exits). Dispose drains
  276. // them, so the no-context arm of the SubagentStart continuation — covered
  277. // only here — executes before this file's coverage snapshot instead of
  278. // racing it (the arm went uncovered on a loaded CI runner and failed the
  279. // per-file 100% branch gate).
  280. await hooks.dispose()
  281. })
  282. it('disposing the bridge aborts a still-running hook and drains to quiescence', async () => {
  283. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
  284. dirs.push(dir)
  285. const pidFile = join(dir, 'pid')
  286. const marker = join(dir, 'started')
  287. const slowHook = join(dir, 'slow.sh')
  288. // Record the hook shell's PID and touch the marker FIRST so the test can
  289. // tell "the hook is genuinely mid-run", then sleep far past the suite
  290. // timeout. Dispose must KILL the process (the tracker's abort signal), not
  291. // await its exit or its 10-minute default hook timeout.
  292. writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
  293. chmodSync(slowHook, 0o755)
  294. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
  295. SubagentStart: [{ hooks: [{ type: 'command', command: slowHook }] }],
  296. } }))
  297. const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([]))
  298. const warn = vi.fn()
  299. ctx.logger.warn = warn as never
  300. ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false })
  301. await waitFor(() => existsSync(marker))
  302. const pid = Number(readFileSync(pidFile, 'utf8').trim())
  303. await hooks.dispose()
  304. // Quiescence, not just promptness: the drain resolves only after the run
  305. // settled, and the run settles only after the killed process was reaped —
  306. // so by the time dispose returns, the PID must be GONE (kill(pid, 0)
  307. // throws ESRCH). An untracked fire-and-forget regression would leave the
  308. // process alive (or unreaped) and fail this deterministically.
  309. expect(() => process.kill(pid, 0)).toThrow()
  310. // The aborted run resolves as a non-blocking error (runHook never rejects),
  311. // so the drained continuation must NOT have logged a failure.
  312. expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
  313. })
  314. })
  315. describe('hooks-claude-code bridge — load resilience', () => {
  316. it('a missing config file registers no hooks and does not crash the loop', async () => {
  317. const adapter = new MockAdapter([textResponse('fine')])
  318. const ctx = new Context()
  319. await mountAgentLoopTestDependencies(ctx)
  320. await ctx.plugin(SessionProjectionRegistry)
  321. await ctx.plugin(AgentLoop, { agents: [] })
  322. await ctx.plugin(LocalSubprocessRuntime)
  323. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  324. await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
  325. ctx.llm.registerAdapter(['mock'], adapter)
  326. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  327. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  328. await waitForIdle(ctx, agent)
  329. // The turn ran normally — no hooks, no crash.
  330. expect(adapter.requests).toHaveLength(1)
  331. })
  332. it('an invalid regex matcher is reported and registers no hooks', async () => {
  333. const dir = writeConfig({
  334. UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
  335. PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 2' }] }],
  336. })
  337. const adapter = new MockAdapter([textResponse('fine')])
  338. const warn = vi.fn()
  339. const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
  340. const agent = await ctx.agentLoop.create(SessionId('invalid-claude-matcher'), { provider: 'mock', model: 'mock' })
  341. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  342. await waitForIdle(ctx, agent)
  343. expect(adapter.requests).toHaveLength(1)
  344. expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
  345. expect(warn).toHaveBeenCalledWith(expect.stringContaining(
  346. 'invalid claude-code regex matcher "(" on event "PreToolUse"',
  347. ))
  348. })
  349. it('an invalid matcher on an unsupported event does not disable supported hooks', async () => {
  350. const dir = writeConfig({
  351. Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 0' }] }],
  352. UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
  353. })
  354. const adapter = new MockAdapter([textResponse('should not run')])
  355. const warn = vi.fn()
  356. const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
  357. const agent = await ctx.agentLoop.create(SessionId('unsupported-claude-matcher'), { provider: 'mock', model: 'mock' })
  358. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  359. await waitForIdle(ctx, agent)
  360. expect(adapter.requests).toHaveLength(0)
  361. expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'hook/invoked'
  362. || event.type === 'hook/result' || event.type === 'turn/end').map(event => event.type))
  363. .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
  364. expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude-code regex matcher'))
  365. })
  366. it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
  367. // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it
  368. // would veto the prompt (0 model requests) and log a hook/invoked. Build the
  369. // ctx WITHOUT the harness's own bridge mount so this is the ONLY mount, then
  370. // dispose it — a leaked listener fails the test (a no-op `true` hook would
  371. // pass even leaked, so it proved nothing).
  372. const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] })
  373. const adapter = new MockAdapter([textResponse('ok')])
  374. const ctx = new Context()
  375. await mountAgentLoopTestDependencies(ctx)
  376. await ctx.plugin(SessionProjectionRegistry)
  377. await ctx.plugin(AgentLoop, { agents: [] })
  378. await ctx.plugin(LocalSubprocessRuntime)
  379. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  380. const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
  381. await fiber.dispose()
  382. ctx.llm.registerAdapter(['mock'], adapter)
  383. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  384. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  385. await waitForIdle(ctx, agent)
  386. expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
  387. expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran
  388. })
  389. it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
  390. // Postmortem 0001 guard: this plugin HAS `inject = ['bash']`, so a stray
  391. // `export default apply` would collapse the module via `unwrapExports`
  392. // (`exports.default ?? exports`), DROP `inject`, and crash at load with
  393. // "cannot get property … without inject". Guard the shape directly.
  394. expect('default' in HooksClaude).toBe(false)
  395. expect(HooksClaude.name).toBe('hooks-claude-code')
  396. expect(HooksClaude.inject).toEqual(['shell', 'sessionProjections'])
  397. const loader = Object.create(Loader.prototype) as Loader
  398. const unwrapped = loader.unwrapExports(HooksClaude) as Record<string, unknown>
  399. expect(unwrapped).toBe(HooksClaude)
  400. expect(unwrapped.name).toBe('hooks-claude-code')
  401. expect(unwrapped.inject).toEqual(['shell', 'sessionProjections'])
  402. expect(typeof unwrapped.apply).toBe('function')
  403. })
  404. })