interception.spec.ts 34 KB

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