bridge.spec.ts 11 KB

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