interception.spec.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
  7. import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
  8. import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
  9. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  10. /**
  11. * The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
  12. * `agent/session-start`, the reshaped `agent/turn-continuation`
  13. * ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute`
  14. * split with `additionalContext` buffering. These verify the canonical event
  15. * surface a hook bridge (or a native plugin) programs against, WITHOUT any
  16. * external protocol — a native plugin uses the typed decisions directly.
  17. */
  18. async function harness(adapter: MockAdapter) {
  19. const ctx = new Context()
  20. await ctx.plugin(LlmService)
  21. await ctx.plugin(SessionStore)
  22. await ctx.plugin(SystemPrompt)
  23. await ctx.plugin(ToolRegistry)
  24. await ctx.plugin(AgentRegistry)
  25. await ctx.plugin(AgentLoop, { agents: [] })
  26. ctx.llm.registerAdapter(['mock'], adapter)
  27. return ctx
  28. }
  29. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  30. return new Promise((resolve) => {
  31. const dispose = ctx.on('agent/status', (subject, status) => {
  32. if (subject === agent && status === 'idle') {
  33. dispose()
  34. resolve()
  35. }
  36. })
  37. })
  38. }
  39. function send(agent: ReactLoopAgent, text: string) {
  40. agent.send([{ type: 'text', text }])
  41. }
  42. function events(agent: ReactLoopAgent): SessionEvent[] {
  43. return [...agent.session.events]
  44. }
  45. describe('agent/prompt-submit', () => {
  46. it('allow (default via next) records the user/message unchanged', async () => {
  47. const adapter = new MockAdapter([textResponse('ok')])
  48. const ctx = await harness(adapter)
  49. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  50. const seen: string[] = []
  51. ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
  52. seen.push(content.map(b => (b.type === 'text' ? b.text : '')).join(''))
  53. return next()
  54. })
  55. send(agent, 'hello')
  56. await waitForIdle(ctx, agent)
  57. expect(seen).toEqual(['hello'])
  58. const userMsg = events(agent).find(e => e.type === 'user/message')
  59. expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }])
  60. })
  61. it('allow with content REWRITES the prompt before it is recorded', async () => {
  62. const adapter = new MockAdapter([textResponse('ok')])
  63. const ctx = await harness(adapter)
  64. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  65. ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
  66. ({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
  67. send(agent, 'original')
  68. await waitForIdle(ctx, agent)
  69. const userMsg = events(agent).find(e => e.type === 'user/message')
  70. expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'REWRITTEN' }])
  71. // the rewritten prompt is what reached the model
  72. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('REWRITTEN')
  73. expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
  74. })
  75. it('allow with additionalContext injects a separate context/message into the turn', async () => {
  76. const adapter = new MockAdapter([textResponse('ok')])
  77. const ctx = await harness(adapter)
  78. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  79. ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
  80. ({
  81. kind: 'allow',
  82. additionalContext: { content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' } },
  83. }))
  84. send(agent, 'go')
  85. await waitForIdle(ctx, agent)
  86. const log = events(agent)
  87. const userMsg = log.find(e => e.type === 'user/message')
  88. const ctxMsg = log.find(e => e.type === 'context/message')
  89. expect(userMsg).toBeDefined()
  90. expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }])
  91. expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
  92. // both the prompt and the injected context reach the model
  93. const sent = JSON.stringify(adapter.requests[0]!.messages)
  94. expect(sent).toContain('extra ctx')
  95. })
  96. it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
  97. // The merge of the interception seams with master's compaction seam pins one
  98. // ordering: `agent/prompt-submit` runs (rewriting the prompt and injecting
  99. // context) BEFORE the step loop, and `agent/pre-step` fires INSIDE the step
  100. // before the single deriveMessages(). So a compaction listener on
  101. // `agent/pre-step` must observe the surface AFTER the prompt rewrite/inject —
  102. // otherwise it would measure/compact stale history. This cross-test proves
  103. // the two seams compose in the right order (each is covered in isolation
  104. // elsewhere; this asserts they see each other's effects on the same turn).
  105. const adapter = new MockAdapter([textResponse('ok')])
  106. const ctx = await harness(adapter)
  107. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  108. ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
  109. ({
  110. kind: 'allow',
  111. content: [{ type: 'text', text: 'REWRITTEN prompt' }],
  112. additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } },
  113. }))
  114. // The pre-step seam (where compaction lives) derives the surface it would act
  115. // on. Capture what it sees on the first step.
  116. let preStepDerived: string | undefined
  117. ctx.on('agent/pre-step', (subject, _turn, step) => {
  118. if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
  119. })
  120. send(agent, 'ORIGINAL prompt')
  121. await waitForIdle(ctx, agent)
  122. // The pre-step seam ran and saw BOTH the rewrite (not the original) and the
  123. // injected context — i.e. the prompt-submit effects landed before it.
  124. expect(preStepDerived).toBeDefined()
  125. expect(preStepDerived).toContain('REWRITTEN prompt')
  126. expect(preStepDerived).toContain('injected ctx')
  127. expect(preStepDerived).not.toContain('ORIGINAL prompt')
  128. })
  129. it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
  130. const adapter = new MockAdapter([textResponse('should not run')])
  131. const ctx = await harness(adapter)
  132. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  133. ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
  134. ({ kind: 'block', reason: 'blocked by policy' }))
  135. const reasons: TurnEndReason[] = []
  136. ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  137. send(agent, 'do something')
  138. await waitForIdle(ctx, agent)
  139. // the model was never called
  140. expect(adapter.requests).toHaveLength(0)
  141. // the turn opened and closed balanced, with no user/message and no step
  142. const log = events(agent)
  143. expect(log.some(e => e.type === 'turn/start')).toBe(true)
  144. expect(log.some(e => e.type === 'turn/end')).toBe(true)
  145. expect(log.some(e => e.type === 'user/message')).toBe(false)
  146. expect(log.some(e => e.type === 'step/start')).toBe(false)
  147. // the veto is recorded durably as a prompt/blocked in the open turn
  148. const blocked = log.find(e => e.type === 'prompt/blocked')
  149. expect(blocked?.type === 'prompt/blocked' && blocked.data).toMatchObject({
  150. content: [{ type: 'text', text: 'do something' }],
  151. reason: 'blocked by policy',
  152. })
  153. // ended rejected with the block reason
  154. expect(reasons).toEqual([{ kind: 'rejected', reason: 'blocked by policy' }])
  155. const turnEnd = log.findLast(e => e.type === 'turn/end')
  156. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
  157. })
  158. it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
  159. // Two prompts queued into ONE turn: block "secret", allow "safe". The turn is
  160. // NOT rejected (a prompt was allowed), so without a durable prompt/blocked the
  161. // vetoed prompt and its reason would vanish from the log entirely.
  162. const adapter = new MockAdapter([textResponse('ran once')])
  163. const ctx = await harness(adapter)
  164. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  165. ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
  166. const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
  167. return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
  168. })
  169. const reasons: TurnEndReason[] = []
  170. ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  171. // both sends land before the loop drains → one batched turn
  172. send(agent, 'secret')
  173. send(agent, 'safe')
  174. await waitForIdle(ctx, agent)
  175. const log = events(agent)
  176. // the allowed prompt became a user/message and drove exactly one model call
  177. const userMsgs = log.filter(e => e.type === 'user/message')
  178. expect(userMsgs).toHaveLength(1)
  179. expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
  180. expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
  181. // the blocked prompt is durably recorded, with its content + reason
  182. const blocked = log.filter(e => e.type === 'prompt/blocked')
  183. expect(blocked).toHaveLength(1)
  184. expect(blocked[0]?.type === 'prompt/blocked' && blocked[0].data).toMatchObject({
  185. content: [{ type: 'text', text: 'secret' }],
  186. reason: 'policy: no secrets',
  187. })
  188. // the turn did NOT reject — a sibling was allowed — so the boundary reason
  189. // alone would not have preserved the block
  190. expect(reasons.some(r => r.kind === 'rejected')).toBe(false)
  191. })
  192. it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
  193. const adapter = new MockAdapter([textResponse('after')])
  194. const ctx = await harness(adapter)
  195. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  196. let threw = false
  197. ctx.on('agent/prompt-submit', async () => {
  198. if (!threw) { threw = true; throw new Error('prompt hook broke') }
  199. return { kind: 'allow' as const }
  200. })
  201. const errors: Error[] = []
  202. ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
  203. send(agent, 'first')
  204. await waitForIdle(ctx, agent)
  205. expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
  206. // turn balanced
  207. const log = events(agent)
  208. expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
  209. expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
  210. // loop survives: a second prompt runs normally
  211. send(agent, 'second')
  212. await waitForIdle(ctx, agent)
  213. expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
  214. })
  215. })
  216. describe('agent/session-start', () => {
  217. it('fires once with source "startup" for a fresh create, before the first turn', async () => {
  218. const adapter = new MockAdapter([textResponse('ok')])
  219. const ctx = await harness(adapter)
  220. const sources: SessionStartSource[] = []
  221. ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
  222. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  223. // fires synchronously at create, before any turn
  224. expect(sources).toEqual(['startup'])
  225. expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
  226. send(agent, 'go')
  227. await waitForIdle(ctx, agent)
  228. // still only one session-start
  229. expect(sources).toEqual(['startup'])
  230. })
  231. it('a session-start listener can inject context the first request sees', async () => {
  232. const adapter = new MockAdapter([textResponse('ok')])
  233. const ctx = await harness(adapter)
  234. ctx.on('agent/session-start', (agent) => {
  235. agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
  236. })
  237. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  238. send(agent, 'go')
  239. await waitForIdle(ctx, agent)
  240. // the injected context reached the model on the first (only) request
  241. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
  242. // and is recorded with the plugin source, never mislabeled as a user prompt
  243. const ctxMsg = events(agent).find(e => e.type === 'context/message')
  244. expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
  245. })
  246. it('a throwing session-start listener does not abort agent construction', async () => {
  247. const adapter = new MockAdapter([textResponse('ok')])
  248. const ctx = await harness(adapter)
  249. ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
  250. // create must not throw — the listener error is contained/logged
  251. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  252. expect(agent.id).toBe(SessionId('a1'))
  253. // and the agent still runs
  254. send(agent, 'go')
  255. await waitForIdle(ctx, agent)
  256. expect(adapter.requests).toHaveLength(1)
  257. })
  258. })
  259. describe('agent/session-prefix', () => {
  260. it('dispatches to global and matching agent-scope listeners only', async () => {
  261. const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')])
  262. const ctx = await harness(adapter)
  263. const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { model: 'mock' })
  264. const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { model: 'mock' })
  265. const seen: string[] = []
  266. ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
  267. seen.push(`global:${agent.id}`)
  268. return next()
  269. })
  270. agentA.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
  271. seen.push(`a:${agent.id}`)
  272. return next()
  273. })
  274. agentB.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
  275. seen.push(`b:${agent.id}`)
  276. return next()
  277. })
  278. send(agentA, 'run a')
  279. await waitForIdle(ctx, agentA)
  280. send(agentB, 'run b')
  281. await waitForIdle(ctx, agentB)
  282. expect(seen).toEqual([
  283. 'global:prefix-a', 'a:prefix-a',
  284. 'global:prefix-b', 'b:prefix-b',
  285. ])
  286. })
  287. it('composes once per loop instance and fronts every request; the header records it; history stays untouched', async () => {
  288. const adapter = new MockAdapter([
  289. toolCallResponse('c1', 'echo', { text: 'ping' }),
  290. textResponse('done'),
  291. textResponse('again'),
  292. ])
  293. const ctx = await harness(adapter)
  294. ctx.tools.register(defineTool({
  295. name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
  296. async execute(args) { return [{ type: 'text', text: String(args.text) }] },
  297. }))
  298. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  299. const reminder: Message = { role: 'user', content: [{ type: 'text', text: '<system-reminder>catalog</system-reminder>' }] }
  300. let composed = 0
  301. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
  302. composed += 1
  303. return [...await next(), reminder]
  304. })
  305. send(agent, 'go')
  306. await waitForIdle(ctx, agent)
  307. send(agent, 'next turn')
  308. await waitForIdle(ctx, agent)
  309. // Three requests (two turns), ONE composition: the frozen product is
  310. // reused verbatim, so the prefix cannot drift mid-session.
  311. expect(adapter.requests).toHaveLength(3)
  312. expect(composed).toBe(1)
  313. for (const request of adapter.requests) {
  314. expect(request.messages[0]).toEqual(reminder)
  315. }
  316. // The anchoring snapshot is the prefix's durable record — and the ONLY
  317. // header event: reuse means no changed snapshot ever.
  318. const headerEvents = events(agent).filter(e => e.type === 'request/header')
  319. expect(headerEvents).toHaveLength(1)
  320. expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.header.messagePrefix).toEqual([reminder])
  321. // Never session history: the derivation starts at the real user prompt.
  322. expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] })
  323. })
  324. it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => {
  325. const adapter = new MockAdapter([textResponse('ok')])
  326. const ctx = await harness(adapter)
  327. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  328. const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] }
  329. const order: string[] = []
  330. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
  331. order.push('compose')
  332. return [reminder, ...await next()]
  333. })
  334. const seen: (readonly Message[])[] = []
  335. ctx.on('agent/pre-step', (_agent, _turn, _step, _system, sessionPrefix) => {
  336. order.push('pre-step')
  337. seen.push(sessionPrefix)
  338. })
  339. send(agent, 'hi')
  340. await waitForIdle(ctx, agent)
  341. // Composition precedes the pre-step seam, and the seam receives THIS
  342. // instance's composed prefix — a token-pressure gate (compaction) counts
  343. // what the request will actually carry, never a stale logged prefix.
  344. expect(order).toEqual(['compose', 'pre-step'])
  345. expect(seen[0]).toEqual([reminder])
  346. })
  347. it('the canonical prepend pattern composes contributions in registration order', async () => {
  348. const adapter = new MockAdapter([textResponse('ok')])
  349. const ctx = await harness(adapter)
  350. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  351. // Both listeners use the canonical `[mine, ...await next()]` prepend: the
  352. // waterfall unwinds innermost-first (the second listener's array is built
  353. // first), so prepending puts the FIRST-registered contribution first.
  354. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
  355. return [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...await next()]
  356. })
  357. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
  358. return [{ role: 'user', content: [{ type: 'text', text: 'second' }] }, ...await next()]
  359. })
  360. send(agent, 'hi')
  361. await waitForIdle(ctx, agent)
  362. const texts = adapter.requests[0]!.messages.map(m => m.content[0]?.type === 'text' ? m.content[0].text : '')
  363. expect(texts).toEqual(['first', 'second', 'hi'])
  364. })
  365. it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => {
  366. const adapter = new MockAdapter([textResponse('ok')])
  367. const ctx = await harness(adapter)
  368. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  369. // A listener that delegates without contributing — the canonical no-op.
  370. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next())
  371. send(agent, 'hi')
  372. await waitForIdle(ctx, agent)
  373. const headerEvent = events(agent).find(e => e.type === 'request/header')
  374. expect(headerEvent?.type === 'request/header' && 'messagePrefix' in headerEvent.data.header).toBe(false)
  375. expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
  376. })
  377. it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => {
  378. const adapter = new MockAdapter([textResponse('ok')])
  379. const ctx = await harness(adapter)
  380. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  381. let mutationError: unknown
  382. ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise<Message[]> => {
  383. try {
  384. prefix.push({ role: 'user', content: [{ type: 'text', text: 'smuggled' }] })
  385. } catch (error: unknown) {
  386. mutationError = error
  387. }
  388. return next()
  389. })
  390. send(agent, 'hi')
  391. await waitForIdle(ctx, agent)
  392. expect(mutationError).toBeInstanceOf(TypeError)
  393. expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
  394. })
  395. it('mutating a listener-held reference after composition cannot alter later requests (the cache is a frozen clone)', async () => {
  396. const adapter = new MockAdapter([
  397. toolCallResponse('c1', 'echo', { text: 'ping' }),
  398. textResponse('done'),
  399. ])
  400. const ctx = await harness(adapter)
  401. ctx.tools.register(defineTool({
  402. name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
  403. async execute(args) { return [{ type: 'text', text: String(args.text) }] },
  404. }))
  405. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  406. const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] }
  407. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [...await next(), held])
  408. send(agent, 'go')
  409. await waitForIdle(ctx, agent)
  410. // The listener mutates the object it contributed AFTER composition; the
  411. // cached prefix is a deep-frozen clone, so step 2's request is unchanged.
  412. held.content = [{ type: 'text', text: 'v2' }]
  413. expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'v1' }] })
  414. expect(events(agent).filter(e => e.type === 'request/header')).toHaveLength(1)
  415. })
  416. })
  417. describe('agent/turn-continuation (ContinuationDecision)', () => {
  418. it('a continue decision with a reason records next-step steering in the same turn', async () => {
  419. const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
  420. const ctx = await harness(adapter)
  421. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  422. let forced = false
  423. ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
  424. if (!forced) {
  425. forced = true
  426. return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } }
  427. }
  428. return next()
  429. })
  430. send(agent, 'go')
  431. await waitForIdle(ctx, agent)
  432. const log = events(agent)
  433. // same turn, two steps
  434. expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
  435. expect(log.filter(e => e.type === 'step/start')).toHaveLength(2)
  436. // the reason was recorded as steering BEFORE step 2, with its plugin source
  437. const steering = log.find(e => e.type === 'steering/message')
  438. expect(steering?.type === 'steering/message' && steering.data.content).toEqual([{ type: 'text', text: 'keep going on the goal' }])
  439. expect(steering?.type === 'steering/message' && steering.data.source).toEqual({ kind: 'plugin', plugin: 'goal' })
  440. // and reached the next request
  441. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going on the goal')
  442. })
  443. it('a stop decision ends the turn even when the step had tool calls', async () => {
  444. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })])
  445. const ctx = await harness(adapter)
  446. ctx.tools.register(defineTool({
  447. name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
  448. async execute(args) { return [{ type: 'text', text: String(args.text) }] },
  449. }))
  450. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  451. ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => ({ action: 'stop' }))
  452. send(agent, 'go')
  453. await waitForIdle(ctx, agent)
  454. // default would have continued (had tool calls), but the stop decision wins
  455. expect(adapter.requests).toHaveLength(1)
  456. expect(events(agent).some(e => e.type === 'tool/result')).toBe(true)
  457. })
  458. })
  459. describe('tools/post-execute additionalContext buffering across a multi-call step', () => {
  460. it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => {
  461. // One assistant step with TWO tool calls; the second model response stops.
  462. const twoCalls = [
  463. { type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
  464. { type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'echo', arguments: '{"text":"a"}' } },
  465. { type: 'block-start' as const, index: 1, blockType: 'tool-call' as const },
  466. { type: 'block-end' as const, index: 1, block: { type: 'tool-call' as const, id: CallId('c2'), name: 'echo', arguments: '{"text":"b"}' } },
  467. { type: 'usage' as const, usage: { inputTokens: 5, outputTokens: 5 } },
  468. { type: 'finish' as const, reason: { kind: 'tool-calls' as const } },
  469. ]
  470. const adapter = new MockAdapter([twoCalls, textResponse('done')])
  471. const ctx = await harness(adapter)
  472. ctx.tools.register(defineTool({
  473. name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
  474. async execute(args) { return [{ type: 'text', text: String(args.text) }] },
  475. }))
  476. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  477. // Each call attaches additionalContext naming itself.
  478. ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
  479. ({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } }))
  480. send(agent, 'go')
  481. await waitForIdle(ctx, agent)
  482. // Event order in the log: both tool/results, THEN both context/messages —
  483. // never interleaved (which would break tool-call/result adjacency).
  484. const types = events(agent).map(e => e.type)
  485. const firstResult = types.indexOf('tool/result')
  486. const lastResult = types.lastIndexOf('tool/result')
  487. const firstCtx = types.indexOf('context/message')
  488. expect(firstResult).toBeGreaterThanOrEqual(0)
  489. expect(lastResult).toBeGreaterThan(firstResult) // two results
  490. expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results
  491. // both contexts present
  492. const ctxTexts = events(agent)
  493. .filter(e => e.type === 'context/message')
  494. .flatMap(e => (e.type === 'context/message' ? e.data.content : []))
  495. .map(b => (b.type === 'text' ? b.text : ''))
  496. expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
  497. })
  498. })
  499. describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end through the loop)', () => {
  500. it('deny short-circuits dispatch into an isError result the model sees', async () => {
  501. const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')])
  502. const ctx = await harness(adapter)
  503. let ran = false
  504. ctx.tools.register(defineTool({
  505. name: 'danger', description: 'danger', parameters: {},
  506. async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
  507. }))
  508. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  509. ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
  510. if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' }
  511. return next()
  512. })
  513. send(agent, 'go')
  514. await waitForIdle(ctx, agent)
  515. expect(ran).toBe(false)
  516. const result = events(agent).find(e => e.type === 'tool/result')
  517. expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
  518. expect(result?.type === 'tool/result'
  519. && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true)
  520. })
  521. })
  522. describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => {
  523. // The whole point of the interception taxonomy: a "native hook" needs no
  524. // dsh-hook-protocol, no external command, no hook/* log — it is an ordinary
  525. // cordis plugin subscribing to the canonical events and returning typed
  526. // decisions. This proves all four seams compose end-to-end through the REAL
  527. // loop, with NO hook/* SessionEvents involved (those belong to the bridge lib).
  528. const NativeGuard = {
  529. name: 'native-guard',
  530. apply(ctx: Context) {
  531. // 1. SessionStart: seed a standing instruction.
  532. ctx.on('agent/session-start', (agent, source) => {
  533. agent.inject(
  534. [{ type: 'text', text: `policy active (started: ${source})` }],
  535. { source: { kind: 'plugin', plugin: 'native-guard' } },
  536. )
  537. })
  538. // 2. PromptSubmit: block a forbidden prompt, annotate the rest.
  539. ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
  540. const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
  541. if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
  542. return next()
  543. })
  544. // 3. PreToolUse: deny a dangerous tool by name.
  545. ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
  546. if (exec.name === 'danger') return { kind: 'deny', reason: 'danger tool denied' }
  547. return next()
  548. })
  549. // 4. PostToolUse: attach context after a tool runs.
  550. ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
  551. const decision = await next()
  552. if (decision.kind === 'accept') {
  553. return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } }
  554. }
  555. return decision
  556. })
  557. },
  558. }
  559. it('all four seams fire for a real allowed turn with a tool call', async () => {
  560. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')])
  561. const ctx = await harness(adapter)
  562. await ctx.plugin(NativeGuard)
  563. ctx.tools.register(defineTool({
  564. name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
  565. async execute(args) { return [{ type: 'text', text: String(args.text) }] },
  566. }))
  567. const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
  568. send(agent, 'please echo hi')
  569. await waitForIdle(ctx, agent)
  570. const log = events(agent)
  571. // session-start preamble injected
  572. expect(log.some(e => e.type === 'context/message'
  573. && e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
  574. // prompt allowed → user/message recorded
  575. expect(log.some(e => e.type === 'user/message')).toBe(true)
  576. // tool ran (echo allowed) and post-execute attached "audited" context
  577. expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true)
  578. expect(log.some(e => e.type === 'context/message'
  579. && e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
  580. // NO hook/* events — a native plugin needs none
  581. expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
  582. })
  583. it('the same plugin blocks a destructive prompt → rejected turn, model never called', async () => {
  584. const adapter = new MockAdapter([textResponse('should not run')])
  585. const ctx = await harness(adapter)
  586. await ctx.plugin(NativeGuard)
  587. const agent = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' })
  588. const reasons: TurnEndReason[] = []
  589. ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  590. send(agent, 'run rm -rf /')
  591. await waitForIdle(ctx, agent)
  592. expect(adapter.requests).toHaveLength(0)
  593. expect(reasons).toEqual([{ kind: 'rejected', reason: 'destructive prompt blocked' }])
  594. })
  595. it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => {
  596. const adapter = new MockAdapter([textResponse('ok')])
  597. const ctx = await harness(adapter)
  598. const fiber = await ctx.plugin(NativeGuard)
  599. await fiber.dispose()
  600. // After disposal, a destructive prompt is NOT blocked (the listener is gone).
  601. const agent = ctx.agentLoop.create(SessionId('a3'), { model: 'mock' })
  602. send(agent, 'run rm -rf /')
  603. await waitForIdle(ctx, agent)
  604. // the prompt ran (not rejected) — proving the prompt-submit listener was disposed
  605. expect(adapter.requests).toHaveLength(1)
  606. expect(events(agent).some(e => e.type === 'user/message')).toBe(true)
  607. })
  608. })