bridge.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { mkdtempSync, rmSync, writeFileSync, chmodSync } 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 LlmService from '@deepseek-ai/dsh-llm'
  8. import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
  9. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  11. import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
  12. import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
  13. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-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: scripted mock MODEL + REAL loop + REAL bash +
  18. * REAL `dsh-hooks-codex` running REAL shell scripts from a temp `hooks.json`.
  19. * Codex dialect specifics exercised here: regex matcher (substring), block-only
  20. * decisions, the five-event subset.
  21. */
  22. const dirs: string[] = []
  23. afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
  24. function configDir(): string {
  25. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-'))
  26. dirs.push(dir)
  27. return dir
  28. }
  29. function script(dir: string, name: string, body: string): string {
  30. const path = join(dir, name)
  31. writeFileSync(path, body)
  32. chmodSync(path, 0o755)
  33. return path
  34. }
  35. function writeHooks(dir: string, hooks: unknown): void {
  36. writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks }))
  37. }
  38. async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
  39. const ctx = new Context()
  40. await ctx.plugin(LlmService)
  41. await ctx.plugin(SessionStore)
  42. await ctx.plugin(SystemPrompt)
  43. await ctx.plugin(ToolRegistry)
  44. await ctx.plugin(AgentRegistry)
  45. await ctx.plugin(AgentLoop, { agents: [] })
  46. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  47. await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
  48. ctx.llm.registerAdapter(['mock'], adapter)
  49. return ctx
  50. }
  51. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  52. return new Promise((resolve) => {
  53. const dispose = ctx.on('agent/status', (subject, status) => {
  54. if (subject === agent && status === 'idle') { dispose(); resolve() }
  55. })
  56. })
  57. }
  58. function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
  59. describe('hooks-codex bridge', () => {
  60. it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => {
  61. const dir = configDir()
  62. const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\necho "codex blocked it" >&2\nexit 2\n')
  63. // Codex regex matcher: "Bash" is /Bash/ — matches the tool name "Bash".
  64. writeHooks(dir, { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: deny }] }] })
  65. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
  66. const ctx = await harness(dir, adapter)
  67. let ran = false
  68. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
  69. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  70. agent.send([{ type: 'text', text: 'run ls' }])
  71. await waitForIdle(ctx, agent)
  72. expect(ran).toBe(false)
  73. const result = events(agent).find(e => e.type === 'tool/result')
  74. expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
  75. expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('codex blocked it'))).toBe(true)
  76. // recorded under the codex dialect
  77. expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.dialect === 'codex' && e.data.point === 'PreToolUse')).toBe(true)
  78. })
  79. it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => {
  80. const dir = configDir()
  81. // Block exactly ONCE (a marker file), then allow — without a one-shot guard a
  82. // hook that always exits 2 would force-continue forever (the deferred
  83. // stop_hook_active loop-guard is the real fix; here we self-limit so the test
  84. // exercises the continue path without looping).
  85. const marker = join(dir, 'fired')
  86. 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`)
  87. writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] })
  88. // Step 1 has no tool calls → would stop; the Stop hook forces step 2.
  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(AgentId('a1'), { model: 'mock' })
  92. agent.send([{ type: 'text', text: 'go' }])
  93. await waitForIdle(ctx, agent)
  94. // The Stop hook's reason became next-step steering → a second model request ran.
  95. expect(adapter.requests).toHaveLength(2)
  96. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal')
  97. })
  98. it('only the five Codex events are honored — a SubagentStop entry is ignored', async () => {
  99. const dir = configDir()
  100. const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n')
  101. // SubagentStop is NOT a Codex event; it must be dropped (no crash, no effect).
  102. writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
  103. const adapter = new MockAdapter([textResponse('fine')])
  104. const ctx = await harness(dir, adapter)
  105. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  106. agent.send([{ type: 'text', text: 'go' }])
  107. await waitForIdle(ctx, agent)
  108. // Ran normally; the unknown event was dropped at parse.
  109. expect(adapter.requests).toHaveLength(1)
  110. })
  111. it('a missing config registers no hooks and does not crash', async () => {
  112. const dir = configDir() // no hooks.json written
  113. const adapter = new MockAdapter([textResponse('ok')])
  114. const ctx = await harness(dir, adapter)
  115. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  116. agent.send([{ type: 'text', text: 'go' }])
  117. await waitForIdle(ctx, agent)
  118. expect(adapter.requests).toHaveLength(1)
  119. })
  120. it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
  121. const dir = configDir()
  122. // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose, it
  123. // would veto the prompt (0 model requests) and log a hook/invoked. After a
  124. // clean dispose the turn must proceed untouched — this fails loudly on a leak
  125. // (a no-op `true` hook would pass even with a leaked listener).
  126. const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n')
  127. writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] })
  128. const adapter = new MockAdapter([textResponse('ok')])
  129. const ctx = new Context()
  130. await ctx.plugin(LlmService)
  131. await ctx.plugin(SessionStore)
  132. await ctx.plugin(SystemPrompt)
  133. await ctx.plugin(ToolRegistry)
  134. await ctx.plugin(AgentRegistry)
  135. await ctx.plugin(AgentLoop, { agents: [] })
  136. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  137. const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
  138. await fiber.dispose()
  139. ctx.llm.registerAdapter(['mock'], adapter)
  140. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  141. agent.send([{ type: 'text', text: 'go' }])
  142. await waitForIdle(ctx, agent)
  143. expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
  144. expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran
  145. })
  146. it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
  147. expect('default' in HooksCodex).toBe(false)
  148. expect(HooksCodex.name).toBe('hooks-codex')
  149. expect(HooksCodex.inject).toEqual(['bash'])
  150. const loader = Object.create(Loader.prototype) as Loader
  151. const unwrapped = loader.unwrapExports(HooksCodex) as Record<string, unknown>
  152. expect(unwrapped).toBe(HooksCodex)
  153. expect(unwrapped.name).toBe('hooks-codex')
  154. expect(unwrapped.inject).toEqual(['bash'])
  155. expect(typeof unwrapped.apply).toBe('function')
  156. })
  157. })