interception.spec.ts 33 KB

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