interception.spec.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, {
  5. SessionId,
  6. type SessionEvent,
  7. type TurnEndReason,
  8. type UserMessage,
  9. } from '@deepseek-ai/dsh-session'
  10. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  11. import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
  12. import AgentRegistry, {
  13. type Agent,
  14. type InboxPlacement,
  15. type PromptDecision,
  16. type SessionStartSource,
  17. } from '@deepseek-ai/dsh-agent'
  18. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  19. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  20. /**
  21. * The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
  22. * `agent/session-start`, `agent/turn-stopping`, and the
  23. * `tools/pre-execute` / `tools/post-execute`
  24. * split with `additionalContexts` buffering. These verify the canonical event
  25. * surface a hook bridge (or a native plugin) programs against, WITHOUT any
  26. * external protocol — a native plugin uses the typed decisions directly.
  27. */
  28. async function harness(adapter: MockAdapter) {
  29. const ctx = new Context()
  30. await ctx.plugin(LlmService)
  31. await ctx.plugin(SessionStore)
  32. await ctx.plugin(SystemPrompt)
  33. await ctx.plugin(ToolRegistry)
  34. await ctx.plugin(AgentRegistry)
  35. await ctx.plugin(AgentLoop, { agents: [] })
  36. ctx.llm.registerAdapter(['mock'], adapter)
  37. return ctx
  38. }
  39. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  40. return new Promise((resolve) => {
  41. const dispose = ctx.on('agent/status', (subject, status) => {
  42. if (subject === agent && status === 'idle') {
  43. dispose()
  44. resolve()
  45. }
  46. })
  47. })
  48. }
  49. function send(agent: Agent, text: string) {
  50. agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
  51. }
  52. function events(agent: Agent): SessionEvent[] {
  53. return [...agent.session.events]
  54. }
  55. describe('agent/prompt-submit', () => {
  56. it('allow (default via next) records the user/message unchanged', async () => {
  57. const adapter = new MockAdapter([textResponse('ok')])
  58. const ctx = await harness(adapter)
  59. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  60. const seen: string[] = []
  61. ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
  62. seen.push(message.content.map(b => (b.type === 'text' ? b.text : '')).join(''))
  63. return next()
  64. })
  65. send(agent, 'hello')
  66. await waitForIdle(ctx, agent)
  67. expect(seen).toEqual(['hello'])
  68. const userMsg = events(agent).find(e => e.type === 'user/message')
  69. expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }])
  70. })
  71. it('publishes frozen input without replacing its identity', async () => {
  72. const adapter = new MockAdapter([textResponse('ok')])
  73. const ctx = await harness(adapter)
  74. const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' })
  75. const entered = Promise.withResolvers<undefined>()
  76. const decision = Promise.withResolvers<PromptDecision>()
  77. const observed: UserMessage[] = []
  78. ctx.on('agent/inbox/enqueue', (subject, message) => {
  79. if (subject !== agent) return
  80. expect(Object.isFrozen(message)).toBe(true)
  81. expect(Object.isFrozen(message.content)).toBe(true)
  82. expect(Object.isFrozen(message.content[0])).toBe(true)
  83. expect(Object.isFrozen(message.source)).toBe(true)
  84. expect(() => {
  85. const block = message.content[0]
  86. if (block?.type === 'text') block.text = 'listener mutation'
  87. }).toThrow()
  88. })
  89. ctx.on('agent/inbox/enqueue', (subject, message) => {
  90. if (subject === agent) observed.push(message)
  91. })
  92. ctx.on('agent/prompt-submit', async () => {
  93. entered.resolve(undefined)
  94. return decision.promise
  95. })
  96. const input: UserMessage = createUserMessage({
  97. content: [{ type: 'text', text: 'accepted text' }],
  98. source: { kind: 'plugin', plugin: 'accepted source' },
  99. })
  100. const idle = waitForIdle(ctx, agent)
  101. agent.followup(input)
  102. await entered.promise
  103. const block = input.content[0]
  104. expect(() => {
  105. if (block?.type === 'text') block.text = 'caller mutation'
  106. }).toThrow(TypeError)
  107. expect(() => {
  108. if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation'
  109. }).toThrow(TypeError)
  110. decision.resolve({ kind: 'allow' })
  111. await idle
  112. expect(observed).toHaveLength(1)
  113. expect(observed[0]).toBe(input)
  114. expect(observed[0]).toMatchObject({
  115. content: [{ type: 'text', text: 'accepted text' }],
  116. source: { kind: 'plugin', plugin: 'accepted source' },
  117. })
  118. const userMsg = events(agent).find(event => event.type === 'user/message')
  119. expect(userMsg?.type === 'user/message' && userMsg.data).toEqual(input)
  120. })
  121. it('allow with content REWRITES the prompt before it is recorded', async () => {
  122. const adapter = new MockAdapter([textResponse('ok')])
  123. const ctx = await harness(adapter)
  124. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  125. ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
  126. ({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
  127. send(agent, 'original')
  128. await waitForIdle(ctx, agent)
  129. const userMsg = events(agent).find(e => e.type === 'user/message')
  130. expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'REWRITTEN' }])
  131. // the rewritten prompt is what reached the model
  132. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('REWRITTEN')
  133. expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
  134. })
  135. it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => {
  136. const adapter = new MockAdapter([textResponse('ok')])
  137. const ctx = await harness(adapter)
  138. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  139. ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
  140. ({
  141. kind: 'allow',
  142. additionalContexts: [createUserMessage({
  143. content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
  144. source: { kind: 'plugin', plugin: 'test' },
  145. })],
  146. }))
  147. send(agent, 'go')
  148. await waitForIdle(ctx, agent)
  149. const log = events(agent)
  150. const userMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'user')
  151. const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
  152. expect(userMsg).toBeDefined()
  153. expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
  154. expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
  155. const sent = JSON.stringify(adapter.requests[0]!.messages)
  156. expect(sent).toContain('extra ctx')
  157. })
  158. it('runs pre-step after prompt rewrites and injected context become durable', async () => {
  159. const adapter = new MockAdapter([textResponse('ok')])
  160. const ctx = await harness(adapter)
  161. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  162. ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
  163. ({
  164. kind: 'allow',
  165. content: [{ type: 'text', text: 'REWRITTEN prompt' }],
  166. additionalContexts: [createUserMessage({
  167. content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' },
  168. })],
  169. }))
  170. let preStepDerived: string | undefined
  171. ctx.on('agent/step', (subject, _turn, step) => {
  172. if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
  173. })
  174. send(agent, 'ORIGINAL prompt')
  175. await waitForIdle(ctx, agent)
  176. expect(preStepDerived).toBeDefined()
  177. expect(preStepDerived).toContain('REWRITTEN prompt')
  178. expect(preStepDerived).toContain('injected ctx')
  179. expect(preStepDerived).not.toContain('ORIGINAL prompt')
  180. })
  181. it('block drops the claimed prompt before any turn or model call', async () => {
  182. const adapter = new MockAdapter([textResponse('should not run')])
  183. const ctx = await harness(adapter)
  184. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  185. ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
  186. ({ kind: 'block', reason: 'blocked by policy' }))
  187. const reasons: TurnEndReason[] = []
  188. ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  189. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } }))
  190. await agent.whenIdle()
  191. // the model was never called
  192. expect(adapter.requests).toHaveLength(0)
  193. const log = events(agent)
  194. expect(log.some(e => e.type === 'turn/start')).toBe(false)
  195. expect(log.some(e => e.type === 'turn/end')).toBe(false)
  196. expect(log.some(e => e.type === 'user/message')).toBe(false)
  197. expect(log.some(e => e.type === 'step/start')).toBe(false)
  198. expect(reasons).toEqual([])
  199. })
  200. it('stages inject and steer during admission for the admitted turn', async () => {
  201. const adapter = new MockAdapter([textResponse('ok')])
  202. const ctx = await harness(adapter)
  203. const agent = ctx.agentLoop.create(SessionId('admission-outbox'), { provider: 'mock', model: 'mock' })
  204. const entered = Promise.withResolvers<undefined>()
  205. const decision = Promise.withResolvers<PromptDecision>()
  206. const placements: InboxPlacement[] = []
  207. ctx.on('agent/prompt-submit', async () => {
  208. entered.resolve(undefined)
  209. return decision.promise
  210. })
  211. ctx.on('agent/inbox/enqueue', (subject, _message, placement) => {
  212. if (subject === agent) placements.push(placement)
  213. })
  214. const idle = waitForIdle(ctx, agent)
  215. send(agent, 'admitted prompt')
  216. await entered.promise
  217. expect(agent.status).toBe('running')
  218. expect(agent.acceptsNextStep).toBe(true)
  219. expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
  220. agent.inject(createUserMessage({
  221. content: [{ type: 'text', text: 'attached context' }],
  222. source: { kind: 'plugin', plugin: 'test' },
  223. }))
  224. agent.steer(createUserMessage({ content: [{ type: 'text', text: 'admission steering' }], source: { kind: 'user' } }))
  225. expect(events(agent).some(event => event.type === 'user/message')).toBe(false)
  226. expect(placements).toEqual(['queued', 'steering'])
  227. decision.resolve({ kind: 'allow' })
  228. await idle
  229. expect(agent.acceptsNextStep).toBe(false)
  230. const staged = events(agent).filter(event =>
  231. event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
  232. expect(staged.map(event => event.type)).toEqual([
  233. 'turn/start',
  234. 'user/message',
  235. 'user/message',
  236. 'steering/message',
  237. ])
  238. expect(staged[1]?.type === 'user/message' && staged[1].data.content)
  239. .toEqual([{ type: 'text', text: 'admitted prompt' }])
  240. expect(staged[2]?.type === 'user/message' && staged[2].data.content)
  241. .toEqual([{ type: 'text', text: 'attached context' }])
  242. expect(staged[3]?.type === 'steering/message' && staged[3].data.message.content)
  243. .toEqual([{ type: 'text', text: 'admission steering' }])
  244. const request = JSON.stringify(adapter.requests[0]?.messages)
  245. expect(request).toContain('admitted prompt')
  246. expect(request).toContain('attached context')
  247. expect(request).toContain('admission steering')
  248. })
  249. it('keeps admission-time outbox input staged when admission is blocked', async () => {
  250. const adapter = new MockAdapter([textResponse('retried')])
  251. const ctx = await harness(adapter)
  252. const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' })
  253. const entered = Promise.withResolvers<undefined>()
  254. const decision = Promise.withResolvers<PromptDecision>()
  255. const disposeBlock = ctx.on('agent/prompt-submit', async () => {
  256. entered.resolve(undefined)
  257. return decision.promise
  258. })
  259. const blockedIdle = waitForIdle(ctx, agent)
  260. send(agent, 'blocked prompt')
  261. await entered.promise
  262. expect(agent.acceptsNextStep).toBe(true)
  263. agent.inject(createUserMessage({
  264. content: [{ type: 'text', text: 'staged context' }],
  265. source: { kind: 'plugin', plugin: 'test' },
  266. }))
  267. agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } }))
  268. decision.resolve({ kind: 'block', reason: 'policy' })
  269. await blockedIdle
  270. expect(agent.acceptsNextStep).toBe(false)
  271. expect(events(agent)).toEqual([])
  272. expect(adapter.requests).toEqual([])
  273. disposeBlock()
  274. send(agent, 'resume')
  275. await waitForIdle(ctx, agent)
  276. const staged = events(agent).filter(event =>
  277. event.type === 'user/message' || event.type === 'steering/message')
  278. expect(staged.map(event => event.type)).toEqual([
  279. 'user/message',
  280. 'steering/message',
  281. 'user/message',
  282. ])
  283. expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt')
  284. expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged context')
  285. expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')
  286. })
  287. it('orders rejected-admission outbox input before a later admitted prompt', async () => {
  288. const adapter = new MockAdapter([textResponse('continued')])
  289. const ctx = await harness(adapter)
  290. const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), {
  291. provider: 'mock',
  292. model: 'mock',
  293. })
  294. ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
  295. const decision = await next()
  296. return message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')
  297. ? { kind: 'block', reason: 'policy' }
  298. : decision
  299. })
  300. ctx.on('agent/prompt-submit', async (subject, message, _signal, next) => {
  301. if (message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) {
  302. subject.inject(createUserMessage({
  303. content: [{ type: 'text', text: 'earlier state change' }],
  304. source: { kind: 'plugin', plugin: 'test' },
  305. }))
  306. subject.steer(createUserMessage({
  307. content: [{ type: 'text', text: 'earlier steering' }],
  308. source: { kind: 'user' },
  309. }))
  310. }
  311. return next()
  312. })
  313. const idle = waitForIdle(ctx, agent)
  314. send(agent, 'blocked prompt')
  315. send(agent, 'later prompt')
  316. await idle
  317. const staged = events(agent).filter(event =>
  318. event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
  319. expect(staged.map(event => event.type)).toEqual([
  320. 'turn/start',
  321. 'user/message',
  322. 'steering/message',
  323. 'user/message',
  324. ])
  325. expect(staged[1]?.type === 'user/message' && staged[1].data.content)
  326. .toEqual([{ type: 'text', text: 'earlier state change' }])
  327. expect(staged[2]?.type === 'steering/message' && staged[2].data.message.content)
  328. .toEqual([{ type: 'text', text: 'earlier steering' }])
  329. expect(staged[3]?.type === 'user/message' && staged[3].data.content)
  330. .toEqual([{ type: 'text', text: 'later prompt' }])
  331. })
  332. it('commits context-only injection when admission closes without a turn', async () => {
  333. const adapter = new MockAdapter([])
  334. const ctx = await harness(adapter)
  335. const agent = ctx.agentLoop.create(SessionId('blocked-admission-context'), { provider: 'mock', model: 'mock' })
  336. const entered = Promise.withResolvers<undefined>()
  337. const decision = Promise.withResolvers<PromptDecision>()
  338. ctx.on('agent/prompt-submit', async () => {
  339. entered.resolve(undefined)
  340. return decision.promise
  341. })
  342. const idle = waitForIdle(ctx, agent)
  343. send(agent, 'blocked prompt')
  344. await entered.promise
  345. agent.inject(createUserMessage({
  346. content: [{ type: 'text', text: 'independent context' }],
  347. source: { kind: 'plugin', plugin: 'test' },
  348. }))
  349. decision.resolve({ kind: 'block', reason: 'policy' })
  350. await idle
  351. const log = events(agent)
  352. expect(log.map(event => event.type)).toEqual(['user/message'])
  353. expect(log[0]?.type === 'user/message' && log[0].data.content)
  354. .toEqual([{ type: 'text', text: 'independent context' }])
  355. expect(adapter.requests).toEqual([])
  356. })
  357. it('retains rejected-admission context when its idle append fails', async () => {
  358. const adapter = new MockAdapter([textResponse('retried')])
  359. const ctx = await harness(adapter)
  360. const agent = ctx.agentLoop.create(SessionId('blocked-admission-append-failure'), {
  361. provider: 'mock',
  362. model: 'mock',
  363. })
  364. const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  365. vi.spyOn(agent.session, 'append').mockImplementationOnce(() => {
  366. throw new Error('append unavailable')
  367. })
  368. const entered = Promise.withResolvers<undefined>()
  369. const decision = Promise.withResolvers<PromptDecision>()
  370. const disposeBlock = ctx.on('agent/prompt-submit', async () => {
  371. entered.resolve(undefined)
  372. return decision.promise
  373. })
  374. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' } }))
  375. await entered.promise
  376. agent.inject(createUserMessage({
  377. content: [{ type: 'text', text: 'retained context' }],
  378. source: { kind: 'plugin', plugin: 'test' },
  379. }))
  380. decision.resolve({ kind: 'block', reason: 'policy' })
  381. await agent.whenIdle()
  382. expect(events(agent)).toEqual([])
  383. expect(warned).toHaveBeenCalledWith(expect.stringContaining('append unavailable'))
  384. disposeBlock()
  385. send(agent, 'resume')
  386. await waitForIdle(ctx, agent)
  387. expect(events(agent).some(event => event.type === 'user/message'
  388. && JSON.stringify(event.data.content).includes('retained context'))).toBe(true)
  389. })
  390. it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
  391. const adapter = new MockAdapter([textResponse('ran once')])
  392. const ctx = await harness(adapter)
  393. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  394. ctx.on('agent/prompt-submit', async (_agent, message, _signal, next): Promise<PromptDecision> => {
  395. const text = message.content.map(b => (b.type === 'text' ? b.text : '')).join('')
  396. return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
  397. })
  398. const reasons: TurnEndReason[] = []
  399. ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  400. // The rejected admission is dropped; the allowed prompt owns the only turn.
  401. send(agent, 'secret')
  402. send(agent, 'safe')
  403. await waitForIdle(ctx, agent)
  404. const log = events(agent)
  405. // The allowed prompt became a user/message and drove exactly one model call.
  406. const userMsgs = log.filter(e => e.type === 'user/message')
  407. expect(userMsgs).toHaveLength(1)
  408. expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
  409. expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
  410. expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
  411. expect(reasons).toEqual([{ kind: 'completed' }])
  412. })
  413. it('a throwing prompt-submit listener drops that admission while an adjacent message survives', async () => {
  414. const adapter = new MockAdapter([textResponse('after')])
  415. const ctx = await harness(adapter)
  416. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  417. let threw = false
  418. ctx.on('agent/prompt-submit', async () => {
  419. if (!threw) { threw = true; throw new Error('prompt hook broke') }
  420. return { kind: 'allow' as const }
  421. })
  422. const errors: Error[] = []
  423. const reasons: TurnEndReason[] = []
  424. const statuses: string[] = []
  425. ctx.on('agent/error', (_a, _t, _s, error) => {
  426. if (error instanceof Error) errors.push(error)
  427. })
  428. ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) })
  429. ctx.on('session/event', (session, event) => {
  430. if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
  431. })
  432. const idle = waitForIdle(ctx, agent)
  433. send(agent, 'first')
  434. send(agent, 'second')
  435. await idle
  436. expect(errors).toEqual([])
  437. const log = events(agent)
  438. expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
  439. expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
  440. expect(reasons).toEqual([{ kind: 'completed' }])
  441. expect(statuses).toEqual(['running', 'idle'])
  442. expect(adapter.requests).toHaveLength(1)
  443. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
  444. })
  445. })
  446. describe('agent/session-start', () => {
  447. it('fires once with source "startup" for a fresh create, before the first turn', async () => {
  448. const adapter = new MockAdapter([textResponse('ok')])
  449. const ctx = await harness(adapter)
  450. const sources: SessionStartSource[] = []
  451. ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
  452. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  453. // fires synchronously at create, before any turn
  454. expect(sources).toEqual(['startup'])
  455. expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
  456. send(agent, 'go')
  457. await waitForIdle(ctx, agent)
  458. // still only one session-start
  459. expect(sources).toEqual(['startup'])
  460. })
  461. it('a session-start listener can inject context the first request sees', async () => {
  462. const adapter = new MockAdapter([textResponse('ok')])
  463. const ctx = await harness(adapter)
  464. ctx.on('agent/session-start', (agent) => {
  465. agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } }))
  466. })
  467. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  468. send(agent, 'go')
  469. await waitForIdle(ctx, agent)
  470. // the injected context reached the model on the first (only) request
  471. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
  472. // and is recorded with the plugin source, never mislabeled as a user prompt
  473. const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
  474. expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
  475. })
  476. it('a throwing session-start listener does not abort agent construction', async () => {
  477. const adapter = new MockAdapter([textResponse('ok')])
  478. const ctx = await harness(adapter)
  479. ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
  480. // create must not throw — the listener error is contained/logged
  481. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  482. expect(agent.id).toBe(SessionId('a1'))
  483. // and the agent still runs
  484. send(agent, 'go')
  485. await waitForIdle(ctx, agent)
  486. expect(adapter.requests).toHaveLength(1)
  487. })
  488. })
  489. describe('tool additionalContexts buffering across a step', () => {
  490. it('appends each call\'s contexts only AFTER all tool/results, preserving adjacency', async () => {
  491. // One assistant step with TWO tool calls; the second model response stops.
  492. const twoCalls = [
  493. { type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
  494. { type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'echo', arguments: '{"text":"a"}' } },
  495. { type: 'block-start' as const, index: 1, blockType: 'tool-call' as const },
  496. { type: 'block-end' as const, index: 1, block: { type: 'tool-call' as const, id: CallId('c2'), name: 'echo', arguments: '{"text":"b"}' } },
  497. { type: 'usage' as const, usage: { inputTokens: 5, outputTokens: 5 } },
  498. { type: 'finish' as const, reason: { kind: 'tool-calls' as const } },
  499. ]
  500. const adapter = new MockAdapter([twoCalls, textResponse('done')])
  501. const ctx = await harness(adapter)
  502. ctx.tools.register(defineContentToolFixture({
  503. name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
  504. async execute(args) { return [{ type: 'text', text: String(args.text) }] },
  505. }))
  506. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  507. // Each call attaches one context naming itself.
  508. ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
  509. ({
  510. kind: 'accept',
  511. additionalContexts: [createUserMessage({
  512. content: [{ type: 'text', text: `ctx-${exec.callId}` }],
  513. source: { kind: 'plugin', plugin: 'p' },
  514. })],
  515. }))
  516. send(agent, 'go')
  517. await waitForIdle(ctx, agent)
  518. // Event order in the log: both tool/results, THEN both injected contexts —
  519. // never interleaved (which would break tool-call/result adjacency).
  520. const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
  521. const seqs = events(agent)
  522. const firstResult = seqs.findIndex(e => e.type === 'tool/result')
  523. const lastResult = seqs.map(e => e.type).lastIndexOf('tool/result')
  524. const firstCtx = seqs.findIndex(e => e === injected[0])
  525. expect(firstResult).toBeGreaterThanOrEqual(0)
  526. expect(lastResult).toBeGreaterThan(firstResult) // two results
  527. expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results
  528. // both contexts present
  529. const ctxTexts = injected
  530. .flatMap(e => (e.type === 'user/message' ? e.data.content : []))
  531. .map(b => (b.type === 'text' ? b.text : ''))
  532. expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
  533. })
  534. it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
  535. const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')])
  536. const ctx = await harness(adapter)
  537. ctx.tools.register(defineContentToolFixture({
  538. name: 'composite', description: 'composite', parameters: {},
  539. async execute(_args, exec) {
  540. exec.deferContext(createUserMessage({
  541. content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' },
  542. }))
  543. exec.deferContext(createUserMessage({
  544. content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' },
  545. }))
  546. return [{ type: 'text', text: 'outer result' }]
  547. },
  548. }))
  549. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  550. send(agent, 'go')
  551. await waitForIdle(ctx, agent)
  552. const log = events(agent)
  553. const resultIndex = log.findIndex(event => event.type === 'tool/result')
  554. const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
  555. expect(resultIndex).toBeGreaterThanOrEqual(0)
  556. expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex)
  557. expect(contextEvents.map(event => event.type === 'user/message' && event.data.source)).toEqual([
  558. { kind: 'plugin', plugin: 'a' },
  559. { kind: 'plugin', plugin: 'b' },
  560. ])
  561. })
  562. })
  563. describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end through the loop)', () => {
  564. it('deny short-circuits dispatch into an isError result the model sees', async () => {
  565. const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')])
  566. const ctx = await harness(adapter)
  567. let ran = false
  568. ctx.tools.register(defineContentToolFixture({
  569. name: 'danger', description: 'danger', parameters: {},
  570. async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
  571. }))
  572. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  573. ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
  574. if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' }
  575. return next()
  576. })
  577. send(agent, 'go')
  578. await waitForIdle(ctx, agent)
  579. expect(ran).toBe(false)
  580. const result = events(agent).find(e => e.type === 'tool/result')
  581. expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
  582. expect(result?.type === 'tool/result'
  583. && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true)
  584. })
  585. })
  586. describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => {
  587. // The whole point of the interception taxonomy: a "native hook" needs no dsh-hook-protocol,
  588. // no external command, no hook/* log — it is an ordinary cordis plugin subscribing to the
  589. // canonical events and returning typed decisions.
  590. const NativeGuard = {
  591. name: 'native-guard',
  592. apply(ctx: Context) {
  593. // 1. SessionStart: seed a standing instruction.
  594. ctx.on('agent/session-start', (agent, source) => {
  595. agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } }))
  596. })
  597. // 2. PromptSubmit: block a forbidden prompt, annotate the rest.
  598. ctx.on('agent/prompt-submit', async (_agent, message, _signal, next): Promise<PromptDecision> => {
  599. const text = message.content.map(b => (b.type === 'text' ? b.text : '')).join('')
  600. if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
  601. return next()
  602. })
  603. // 3. PreToolUse: deny a dangerous tool by name.
  604. ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
  605. if (exec.name === 'danger') return { kind: 'deny', reason: 'danger tool denied' }
  606. return next()
  607. })
  608. // 4. PostToolUse: attach context after a tool runs.
  609. ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
  610. const decision = await next()
  611. if (decision.kind === 'accept') {
  612. return { kind: 'accept', additionalContexts: [createUserMessage({
  613. content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' },
  614. })] }
  615. }
  616. return decision
  617. })
  618. },
  619. }
  620. it('all four seams fire for a real allowed turn with a tool call', async () => {
  621. const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')])
  622. const ctx = await harness(adapter)
  623. await ctx.plugin(NativeGuard)
  624. ctx.tools.register(defineContentToolFixture({
  625. name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
  626. async execute(args) { return [{ type: 'text', text: String(args.text) }] },
  627. }))
  628. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  629. send(agent, 'please echo hi')
  630. await waitForIdle(ctx, agent)
  631. const log = events(agent)
  632. // session-start preamble injected
  633. expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
  634. && e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
  635. // prompt allowed → user-sourced user/message recorded
  636. expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true)
  637. // tool ran (echo allowed) and post-execute attached "audited" context
  638. expect(log.some(e => e.type === 'tool/result' && !e.data.message.content[0].isError)).toBe(true)
  639. expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
  640. && e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
  641. // NO hook/* events — a native plugin needs none
  642. expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
  643. })
  644. it('the same plugin blocks a destructive prompt before a turn or model call', async () => {
  645. const adapter = new MockAdapter([textResponse('should not run')])
  646. const ctx = await harness(adapter)
  647. await ctx.plugin(NativeGuard)
  648. const agent = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
  649. const reasons: TurnEndReason[] = []
  650. ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  651. send(agent, 'run rm -rf /')
  652. await agent.whenIdle()
  653. expect(adapter.requests).toHaveLength(0)
  654. expect(reasons).toEqual([])
  655. })
  656. it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => {
  657. const adapter = new MockAdapter([textResponse('ok')])
  658. const ctx = await harness(adapter)
  659. const fiber = await ctx.plugin(NativeGuard)
  660. await fiber.dispose()
  661. // After disposal, a destructive prompt is NOT blocked (the listener is gone).
  662. const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' })
  663. send(agent, 'run rm -rf /')
  664. await waitForIdle(ctx, agent)
  665. // the prompt ran (not rejected) — proving the prompt-submit listener was disposed
  666. expect(adapter.requests).toHaveLength(1)
  667. expect(events(agent).some(e => e.type === 'user/message')).toBe(true)
  668. })
  669. })