interception.spec.ts 34 KB

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