coverage-cases.ts 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  8. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  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. const testToolSignal = new AbortController().signal
  18. const dirs: string[] = []
  19. const contexts: Context[] = []
  20. afterEach(async () => {
  21. for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
  22. for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
  23. })
  24. function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d }
  25. function sh(d: string, name: string, body: string): string {
  26. const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p
  27. }
  28. function hooks(d: string, h: unknown): string {
  29. writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
  30. }
  31. type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string }
  32. async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
  33. const ctx = new Context()
  34. contexts.push(ctx)
  35. await mountAgentLoopTestDependencies(ctx)
  36. if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
  37. await ctx.plugin(AgentLoop, { agents: [] })
  38. await ctx.plugin(LocalSubprocessRuntime)
  39. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  40. await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
  41. ctx.llm.registerAdapter(['mock'], adapter)
  42. return ctx
  43. }
  44. function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
  45. return agent.whenIdle()
  46. }
  47. function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() }
  48. /** Poll until `predicate` holds or the deadline passes — robust to detached
  49. * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
  50. async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
  51. const deadline = Date.now() + timeout
  52. while (!predicate()) {
  53. if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
  54. await new Promise(r => setTimeout(r, interval))
  55. }
  56. }
  57. export type CoverageGroup = 'prompt' | 'post-tool' | 'result-shape' | 'edge-paths' | 'payload'
  58. /** Register independently schedulable slices of the hooks-codex coverage matrix. */
  59. export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGroup[]): void {
  60. const selected = new Set(typeof groups === 'string' ? [groups] : groups)
  61. if (selected.has('prompt')) describe('hooks-codex coverage — prompt decision mapping', () => {
  62. it('degrades transcript_path to null even with persistence mounted', async () => {
  63. const d = dir()
  64. const cap = join(d, 'payload')
  65. const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
  66. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  67. const ctx = await harness(path, adapter, { sessionRoot: dir() })
  68. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  69. const agent = await ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
  70. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  71. await waitForIdle(ctx, agent)
  72. // The persistence seam exposes no artifact paths, so the field stays null
  73. // even with a persistence backend mounted.
  74. expect((JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null }).transcript_path).toBeNull()
  75. }, 15_000) // The real agent/hook subprocess loop needs process startup and teardown headroom.
  76. it('UserPromptSubmit block (exit 2) closes a blocked turn without a step', async () => {
  77. const d = dir()
  78. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  79. const adapter = new MockAdapter([textResponse('no')])
  80. const ctx = await harness(join(d, 'hooks.json'), adapter)
  81. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  82. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  83. expect(adapter.requests).toHaveLength(0)
  84. expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
  85. || e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
  86. .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
  87. })
  88. it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => {
  89. const d = dir()
  90. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] })
  91. const adapter = new MockAdapter([textResponse('ok')])
  92. const ctx = await harness(join(d, 'hooks.json'), adapter)
  93. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  94. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  95. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
  96. })
  97. it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
  98. // Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a
  99. // downstream policy listener can still block.
  100. const d = dir()
  101. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
  102. const adapter = new MockAdapter([textResponse('should not run')])
  103. const ctx = await harness(join(d, 'hooks.json'), adapter)
  104. ctx.on('agent/pre-step', async () => ({
  105. kind: 'reject' as const,
  106. }))
  107. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  108. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  109. expect(adapter.requests).toHaveLength(0)
  110. expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
  111. expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
  112. || e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
  113. .toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
  114. })
  115. it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {
  116. const d = dir()
  117. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] })
  118. const adapter = new MockAdapter([textResponse('ok')])
  119. const ctx = await harness(join(d, 'hooks.json'), adapter)
  120. ctx.on('agent/pre-step', async ({ messages }) => ({
  121. kind: 'enter' as const,
  122. messages: [{
  123. ...messages[0]!,
  124. content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
  125. }, createUserMessage({
  126. content: [{ type: 'text' as const, text: 'from-downstream' }],
  127. source: { kind: 'plugin' as const, plugin: 'policy' },
  128. })],
  129. }))
  130. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  131. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  132. const req = JSON.stringify(adapter.requests[0]!.messages)
  133. expect(req).toContain('from-bridge')
  134. expect(req).toContain('from-downstream')
  135. expect(req).toContain('rewritten-prompt')
  136. const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
  137. expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
  138. { kind: 'plugin', plugin: 'policy' },
  139. { kind: 'plugin', plugin: 'hooks-codex' },
  140. ])
  141. })
  142. })
  143. if (selected.has('post-tool')) describe('hooks-codex coverage — post-tool and session context mapping', () => {
  144. it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => {
  145. const d = dir()
  146. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
  147. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  148. const ctx = await harness(join(d, 'hooks.json'), adapter)
  149. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  150. ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
  151. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  152. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  153. const result = events(agent).find(e => e.type === 'tool/result')
  154. expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
  155. expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
  156. })
  157. it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => {
  158. const d = dir()
  159. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
  160. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  161. const ctx = await harness(join(d, 'hooks.json'), adapter)
  162. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  163. ctx.on('tools/post-execute', async () => ({
  164. kind: 'accept' as const,
  165. additionalContexts: [createUserMessage({
  166. content: [{ type: 'text' as const, text: 'downstream-note' }],
  167. source: { kind: 'plugin' as const, plugin: 'policy' },
  168. })],
  169. }))
  170. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  171. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  172. const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
  173. expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
  174. { kind: 'plugin', plugin: 'hooks-codex' },
  175. { kind: 'plugin', plugin: 'policy' },
  176. ])
  177. })
  178. it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
  179. const d = dir()
  180. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
  181. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
  182. const ctx = await harness(join(d, 'hooks.json'), adapter)
  183. ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  184. ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
  185. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  186. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  187. const result = events(agent).find(e => e.type === 'tool/result')
  188. expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
  189. expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
  190. expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
  191. }, 10_000) // The real hook subprocess needs startup and teardown headroom under full-suite contention.
  192. it('SessionStart additionalContext is injected for the first request', async () => {
  193. const d = dir()
  194. hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] })
  195. const adapter = new MockAdapter([textResponse('ok')])
  196. const ctx = await harness(join(d, 'hooks.json'), adapter)
  197. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  198. await waitFor(() => agent.inbox.nextStep.some(message =>
  199. message.content.some(block => block.type === 'text' && block.text.includes('start-ctx'))))
  200. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  201. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
  202. })
  203. it('PostToolUse block (exit 2) → isError feedback; default reason', async () => {
  204. const d = dir()
  205. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  206. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
  207. const ctx = await harness(join(d, 'hooks.json'), adapter)
  208. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  209. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  210. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  211. const r = events(agent).find(e => e.type === 'tool/result')
  212. expect(r?.type === 'tool/result' && r.data.message.content[0].isError).toBe(true)
  213. expect(r?.type === 'tool/result' && r.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
  214. })
  215. it('PostToolUse additionalContext (clean exit) is attached after the result', async () => {
  216. const d = dir()
  217. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] })
  218. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
  219. const ctx = await harness(join(d, 'hooks.json'), adapter)
  220. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  221. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  222. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  223. expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
  224. })
  225. })
  226. if (selected.has('result-shape')) describe('hooks-codex coverage — hook result shape and configuration', () => {
  227. it('PreToolUse for a tool call WITHOUT a command arg passes an empty command (commandOf non-object/missing arm)', async () => {
  228. const d = dir()
  229. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pre.sh', '#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n') }] }] })
  230. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg
  231. const ctx = await harness(join(d, 'hooks.json'), adapter)
  232. let ran = false
  233. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  234. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  235. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  236. expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
  237. })
  238. it('a clean exit-0 hook records exitCode 0 and omits stderrSummary', async () => {
  239. const d = dir()
  240. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
  241. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  242. const ctx = await harness(join(d, 'hooks.json'), adapter)
  243. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  244. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  245. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  246. const res = events(agent).find(e => e.type === 'hook/result')
  247. expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
  248. expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
  249. })
  250. it('a long stderr is truncated in the hook/result summary', async () => {
  251. const d = dir()
  252. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
  253. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  254. const ctx = await harness(join(d, 'hooks.json'), adapter)
  255. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  256. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  257. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  258. const res = events(agent).find(e => e.type === 'hook/result')
  259. expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
  260. expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
  261. })
  262. it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
  263. const d = dir()
  264. hooks(d, {})
  265. for (const bad of [0, -5, 1.5, Number.NaN]) {
  266. const adapter = new MockAdapter([])
  267. await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad }))
  268. .rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/)
  269. }
  270. })
  271. it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
  272. const d = dir()
  273. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
  274. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  275. const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
  276. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  277. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  278. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  279. const res = events(agent).find(e => e.type === 'hook/result')
  280. expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
  281. })
  282. it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => {
  283. const d = dir()
  284. const marker = join(d, 'ran')
  285. hooks(d, { UserPromptSubmit: [{ hooks: [
  286. { type: 'command', command: 'bg.sh', async: true }, // skipped → warn
  287. { type: 'command', command: sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) },
  288. ] }] })
  289. const warn = vi.fn()
  290. const adapter = new MockAdapter([textResponse('ok')])
  291. const ctx = new Context()
  292. await mountAgentLoopTestDependencies(ctx)
  293. await ctx.plugin(AgentLoop, { agents: [] })
  294. await ctx.plugin(LocalSubprocessRuntime)
  295. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  296. ctx.logger.warn = warn as never
  297. // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
  298. HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
  299. ctx.llm.registerAdapter(['mock'], adapter)
  300. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  301. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  302. expect(existsSync(marker)).toBe(true)
  303. expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook'))
  304. })
  305. it('a no-op clean hook proceeds (contextFrom empty → next)', async () => {
  306. const d = dir()
  307. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
  308. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  309. const ctx = await harness(join(d, 'hooks.json'), adapter)
  310. let ran = false
  311. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  312. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  313. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  314. expect(ran).toBe(true)
  315. })
  316. it('SessionStart with no additionalContext is a no-op (contextFrom empty)', async () => {
  317. const d = dir()
  318. // The hook touches a marker so we can wait for it to ACTUALLY FINISH before
  319. // asserting absence — a completed turn alone would not prove the detached
  320. // session-start hook ran, making the absence check a false pass.
  321. const marker = join(d, 'ss-ran')
  322. hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] })
  323. const adapter = new MockAdapter([textResponse('ok')])
  324. const ctx = await harness(join(d, 'hooks.json'), adapter)
  325. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  326. await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
  327. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  328. expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
  329. })
  330. it('a throwing SessionStart inject is contained (logged)', async () => {
  331. const d = dir()
  332. hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') }] }] })
  333. const adapter = new MockAdapter([textResponse('ok')])
  334. const ctx = await harness(join(d, 'hooks.json'), adapter)
  335. const warn = vi.fn(); ctx.logger.warn = warn as never
  336. try {
  337. await ctx.agents.create({
  338. sessionId: SessionId('a1'),
  339. agentOptions: { provider: 'mock', model: 'mock' },
  340. setup(_agentCtx, agent) {
  341. vi.spyOn(agent, 'inject').mockImplementationOnce(() => { throw new Error('inject boom') })
  342. },
  343. })
  344. expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
  345. } finally {
  346. await ctx.fiber.dispose()
  347. }
  348. })
  349. })
  350. if (selected.has('edge-paths')) describe('hooks-codex coverage — matching and no-agent edge paths', () => {
  351. it('a clean PreToolUse with no decision allows the tool (no deny)', async () => {
  352. const d = dir()
  353. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'ok.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. let ran = false
  357. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  358. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  359. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  360. expect(ran).toBe(true)
  361. })
  362. it('a non-matching regex matcher skips the hook (matchesMatcher false → continue)', async () => {
  363. const d = dir()
  364. // /^Edit$/ does not match the tool name "Bash" → the group is skipped.
  365. hooks(d, { PreToolUse: [{ matcher: '^Edit$', hooks: [{ type: 'command', command: sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  366. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  367. const ctx = await harness(join(d, 'hooks.json'), adapter)
  368. let ran = false
  369. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  370. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  371. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  372. expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded
  373. expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false)
  374. })
  375. it('a {"continue":false} hook is RECORDED as "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
  376. // Honoring `continue:false` is deferred — the extension points have no hard-halt
  377. // primitive. Assert the LOG records the halt request AND that the run is not
  378. // actually halted (the tool still runs, the turn completes).
  379. const d = dir()
  380. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') }] }] })
  381. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  382. const ctx = await harness(join(d, 'hooks.json'), adapter)
  383. let ran = false
  384. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  385. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  386. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  387. const res = events(agent).find(e => e.type === 'hook/result')
  388. expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
  389. expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
  390. })
  391. it('PreToolUse deny with EMPTY stderr uses the default reason (?? right arm)', async () => {
  392. const d = dir()
  393. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  394. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  395. const ctx = await harness(join(d, 'hooks.json'), adapter)
  396. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  397. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  398. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  399. const r = events(agent).find(e => e.type === 'tool/result')
  400. expect(r?.type === 'tool/result' && r.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
  401. })
  402. it('PostToolUse block AND additionalContext are surfaced together', async () => {
  403. const d = dir()
  404. 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') }] }] })
  405. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  406. const ctx = await harness(join(d, 'hooks.json'), adapter)
  407. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  408. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  409. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  410. const r = events(agent).find(e => e.type === 'tool/result')
  411. expect(r?.type === 'tool/result' && r.data.message.content[0].isError).toBe(true)
  412. expect(r?.type === 'tool/result' && r.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
  413. expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true)
  414. })
  415. it('commandOf reads a non-string command arg as an empty command', async () => {
  416. const d = dir()
  417. // The tool-call arguments carry `command` as a NUMBER → commandOf's
  418. // `typeof command === 'string'` false arm → '' (the payload's tool_input.command).
  419. const cap = join(d, 'payload')
  420. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] })
  421. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')])
  422. const ctx = await harness(join(d, 'hooks.json'), adapter)
  423. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  424. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  425. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  426. const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
  427. expect(payload.tool_input.command).toBe('')
  428. })
  429. it('a no-agent direct PreToolUse run uses process.cwd() and turn 0 (no session to record)', async () => {
  430. const d = dir()
  431. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  432. const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
  433. let ran = false
  434. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
  435. const { ToolCallId } = await import('@deepseek-ai/dsh-llm')
  436. const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'Bash', arguments: { command: 'x' } })
  437. expect(ran).toBe(false) // denied
  438. expect(result.isError).toBe(true)
  439. })
  440. it('a no-agent direct PostToolUse run attaches context with no session to record', async () => {
  441. const d = dir()
  442. hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] })
  443. const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
  444. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  445. const { ToolCallId } = await import('@deepseek-ai/dsh-llm')
  446. const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'Bash', arguments: { command: 'x' } })
  447. expect(result.isError).toBeFalsy()
  448. expect(result.additionalContexts?.[0]?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true)
  449. })
  450. it('when the bash executor REJECTS, the hook/result omits exitCode (non-blocking)', async () => {
  451. const d = dir()
  452. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
  453. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  454. const ctx = await harness(join(d, 'hooks.json'), adapter)
  455. ctx.shell.run = (() => Promise.reject(new Error('executor down')))
  456. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  457. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  458. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  459. const res = events(agent).find(e => e.type === 'hook/result')
  460. expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
  461. })
  462. })
  463. if (selected.has('payload')) describe('hooks-codex coverage — continuation, payload, and cwd mapping', () => {
  464. it('a blocking Stop hook with EMPTY stderr still forces continuation (no reason required)', async () => {
  465. // Regression: an exit-2 Stop hook with no stderr yields decision 'deny' +
  466. // reason undefined; the turn must STILL force-continue, not silently stop.
  467. const d = dir()
  468. const marker = join(d, 'fired')
  469. 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`) }] }] })
  470. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  471. const ctx = await harness(join(d, 'hooks.json'), adapter)
  472. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  473. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  474. expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation
  475. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
  476. })
  477. it('a clean UserPromptSubmit hook that prints PLAIN stdout injects it as context', async () => {
  478. // Codex feeds a SessionStart/UserPromptSubmit hook's PLAIN (non-JSON) stdout
  479. // as additionalContext (unlike CC, which needs a JSON hookSpecificOutput).
  480. const d = dir()
  481. 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') }] }] })
  482. const adapter = new MockAdapter([textResponse('ok')])
  483. const ctx = await harness(join(d, 'hooks.json'), adapter)
  484. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  485. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  486. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook')
  487. })
  488. it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => {
  489. // SessionStart cannot block, but non-clean stdout still must not become context. The marker
  490. // waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches
  491. // the codec's structured-stdout rule.
  492. const d = dir()
  493. const marker = join(d, 'ran')
  494. hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] })
  495. const adapter = new MockAdapter([textResponse('ok')])
  496. const ctx = await harness(join(d, 'hooks.json'), adapter)
  497. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  498. await waitFor(() => existsSync(marker)) // the exit-2 hook has finished
  499. expect(events(agent).some(e => e.type === 'user/message'
  500. && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false)
  501. })
  502. it('a UserPromptSubmit hook with a non-blocking error exit (1) + stdout does NOT inject it', async () => {
  503. // Exit 1 is a non-blocking error (no decision), so the prompt is NOT blocked
  504. // and the handler falls through to the context path — the gate must still
  505. // suppress the error hook's stdout ("stale" never reaches the model).
  506. const d = dir()
  507. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] })
  508. const adapter = new MockAdapter([textResponse('ok')])
  509. const ctx = await harness(join(d, 'hooks.json'), adapter)
  510. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  511. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  512. expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran
  513. expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale')
  514. })
  515. it('a clean SessionStart hook that prints PLAIN stdout injects it (not JSON)', async () => {
  516. const d = dir()
  517. hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] })
  518. const adapter = new MockAdapter([textResponse('ok')])
  519. const ctx = await harness(join(d, 'hooks.json'), adapter)
  520. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  521. await waitFor(() => agent.inbox.nextStep.some(message =>
  522. message.content.some(block => block.type === 'text' && block.text.includes('session preamble'))))
  523. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  524. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
  525. })
  526. it('a clean hook that prints JSON is NOT injected as prose (plain-stdout gate)', async () => {
  527. // A structured (JSON) stdout must go through the hookSpecificOutput path, not
  528. // be dumped verbatim as context — the `!startsWith('{')` gate guards this.
  529. const d = dir()
  530. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] })
  531. const adapter = new MockAdapter([textResponse('ok')])
  532. const ctx = await harness(join(d, 'hooks.json'), adapter)
  533. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  534. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  535. expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated')
  536. })
  537. it('the PreToolUse payload carries the REAL tool name (matches the matcher subject)', async () => {
  538. // Regression: the payload once hardcoded tool_name "Bash", disagreeing with
  539. // the exec.name matcher subject — a config matcher on the real name would
  540. // then never fire. Capture the payload and assert tool_name === the real name.
  541. const d = dir()
  542. const cap = join(d, 'payload')
  543. hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] })
  544. const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
  545. const ctx = await harness(join(d, 'hooks.json'), adapter)
  546. ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  547. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  548. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  549. const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } }
  550. expect(payload.tool_name).toBe('shell')
  551. expect(payload.tool_input.command).toBe('ls')
  552. })
  553. it('a Codex matcher on the REAL tool name fires (matcher subject === payload tool_name)', async () => {
  554. // A regex matcher matching the real tool name must select the hook — proving
  555. // the matcher subject and the payload tool_name agree.
  556. const d = dir()
  557. hooks(d, { PreToolUse: [{ matcher: 'shell', hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
  558. const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
  559. const ctx = await harness(join(d, 'hooks.json'), adapter)
  560. let ran = false
  561. ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
  562. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  563. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  564. expect(ran).toBe(false) // the matcher fired → the hook denied the tool
  565. expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true)
  566. })
  567. it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => {
  568. const d = dir()
  569. hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] })
  570. const adapter = new MockAdapter([textResponse('ok')])
  571. const ctx = await harness(join(d, 'hooks.json'), adapter)
  572. const warn = vi.fn(); ctx.logger.warn = warn as never
  573. const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  574. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
  575. expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
  576. expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
  577. })
  578. it('runs an agent-scoped hook in the session cwd, not the executor default', async () => {
  579. // Same regression as the CC bridge: the Codex bridge must thread the session
  580. // cwd as the hook workdir. Executor default = serverDir; session cwd =
  581. // sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir.
  582. const serverDir = dir()
  583. const sessionDir = dir()
  584. const marker = join(sessionDir, 'where')
  585. hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
  586. const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
  587. const ctx = new Context()
  588. await mountAgentLoopTestDependencies(ctx)
  589. await ctx.plugin(AgentLoop, { agents: [] })
  590. await ctx.plugin(LocalSubprocessRuntime)
  591. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
  592. await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
  593. ctx.llm.registerAdapter(['mock'], adapter)
  594. ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
  595. const { SessionId } = await import('@deepseek-ai/dsh-session')
  596. const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
  597. handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  598. await waitForIdle(ctx, handle.agent)
  599. expect(existsSync(marker)).toBe(true)
  600. expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
  601. await handle.dispose()
  602. })
  603. })
  604. }