interception.spec.ts 35 KB

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