interception.spec.ts 32 KB

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