1
0

bridge.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  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 } 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 * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
  16. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  17. /**
  18. * Full-loop Codex bridge tests with a mock model, the real loop and bash
  19. * executor, and shell hooks from a temporary config. Covers regex matching,
  20. * block-only decisions, and the five-event subset.
  21. */
  22. const dirs: string[] = []
  23. const contexts: Context[] = []
  24. afterEach(async () => {
  25. for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
  26. for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
  27. })
  28. function configDir(): string {
  29. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-'))
  30. dirs.push(dir)
  31. return dir
  32. }
  33. function script(dir: string, name: string, body: string): string {
  34. const path = join(dir, name)
  35. writeFileSync(path, body)
  36. chmodSync(path, 0o755)
  37. return path
  38. }
  39. function writeHooks(dir: string, hooks: unknown): void {
  40. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks }))
  41. }
  42. async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
  43. const ctx = new Context()
  44. contexts.push(ctx)
  45. await mountAgentLoopTestDependencies(ctx)
  46. await ctx.plugin(AgentLoop, { agents: [] })
  47. await ctx.plugin(LocalSubprocessRuntime)
  48. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  49. beforeHooks?.(ctx)
  50. await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
  51. ctx.llm.registerAdapter(['mock'], adapter)
  52. return ctx
  53. }
  54. function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
  55. return agent.whenIdle()
  56. }
  57. function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() }
  58. /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */
  59. async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
  60. const deadline = Date.now() + timeout
  61. while (!predicate()) {
  62. if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
  63. await new Promise(r => setTimeout(r, interval))
  64. }
  65. }
  66. describe('hooks-codex bridge', () => {
  67. it('awaits a registry-announced resume hook without a creation signal', async () => {
  68. const dir = configDir()
  69. const capture = script(dir, 'resume.sh', '#!/usr/bin/env bash\necho "resumed context"\n')
  70. writeHooks(dir, { SessionStart: [{ matcher: 'resume', hooks: [{ type: 'command', command: capture }] }] })
  71. const ctx = await harness(dir, new MockAdapter([]))
  72. const session = ctx.sessions.create(SessionId('registry-resume'))
  73. const inject = vi.fn()
  74. const agent = { id: session.id, session, ctx, inject } as unknown as Agent
  75. ctx.effect(() => ctx.agents.enter(agent, undefined))
  76. await ctx.agents.announce(agent, 'resume')
  77. expect(inject).toHaveBeenCalledWith(expect.objectContaining({
  78. content: [{ type: 'text', text: 'resumed context' }],
  79. }))
  80. })
  81. it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => {
  82. const dir = configDir()
  83. const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\necho "codex blocked it" >&2\nexit 2\n')
  84. // Codex regex matcher: "Bash" is /Bash/ — matches the tool name "Bash".
  85. writeHooks(dir, { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: deny }] }] })
  86. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
  87. const ctx = await harness(dir, adapter)
  88. let ran = false
  89. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
  90. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  91. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run ls' }], source: { kind: 'user' } }))
  92. await waitForIdle(ctx, agent)
  93. expect(ran).toBe(false)
  94. const result = events(agent).find(e => e.type === 'tool/result')
  95. expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
  96. expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('codex blocked it'))).toBe(true)
  97. expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.dialect === 'codex' && e.data.point === 'PreToolUse')).toBe(true)
  98. })
  99. it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => {
  100. const dir = configDir()
  101. // Stop ignores its malformed matcher field. Block once with a marker;
  102. // until the loop guard lands, an always-blocking hook would never finish.
  103. const marker = join(dir, 'fired')
  104. const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`)
  105. writeHooks(dir, { Stop: [{ matcher: '[', hooks: [{ type: 'command', command: cont }] }] })
  106. const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
  107. const ctx = await harness(dir, adapter)
  108. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  109. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  110. await waitForIdle(ctx, agent)
  111. expect(adapter.requests).toHaveLength(2)
  112. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal')
  113. }, 15_000) // Two real hook subprocesses and agent steps need startup and teardown headroom under load.
  114. it('turn cancellation aborts and reaps a running UserPromptSubmit hook before idle', async () => {
  115. const dir = configDir()
  116. const pidFile = join(dir, 'pid')
  117. const marker = join(dir, 'started')
  118. const slow = script(dir, 'slow-prompt.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
  119. writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: slow }] }] })
  120. const adapter = new MockAdapter([textResponse('must not run')])
  121. const ctx = await harness(dir, adapter)
  122. const agent = await ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' })
  123. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'cancel the hook' }], source: { kind: 'user' } }))
  124. await waitFor(() => existsSync(marker))
  125. const pid = Number(readFileSync(pidFile, 'utf8').trim())
  126. const idle = agent.whenIdle()
  127. agent.cancel({ kind: 'user' })
  128. await idle
  129. expect(() => process.kill(pid, 0)).toThrow()
  130. expect(adapter.requests).toHaveLength(0)
  131. expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'hook/invoked'
  132. || event.type === 'hook/result' || event.type === 'turn/end').map(event => event.type))
  133. .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
  134. })
  135. it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => {
  136. const dir = configDir()
  137. const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n')
  138. writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
  139. const adapter = new MockAdapter([textResponse('fine')])
  140. const ctx = await harness(dir, adapter)
  141. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  142. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  143. await waitForIdle(ctx, agent)
  144. expect(adapter.requests).toHaveLength(1)
  145. })
  146. it('a missing config registers no hooks and does not crash', async () => {
  147. const dir = configDir() // no hooks.json written
  148. const adapter = new MockAdapter([textResponse('ok')])
  149. const ctx = await harness(dir, adapter)
  150. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  151. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  152. await waitForIdle(ctx, agent)
  153. expect(adapter.requests).toHaveLength(1)
  154. })
  155. it('an invalid regex matcher is reported and registers no hooks', async () => {
  156. const dir = configDir()
  157. writeHooks(dir, {
  158. UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
  159. PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }],
  160. })
  161. const adapter = new MockAdapter([textResponse('ok')])
  162. const warn = vi.fn()
  163. const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
  164. const agent = await ctx.agentLoop.create(SessionId('invalid-codex-matcher'), { provider: 'mock', model: 'mock' })
  165. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  166. await waitForIdle(ctx, agent)
  167. expect(adapter.requests).toHaveLength(1)
  168. expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
  169. expect(warn).toHaveBeenCalledWith(expect.stringContaining(
  170. 'invalid codex regex matcher "[" on event "PreToolUse"',
  171. ))
  172. })
  173. it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
  174. const dir = configDir()
  175. // A leaked listener would let this blocking hook veto the prompt and log an invocation; a
  176. // no-op hook would pass even when leaked.
  177. const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n')
  178. writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] })
  179. const adapter = new MockAdapter([textResponse('ok')])
  180. const ctx = new Context()
  181. await mountAgentLoopTestDependencies(ctx)
  182. await ctx.plugin(AgentLoop, { agents: [] })
  183. await ctx.plugin(LocalSubprocessRuntime)
  184. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  185. const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
  186. await fiber.dispose()
  187. ctx.llm.registerAdapter(['mock'], adapter)
  188. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  189. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  190. await waitForIdle(ctx, agent)
  191. expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
  192. expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran
  193. })
  194. it('disposing the bridge aborts a still-running SessionStart hook and drains to quiescence', async () => {
  195. const dir = configDir()
  196. const pidFile = join(dir, 'pid')
  197. const marker = join(dir, 'started')
  198. // Record the PID and marker before sleeping past the suite timeout. Disposal must abort the
  199. // tracked process through `runPoint`, not await its natural exit.
  200. const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
  201. writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] })
  202. const ctx = new Context()
  203. await mountAgentLoopTestDependencies(ctx)
  204. await ctx.plugin(AgentLoop, { agents: [] })
  205. await ctx.plugin(LocalSubprocessRuntime)
  206. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  207. const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
  208. ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
  209. const warn = vi.fn()
  210. ctx.logger.warn = warn as never
  211. const creating = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  212. try {
  213. await waitFor(() => existsSync(marker))
  214. const pid = Number(readFileSync(pidFile, 'utf8').trim())
  215. await fiber.dispose()
  216. await creating
  217. // The aborted hook must be reaped before bridge disposal resolves.
  218. expect(() => process.kill(pid, 0)).toThrow()
  219. expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
  220. } finally {
  221. await fiber.dispose()
  222. await creating
  223. await ctx.fiber.dispose()
  224. }
  225. })
  226. it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
  227. expect('default' in HooksCodex).toBe(false)
  228. expect(HooksCodex.name).toBe('hooks-codex')
  229. expect(HooksCodex.inject).toEqual(['shell', 'sessionProjections'])
  230. const loader = Object.create(Loader.prototype) as Loader
  231. const unwrapped = loader.unwrapExports(HooksCodex) as Record<string, unknown>
  232. expect(unwrapped).toBe(HooksCodex)
  233. expect(unwrapped.name).toBe('hooks-codex')
  234. expect(unwrapped.inject).toEqual(['shell', 'sessionProjections'])
  235. expect(typeof unwrapped.apply).toBe('function')
  236. })
  237. })