coverage.spec.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { Context } from 'cordis'
  6. import LlmService from '@deepseek-ai/dsh-llm'
  7. import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
  8. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  9. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  10. import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
  11. import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
  12. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  13. import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
  14. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  15. const dirs: string[] = []
  16. afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
  17. function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d }
  18. function sh(d: string, name: string, body: string): string {
  19. const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p
  20. }
  21. function hooks(d: string, h: unknown): string {
  22. writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
  23. }
  24. async function harness(configPath: string, adapter: MockAdapter): Promise<Context> {
  25. const ctx = new Context()
  26. await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt)
  27. await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
  28. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  29. await ctx.plugin(HooksCodex, { configPath, model: 'm' })
  30. ctx.llm.registerAdapter(['mock'], adapter)
  31. return ctx
  32. }
  33. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  34. return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
  35. }
  36. function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
  37. /** Poll until `predicate` holds or the deadline passes — robust to detached
  38. * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
  39. async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
  40. const deadline = Date.now() + timeout
  41. while (!predicate()) {
  42. if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
  43. await new Promise(r => setTimeout(r, interval))
  44. }
  45. }
  46. describe('hooks-codex coverage — decision mapping paths', () => {
  47. it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => {
  48. const d = dir()
  49. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  50. const adapter = new MockAdapter([textResponse('no')])
  51. const ctx = await harness(join(d, 'hooks.json'), adapter)
  52. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  53. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  54. expect(adapter.requests).toHaveLength(0)
  55. const te = events(agent).findLast(e => e.type === 'turn/end')
  56. expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected')
  57. })
  58. it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => {
  59. const d = dir()
  60. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] })
  61. const adapter = new MockAdapter([textResponse('ok')])
  62. const ctx = await harness(join(d, 'hooks.json'), adapter)
  63. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  64. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  65. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
  66. })
  67. it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
  68. // Context alone is not a veto: a downstream agent/prompt-submit listener (a
  69. // policy plugin registered after the bridge) must still get to block. The
  70. // bridge delegates via next() and folds its context onto the decision.
  71. const d = dir()
  72. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
  73. const adapter = new MockAdapter([textResponse('should not run')])
  74. const ctx = await harness(join(d, 'hooks.json'), adapter)
  75. ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
  76. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  77. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  78. expect(adapter.requests).toHaveLength(0)
  79. expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
  80. const te = events(agent).findLast(e => e.type === 'turn/end')
  81. expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
  82. })
  83. it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => {
  84. const d = dir()
  85. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] })
  86. const adapter = new MockAdapter([textResponse('ok')])
  87. const ctx = await harness(join(d, 'hooks.json'), adapter)
  88. ctx.on('agent/prompt-submit', async () => ({
  89. kind: 'allow' as const,
  90. content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
  91. additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
  92. }))
  93. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  94. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  95. const req = JSON.stringify(adapter.requests[0]!.messages)
  96. expect(req).toContain('from-bridge')
  97. expect(req).toContain('from-downstream')
  98. expect(req).toContain('rewritten-prompt')
  99. })
  100. it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => {
  101. const d = dir()
  102. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
  103. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  104. const ctx = await harness(join(d, 'hooks.json'), adapter)
  105. ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  106. ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
  107. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  108. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  109. const result = events(agent).find(e => e.type === 'tool/result')
  110. expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
  111. expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
  112. })
  113. it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
  114. const d = dir()
  115. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
  116. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  117. const ctx = await harness(join(d, 'hooks.json'), adapter)
  118. ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  119. ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
  120. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  121. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  122. const result = events(agent).find(e => e.type === 'tool/result')
  123. expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
  124. expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
  125. expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
  126. })
  127. it('SessionStart additionalContext is injected for the first request', async () => {
  128. const d = dir()
  129. hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] })
  130. const adapter = new MockAdapter([textResponse('ok')])
  131. const ctx = await harness(join(d, 'hooks.json'), adapter)
  132. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  133. await waitFor(() => events(agent).some(e => e.type === 'context/message'
  134. && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
  135. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  136. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
  137. })
  138. it('PostToolUse block (exit 2) → isError feedback; default reason', async () => {
  139. const d = dir()
  140. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  141. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
  142. const ctx = await harness(join(d, 'hooks.json'), adapter)
  143. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  144. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  145. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  146. const r = events(agent).find(e => e.type === 'tool/result')
  147. expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
  148. expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
  149. })
  150. it('PostToolUse additionalContext (clean exit) is attached after the result', async () => {
  151. const d = dir()
  152. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] })
  153. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
  154. const ctx = await harness(join(d, 'hooks.json'), adapter)
  155. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  156. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  157. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  158. expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
  159. })
  160. it('PreToolUse for a tool call WITHOUT a command arg passes an empty command (commandOf non-object/missing arm)', async () => {
  161. const d = dir()
  162. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pre.sh', '#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n') }] }] })
  163. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg
  164. const ctx = await harness(join(d, 'hooks.json'), adapter)
  165. let ran = false
  166. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  167. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  168. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  169. expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
  170. })
  171. it('a clean exit-0 hook records exitCode 0 and omits stderrSummary', async () => {
  172. const d = dir()
  173. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
  174. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  175. const ctx = await harness(join(d, 'hooks.json'), adapter)
  176. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  177. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  178. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  179. const res = events(agent).find(e => e.type === 'hook/result')
  180. expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
  181. expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
  182. })
  183. it('a long stderr is truncated in the hook/result summary', async () => {
  184. const d = dir()
  185. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
  186. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  187. const ctx = await harness(join(d, 'hooks.json'), adapter)
  188. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  189. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  190. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  191. const res = events(agent).find(e => e.type === 'hook/result')
  192. expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
  193. })
  194. it('warns on a skipped async hook and a direct apply() defaults the timeout', async () => {
  195. const d = dir()
  196. const marker = join(d, 'ran')
  197. hooks(d, { UserPromptSubmit: [{ hooks: [
  198. { type: 'command', command: 'bg.sh', async: true }, // skipped → warn
  199. { type: 'command', command: sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) },
  200. ] }] })
  201. const warn = vi.fn()
  202. const adapter = new MockAdapter([textResponse('ok')])
  203. const ctx = new Context()
  204. await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt)
  205. await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
  206. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  207. ctx.logger.warn = warn as never
  208. // Direct apply (schema bypass) → defaultTimeoutMs ?? 600_000 + model ?? '' fallbacks.
  209. HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
  210. ctx.llm.registerAdapter(['mock'], adapter)
  211. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  212. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  213. expect(existsSync(marker)).toBe(true)
  214. expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook'))
  215. })
  216. it('a no-op clean hook proceeds (contextFrom empty → next)', async () => {
  217. const d = dir()
  218. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
  219. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  220. const ctx = await harness(join(d, 'hooks.json'), adapter)
  221. let ran = false
  222. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  223. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  224. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  225. expect(ran).toBe(true)
  226. })
  227. it('SessionStart with no additionalContext is a no-op (contextFrom empty)', async () => {
  228. const d = dir()
  229. // The hook touches a marker so we can wait for it to ACTUALLY FINISH before
  230. // asserting absence — a completed turn alone would not prove the detached
  231. // session-start hook ran, making the absence check a false pass.
  232. const marker = join(d, 'ss-ran')
  233. hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] })
  234. const adapter = new MockAdapter([textResponse('ok')])
  235. const ctx = await harness(join(d, 'hooks.json'), adapter)
  236. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  237. await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
  238. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  239. expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
  240. })
  241. it('a throwing SessionStart inject is contained (logged)', async () => {
  242. const d = dir()
  243. hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') }] }] })
  244. const adapter = new MockAdapter([textResponse('ok')])
  245. const ctx = await harness(join(d, 'hooks.json'), adapter)
  246. const warn = vi.fn(); ctx.logger.warn = warn as never
  247. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  248. agent.inject = (() => { throw new Error('inject boom') })
  249. await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed')))
  250. expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
  251. })
  252. it('a clean PreToolUse with no decision allows the tool (no deny)', async () => {
  253. const d = dir()
  254. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'ok.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
  255. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  256. const ctx = await harness(join(d, 'hooks.json'), adapter)
  257. let ran = false
  258. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  259. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  260. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  261. expect(ran).toBe(true)
  262. })
  263. it('a non-matching regex matcher skips the hook (matchesMatcher false → continue)', async () => {
  264. const d = dir()
  265. // /^Edit$/ does not match the tool name "Bash" → the group is skipped.
  266. hooks(d, { PreToolUse: [{ matcher: '^Edit$', hooks: [{ type: 'command', command: sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  267. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  268. const ctx = await harness(join(d, 'hooks.json'), adapter)
  269. let ran = false
  270. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  271. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  272. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  273. expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded
  274. expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false)
  275. })
  276. it('a {"continue":false} hook is RECORDED as "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
  277. // Honoring `continue:false` is deferred — the seams have no hard-halt
  278. // primitive. Assert the LOG records the halt request AND that the run is not
  279. // actually halted (the tool still runs, the turn completes).
  280. const d = dir()
  281. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') }] }] })
  282. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  283. const ctx = await harness(join(d, 'hooks.json'), adapter)
  284. let ran = false
  285. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  286. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  287. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  288. const res = events(agent).find(e => e.type === 'hook/result')
  289. expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
  290. expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
  291. })
  292. it('PreToolUse deny with EMPTY stderr uses the default reason (?? right arm)', async () => {
  293. const d = dir()
  294. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  295. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  296. const ctx = await harness(join(d, 'hooks.json'), adapter)
  297. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  298. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  299. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  300. const r = events(agent).find(e => e.type === 'tool/result')
  301. expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
  302. })
  303. it('PostToolUse block AND additionalContext are surfaced together', async () => {
  304. const d = dir()
  305. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] })
  306. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  307. const ctx = await harness(join(d, 'hooks.json'), adapter)
  308. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  309. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  310. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  311. const r = events(agent).find(e => e.type === 'tool/result')
  312. expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
  313. expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
  314. expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true)
  315. })
  316. it('commandOf reads a non-string command arg as an empty command', async () => {
  317. const d = dir()
  318. // The tool-call arguments carry `command` as a NUMBER → commandOf's
  319. // `typeof command === 'string'` false arm → '' (the payload's tool_input.command).
  320. const cap = join(d, 'payload')
  321. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] })
  322. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')])
  323. const ctx = await harness(join(d, 'hooks.json'), adapter)
  324. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  325. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  326. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  327. const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
  328. expect(payload.tool_input.command).toBe('')
  329. })
  330. it('a no-agent direct PreToolUse run uses process.cwd() and turn 0 (no session to record)', async () => {
  331. const d = dir()
  332. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  333. const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
  334. let ran = false
  335. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
  336. const { CallId } = await import('@deepseek-ai/dsh-llm')
  337. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
  338. expect(ran).toBe(false) // denied
  339. expect(result.isError).toBe(true)
  340. })
  341. it('a no-agent direct PostToolUse run attaches context with no session to record', async () => {
  342. const d = dir()
  343. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] })
  344. const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
  345. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  346. const { CallId } = await import('@deepseek-ai/dsh-llm')
  347. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
  348. expect(result.isError).toBeFalsy()
  349. expect(result.additionalContext?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true)
  350. })
  351. it('when the bash executor REJECTS, the hook/result omits exitCode (non-blocking)', async () => {
  352. const d = dir()
  353. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
  354. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  355. const ctx = await harness(join(d, 'hooks.json'), adapter)
  356. ctx.bash.run = (() => Promise.reject(new Error('executor down')))
  357. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  358. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  359. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  360. const res = events(agent).find(e => e.type === 'hook/result')
  361. expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
  362. })
  363. it('a blocking Stop hook with EMPTY stderr still forces continuation (no reason required)', async () => {
  364. // Regression: an exit-2 Stop hook with no stderr yields decision 'deny' +
  365. // reason undefined; the turn must STILL force-continue, not silently stop.
  366. const d = dir()
  367. const marker = join(d, 'fired')
  368. hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] })
  369. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  370. const ctx = await harness(join(d, 'hooks.json'), adapter)
  371. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  372. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  373. expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation
  374. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
  375. })
  376. it('a clean UserPromptSubmit hook that prints PLAIN stdout injects it as context', async () => {
  377. // Codex feeds a SessionStart/UserPromptSubmit hook's PLAIN (non-JSON) stdout
  378. // as additionalContext (unlike CC, which needs a JSON hookSpecificOutput).
  379. const d = dir()
  380. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] })
  381. const adapter = new MockAdapter([textResponse('ok')])
  382. const ctx = await harness(join(d, 'hooks.json'), adapter)
  383. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  384. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  385. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook')
  386. })
  387. it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => {
  388. // The plain-stdout→context fold is gated on exitCode === 0, matching the
  389. // codec's structured-stdout rule. SessionStart is an EMIT (cannot block), so
  390. // an `echo stale; exit 2` here is the exact case the gate guards: without it,
  391. // the non-clean hook's stdout would wrongly inject "stale". A marker lets us
  392. // wait for the detached hook to finish before asserting absence.
  393. const d = dir()
  394. const marker = join(d, 'ran')
  395. hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] })
  396. const adapter = new MockAdapter([textResponse('ok')])
  397. const ctx = await harness(join(d, 'hooks.json'), adapter)
  398. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  399. await waitFor(() => existsSync(marker)) // the exit-2 hook has finished
  400. expect(events(agent).some(e => e.type === 'context/message'
  401. && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false)
  402. })
  403. it('a UserPromptSubmit hook with a non-blocking error exit (1) + stdout does NOT inject it', async () => {
  404. // Exit 1 is a non-blocking error (no decision), so the prompt is NOT blocked
  405. // and the handler falls through to the context path — the gate must still
  406. // suppress the error hook's stdout ("stale" never reaches the model).
  407. const d = dir()
  408. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] })
  409. const adapter = new MockAdapter([textResponse('ok')])
  410. const ctx = await harness(join(d, 'hooks.json'), adapter)
  411. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  412. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  413. expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran
  414. expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale')
  415. })
  416. it('a clean SessionStart hook that prints PLAIN stdout injects it (not JSON)', async () => {
  417. const d = dir()
  418. hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] })
  419. const adapter = new MockAdapter([textResponse('ok')])
  420. const ctx = await harness(join(d, 'hooks.json'), adapter)
  421. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  422. await waitFor(() => events(agent).some(e => e.type === 'context/message'
  423. && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
  424. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  425. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
  426. })
  427. it('a clean hook that prints JSON is NOT injected as prose (plain-stdout gate)', async () => {
  428. // A structured (JSON) stdout must go through the hookSpecificOutput path, not
  429. // be dumped verbatim as context — the `!startsWith('{')` gate guards this.
  430. const d = dir()
  431. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] })
  432. const adapter = new MockAdapter([textResponse('ok')])
  433. const ctx = await harness(join(d, 'hooks.json'), adapter)
  434. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  435. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  436. expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated')
  437. })
  438. it('the PreToolUse payload carries the REAL tool name (matches the matcher subject)', async () => {
  439. // Regression: the payload once hardcoded tool_name "Bash", disagreeing with
  440. // the exec.name matcher subject — a config matcher on the real name would
  441. // then never fire. Capture the payload and assert tool_name === the real name.
  442. const d = dir()
  443. const cap = join(d, 'payload')
  444. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] })
  445. const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
  446. const ctx = await harness(join(d, 'hooks.json'), adapter)
  447. ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  448. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  449. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  450. const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } }
  451. expect(payload.tool_name).toBe('shell')
  452. expect(payload.tool_input.command).toBe('ls')
  453. })
  454. it('a Codex matcher on the REAL tool name fires (matcher subject === payload tool_name)', async () => {
  455. // A regex matcher matching the real tool name must select the hook — proving
  456. // the matcher subject and the payload tool_name agree.
  457. const d = dir()
  458. hooks(d, { PreToolUse: [{ matcher: 'shell', hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  459. const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
  460. const ctx = await harness(join(d, 'hooks.json'), adapter)
  461. let ran = false
  462. ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  463. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  464. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  465. expect(ran).toBe(false) // the matcher fired → the hook denied the tool
  466. expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true)
  467. })
  468. it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => {
  469. const d = dir()
  470. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] })
  471. const adapter = new MockAdapter([textResponse('ok')])
  472. const ctx = await harness(join(d, 'hooks.json'), adapter)
  473. const warn = vi.fn(); ctx.logger.warn = warn as never
  474. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  475. agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
  476. expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
  477. expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
  478. })
  479. it('runs an agent-scoped hook in the session cwd, not the executor default', async () => {
  480. // Same regression as the CC bridge: the Codex bridge must thread the session
  481. // cwd as the hook workdir. Executor default = serverDir; session cwd =
  482. // sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir.
  483. const serverDir = dir()
  484. const sessionDir = dir()
  485. const marker = join(sessionDir, 'where')
  486. hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
  487. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  488. const ctx = new Context()
  489. await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt)
  490. await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
  491. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
  492. await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
  493. ctx.llm.registerAdapter(['mock'], adapter)
  494. ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  495. const { SessionId } = await import('@deepseek-ai/dsh-session')
  496. const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
  497. handle.agent.send([{ type: 'text', text: 'go' }])
  498. await waitForIdle(ctx, handle.agent as ReactLoopAgent)
  499. expect(existsSync(marker)).toBe(true)
  500. expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
  501. await handle.dispose()
  502. })
  503. })