1
0

interception.spec.ts 36 KB

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