bridge.spec.ts 25 KB

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