review-fixes.spec.ts 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  7. import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
  8. import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
  9. import * as Invariants from '@deepseek-ai/dsh-invariants'
  10. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  11. /**
  12. * Regression tests for the findings of the first architecture review
  13. * (Codex + sub-agent, post phase-1). Each describe block names the finding.
  14. */
  15. async function harness(adapter: MockAdapter) {
  16. const ctx = new Context()
  17. await ctx.plugin(LlmService)
  18. await ctx.plugin(SessionStore)
  19. await ctx.plugin(SystemPrompt)
  20. await ctx.plugin(ToolRegistry)
  21. await ctx.plugin(AgentRegistry)
  22. await ctx.plugin(AgentLoop, { agents: [] })
  23. ctx.llm.registerAdapter(['mock'], adapter)
  24. return ctx
  25. }
  26. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  27. return new Promise((resolve) => {
  28. const dispose = ctx.on('agent/status', (subject, status) => {
  29. if (subject === agent && status === 'idle') {
  30. dispose()
  31. resolve()
  32. }
  33. })
  34. })
  35. }
  36. function send(agent: ReactLoopAgent, text: string) {
  37. agent.send([{ type: 'text', text }])
  38. }
  39. describe('HIGH: session log records what agent/step-result actually produced', () => {
  40. it('a step-result rewrite is what the log, derived history, and tool dispatch all see', async () => {
  41. const adapter = new MockAdapter([textResponse('original'), textResponse('done')])
  42. const ctx = await harness(adapter)
  43. const executed: string[] = []
  44. ctx.tools.register(defineTool({
  45. name: 'injected-tool',
  46. description: '',
  47. parameters: {},
  48. async execute() {
  49. executed.push('injected-tool')
  50. return [{ type: 'text', text: 'ran' }]
  51. },
  52. }))
  53. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  54. // Plugin rewrites the message: replaces the text AND adds a tool call.
  55. let rewritten = false
  56. ctx.on('agent/step-result', async (_agent, _turn, _step, _message, next) => {
  57. if (rewritten) return next()
  58. rewritten = true
  59. return {
  60. role: 'assistant' as const,
  61. content: [
  62. { type: 'text' as const, text: 'rewritten' },
  63. { type: 'tool-call' as const, id: CallId('c-injected'), name: 'injected-tool', arguments: '{}' },
  64. ],
  65. }
  66. })
  67. send(agent, 'go')
  68. await waitForIdle(ctx, agent)
  69. // the injected tool call was dispatched…
  70. expect(executed).toEqual(['injected-tool'])
  71. // …and the session log recorded the REWRITTEN message, not the original
  72. const recorded = agent.session.events.find(e => e.type === 'assistant/message')!
  73. expect(JSON.stringify(recorded.data)).toContain('rewritten')
  74. expect(JSON.stringify(recorded.data)).not.toContain('original')
  75. // tool/call + tool/result correlate with the injected call id
  76. const callEvent = agent.session.events.find(e => e.type === 'tool/call')!
  77. if (callEvent.type !== 'tool/call') throw new Error('wrong event type')
  78. expect(callEvent.data.callId).toBe('c-injected')
  79. // derived history shows the rewritten message (replay-correct)
  80. const derived = agent.session.deriveMessages()
  81. expect(JSON.stringify(derived)).toContain('rewritten')
  82. expect(JSON.stringify(derived)).not.toContain('original')
  83. })
  84. })
  85. describe('HIGH: abort during tool execution ends the turn', () => {
  86. it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => {
  87. const adapter = new MockAdapter([
  88. // model asks for two tool calls in one step
  89. [
  90. { type: 'block-start', index: 0, blockType: 'tool-call' },
  91. { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'aborter', arguments: '{}' } },
  92. { type: 'block-start', index: 1, blockType: 'tool-call' },
  93. { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'second', arguments: '{}' } },
  94. { type: 'finish', reason: { kind: 'tool-calls' } },
  95. ] satisfies StreamChunk[],
  96. textResponse('should never be requested'),
  97. ])
  98. const ctx = await harness(adapter)
  99. const executed: string[] = []
  100. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  101. ctx.tools.register(defineTool({
  102. name: 'aborter',
  103. description: '',
  104. parameters: {},
  105. async execute() {
  106. executed.push('aborter')
  107. // Fire the in-flight step's AbortController directly (the loop registers
  108. // it on the agent). This is the bare step-abort path — distinct from
  109. // cancel(), which would also clear the inbox; here the subject is the
  110. // loop's response to its running step being aborted mid-tool.
  111. ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
  112. return [{ type: 'text', text: 'done' }]
  113. },
  114. }))
  115. ctx.tools.register(defineTool({
  116. name: 'second',
  117. description: '',
  118. parameters: {},
  119. async execute() {
  120. executed.push('second')
  121. return [{ type: 'text', text: 'done' }]
  122. },
  123. }))
  124. const reasons: TurnEndReason[] = []
  125. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  126. send(agent, 'go')
  127. await waitForIdle(ctx, agent)
  128. expect(executed).toEqual(['aborter']) // second tool never ran
  129. expect(adapter.requests).toHaveLength(1) // no follow-up model call
  130. expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
  131. })
  132. })
  133. describe('HIGH: steering from late extension points is never stranded', () => {
  134. it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => {
  135. const adapter = new MockAdapter([
  136. textResponse('no tools, would stop here'),
  137. textResponse('continued because of steering'),
  138. ])
  139. const ctx = await harness(adapter)
  140. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  141. let steeredOnce = false
  142. ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => {
  143. if (!steeredOnce) {
  144. steeredOnce = true
  145. agent.steer([{ type: 'text', text: 'one more thing' }])
  146. }
  147. return next()
  148. })
  149. send(agent, 'go')
  150. await waitForIdle(ctx, agent)
  151. // the default decision was false (no tools), but steering forced step 2
  152. expect(adapter.requests).toHaveLength(2)
  153. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
  154. })
  155. it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
  156. // The /goal pattern steers from a step boundary so the model addresses a
  157. // standing goal before stopping. Step boundaries have no agent/* mirror, so
  158. // the surviving hook point is the durable step/end session event. With a
  159. // no-tools first step the default continuation is stop; the steering queued
  160. // here must force the `!shouldContinue && hasSteering` override so the SAME
  161. // turn runs another step.
  162. //
  163. // The override is what this test guards, so it asserts the same-turn shape —
  164. // NOT merely that the content reaches requests[1]. Without the override the
  165. // turn would stop, and leftover steering is re-enqueued as a next-turn queued
  166. // message, which ALSO lands in requests[1] (just one turn later). So a
  167. // content-only assertion passes with the override disabled and guards
  168. // nothing. The discriminator is the turn/step shape: override ⇒ ONE turn with
  169. // TWO steps and the steering recorded as a `steering/message` BEFORE step 2;
  170. // re-enqueue fallback ⇒ TWO turns.
  171. const adapter = new MockAdapter([
  172. textResponse('no tools, would stop'),
  173. textResponse('after goal reminder'),
  174. ])
  175. const ctx = await harness(adapter)
  176. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  177. let steeredOnce = false
  178. ctx.on('session/event', (subject, event) => {
  179. if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return
  180. steeredOnce = true
  181. agent.steer([{ type: 'text', text: 'goal reminder from step/end' }])
  182. })
  183. send(agent, 'go')
  184. await waitForIdle(ctx, agent)
  185. // Same-turn continuation: the steering forced step 2 within turn 1.
  186. const events = [...agent.session.events]
  187. expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1)
  188. expect(events.filter(e => e.type === 'step/start')).toHaveLength(2)
  189. // The steered content is recorded as steering (same turn), BEFORE step 2 —
  190. // not as a fresh turn's user/message. This is the mechanism the override uses.
  191. const steeringIdx = events.findIndex(e => e.type === 'steering/message')
  192. const step2Idx = events.map(e => e.type).lastIndexOf('step/start')
  193. expect(steeringIdx).toBeGreaterThanOrEqual(0)
  194. expect(steeringIdx).toBeLessThan(step2Idx)
  195. // and it reached the next model request.
  196. expect(adapter.requests).toHaveLength(2)
  197. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end')
  198. })
  199. it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => {
  200. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
  201. const ctx = await harness(adapter)
  202. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  203. const turns: number[] = []
  204. let steeredOnce = false
  205. ctx.on('session/event', (subject, event) => {
  206. if (subject !== agent.session) return
  207. if (event.type === 'turn/start') turns.push(event.data.turn)
  208. if (event.type === 'turn/end' && !steeredOnce) {
  209. steeredOnce = true
  210. agent.steer([{ type: 'text', text: 'too late for this turn' }])
  211. }
  212. })
  213. send(agent, 'go')
  214. await waitForIdle(ctx, agent)
  215. // the loop chains directly into turn 2 (status never returns to idle in
  216. // between), so the first idle transition means both turns are complete
  217. expect(turns).toEqual([1, 2])
  218. expect(adapter.requests).toHaveLength(2)
  219. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn')
  220. })
  221. it('steering queued during an aborted step is re-delivered, not silently consumed', async () => {
  222. const adapter = new MockAdapter(['hang', textResponse('recovered')])
  223. const ctx = await harness(adapter)
  224. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  225. send(agent, 'go')
  226. await new Promise(r => setTimeout(r, 30))
  227. agent.steer([{ type: 'text', text: 'redirect' }])
  228. // Abort ONLY the in-flight step, via its AbortController directly — NOT
  229. // cancel(), which clears the inbox and would drop the queued steering this
  230. // test proves survives a step abort. There is no public step-only abort
  231. // verb (cancel() is the only public stop primitive), so reach the private
  232. // controller the loop registered.
  233. ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
  234. await waitForIdle(ctx, agent)
  235. // a new turn ran with the steering content delivered as a message
  236. expect(adapter.requests).toHaveLength(2)
  237. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('redirect')
  238. })
  239. })
  240. describe('HIGH: plugin exceptions are contained', () => {
  241. it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => {
  242. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  243. const ctx = await harness(adapter)
  244. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  245. let threwOnce = false
  246. ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => {
  247. if (!threwOnce) {
  248. threwOnce = true
  249. throw new Error('broken continuation plugin')
  250. }
  251. return { action: 'stop' }
  252. })
  253. const errors: Error[] = []
  254. ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
  255. send(agent, 'first')
  256. await waitForIdle(ctx, agent)
  257. expect(errors.map(e => e.message)).toEqual(['broken continuation plugin'])
  258. // the loop is still alive: a second send works normally
  259. send(agent, 'second')
  260. await waitForIdle(ctx, agent)
  261. expect(adapter.requests).toHaveLength(2)
  262. expect(agent.status).toBe('idle')
  263. })
  264. it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
  265. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  266. const ctx = await harness(adapter)
  267. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  268. let rejectedOnce = false
  269. ctx.on('session/flush', async () => {
  270. if (!rejectedOnce) {
  271. rejectedOnce = true
  272. throw new Error('disk full')
  273. }
  274. })
  275. const errors: Error[] = []
  276. ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
  277. send(agent, 'first')
  278. await waitForIdle(ctx, agent)
  279. expect(errors.map(e => e.message)).toEqual(['disk full'])
  280. send(agent, 'second')
  281. await waitForIdle(ctx, agent)
  282. expect(adapter.requests).toHaveLength(2)
  283. })
  284. })
  285. describe('MEDIUM: disposed status is part of the agent/status contract', () => {
  286. it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => {
  287. const adapter = new MockAdapter(['hang'])
  288. const ctx = await harness(adapter)
  289. let agent!: ReactLoopAgent
  290. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  291. agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
  292. }, { inject: ['agentLoop'] }))
  293. const statuses: string[] = []
  294. const reasons: TurnEndReason[] = []
  295. ctx.on('agent/status', (_agent, status) => void statuses.push(status))
  296. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  297. send(agent, 'go')
  298. await new Promise(r => setTimeout(r, 30))
  299. await fiber.dispose()
  300. await agent.done
  301. expect(statuses).toEqual(['running', 'disposed'])
  302. expect(reasons).toEqual([{ kind: 'disposed' }])
  303. })
  304. it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => {
  305. const adapter = new MockAdapter(['hang'])
  306. const ctx = await harness(adapter)
  307. let agent!: ReactLoopAgent
  308. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  309. agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
  310. }, { inject: ['agentLoop'] }))
  311. ctx.on('agent/status', (_agent, status) => {
  312. if (status === 'disposed') throw new Error('broken status listener')
  313. })
  314. send(agent, 'go')
  315. await new Promise(r => setTimeout(r, 30))
  316. await fiber.dispose()
  317. await agent.done // must not hang
  318. expect(agent.status).toBe('disposed')
  319. expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw
  320. })
  321. })
  322. describe('MEDIUM: misc registry and config fixes', () => {
  323. it('duplicate adapter registration is rejected', async () => {
  324. const ctx = new Context()
  325. await ctx.plugin(LlmService)
  326. const adapter = new MockAdapter([])
  327. ctx.llm.registerAdapter(['m1'], adapter)
  328. expect(() => ctx.llm.registerAdapter(['m1'], new MockAdapter([])))
  329. .toThrow('already registered')
  330. // the original registration survives the failed attempt
  331. expect(ctx.llm.models()).toEqual(['m1'])
  332. })
  333. it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => {
  334. const adapter = new MockAdapter([textResponse('never')])
  335. const ctx = await harness(adapter)
  336. const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model
  337. const errors: Error[] = []
  338. ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
  339. send(agent, 'go')
  340. await waitForIdle(ctx, agent)
  341. expect(errors).toHaveLength(1)
  342. expect(errors[0]!.message).toContain('has no model')
  343. expect(errors[0]!.message).toContain('agent/request')
  344. })
  345. it('the agent/request waterfall can supply the model for a model-less agent', async () => {
  346. const adapter = new MockAdapter([textResponse('routed')])
  347. const ctx = await harness(adapter)
  348. const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides
  349. ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
  350. options.model = 'mock'
  351. return next()
  352. })
  353. send(agent, 'go')
  354. await waitForIdle(ctx, agent)
  355. expect(adapter.requests).toHaveLength(1)
  356. expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
  357. })
  358. it('agent/queued carries the resolved source; agent/steering carries its source', async () => {
  359. const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
  360. const ctx = await harness(adapter)
  361. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  362. ctx.tools.register(defineTool({
  363. name: 'noop',
  364. description: '',
  365. parameters: {},
  366. async execute() {
  367. agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'goal' } })
  368. return []
  369. },
  370. }))
  371. const queuedSources: { source: MessageSource; steering: boolean }[] = []
  372. const steeringSources: MessageSource[] = []
  373. ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
  374. ctx.on('agent/steering', (_agent, _turn, _content, source) => void steeringSources.push(source))
  375. send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
  376. await waitForIdle(ctx, agent)
  377. expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false })
  378. expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true })
  379. expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
  380. })
  381. })
  382. describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => {
  383. it('a forked agent continues turn numbers after the seed log', async () => {
  384. const first = new MockAdapter([textResponse('turn one')])
  385. const ctx = await harness(first)
  386. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  387. send(agent, 'first')
  388. await waitForIdle(ctx, agent)
  389. // fork: seed a second context's agent with the first session's log
  390. const second = new MockAdapter([textResponse('turn two')])
  391. const ctx2 = new Context()
  392. await ctx2.plugin(LlmService)
  393. await ctx2.plugin(SessionStore)
  394. await ctx2.plugin(SystemPrompt)
  395. await ctx2.plugin(ToolRegistry)
  396. await ctx2.plugin(AgentRegistry)
  397. await ctx2.plugin(AgentLoop, { agents: [] })
  398. ctx2.llm.registerAdapter(['mock'], second)
  399. const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
  400. const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
  401. ctx2.effect(() => forked.start())
  402. const turns: number[] = []
  403. ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
  404. forked.send([{ type: 'text', text: 'continue' }])
  405. await new Promise<void>((resolve) => {
  406. ctx2.on('agent/status', (subject, status) => {
  407. if (subject === forked && status === 'idle') resolve()
  408. })
  409. })
  410. expect(turns).toEqual([2])
  411. })
  412. })
  413. describe('LOW: discriminated SessionEvent narrows without casts', () => {
  414. it('narrows event.data from event.type', () => {
  415. const session = new Session(SessionId('s'))
  416. const appended: SessionEvent = session.append('tool/call', {
  417. turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}',
  418. })
  419. // compile-time: this switch narrows; runtime: values flow through
  420. switch (appended.type) {
  421. case 'tool/call': {
  422. expect(appended.data.callId).toBe('c1')
  423. expect(appended.data.name).toBe('echo')
  424. break
  425. }
  426. default: throw new Error('wrong narrow')
  427. }
  428. })
  429. })
  430. describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => {
  431. it('translates finish {kind:error} into a turn error with a logged error event', async () => {
  432. // The second sanctioned adapter error path (besides throwing): an
  433. // adapter that cannot throw mid-stream ends the stream with a
  434. // finish-error chunk (e.g. the pi-ai adapter mapping a provider 401).
  435. // The loop must NOT log a normal assistant/message + completed turn.
  436. const errorStream: StreamChunk[] = [
  437. { type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } },
  438. ]
  439. const adapter = new MockAdapter([errorStream])
  440. const ctx = await harness(adapter)
  441. const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { model: 'mock' })
  442. const reasons: TurnEndReason[] = []
  443. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  444. send(agent, 'go')
  445. await waitForIdle(ctx, agent)
  446. expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }])
  447. const events = [...agent.session.events]
  448. // The durable failure lives on turn/end.reason (with the failing step), not
  449. // a standalone error event.
  450. const turnEnd = events.find(event => event.type === 'turn/end')
  451. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' })
  452. // Crucially: no assistant/message was logged for the failed step.
  453. expect(events.some(event => event.type === 'assistant/message')).toBe(false)
  454. })
  455. it('translates finish {kind:aborted} into a turn error coded ABORTED', async () => {
  456. const abortedStream: StreamChunk[] = [
  457. { type: 'finish', reason: { kind: 'aborted' } },
  458. ]
  459. const adapter = new MockAdapter([abortedStream])
  460. const ctx = await harness(adapter)
  461. const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { model: 'mock' })
  462. const reasons: TurnEndReason[] = []
  463. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  464. send(agent, 'go')
  465. await waitForIdle(ctx, agent)
  466. expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'model stream aborted', code: 'ABORTED' }])
  467. expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false)
  468. })
  469. it('handles a finish error without a code (code key omitted)', async () => {
  470. const errorStream: StreamChunk[] = [
  471. { type: 'finish', reason: { kind: 'error', message: 'codeless failure' } },
  472. ]
  473. const adapter = new MockAdapter([errorStream])
  474. const ctx = await harness(adapter)
  475. const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { model: 'mock' })
  476. const reasons: TurnEndReason[] = []
  477. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  478. send(agent, 'go')
  479. await waitForIdle(ctx, agent)
  480. expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'codeless failure' }])
  481. })
  482. })
  483. describe('P1-6: a step/start session-event listener sees the event already in the log', () => {
  484. it('the step/start event is in session.events when its session/event listener fires', async () => {
  485. const adapter = new MockAdapter([textResponse('done')])
  486. const ctx = await harness(adapter)
  487. const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
  488. // Session.append pushes the event BEFORE notifying session/event listeners,
  489. // so a step/start listener always finds the matching event already in the
  490. // log. (Step boundaries have no agent/* mirror — the session log is the live
  491. // feed.)
  492. const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
  493. ctx.on('session/event', (subject, event) => {
  494. if (subject !== agent.session || event.type !== 'step/start') return
  495. const events = [...subject.events]
  496. const last = events.at(-1)
  497. observed.push({
  498. turn: event.data.turn,
  499. step: event.data.step,
  500. lastEventType: last?.type,
  501. sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === event.data.turn && e.data.step === event.data.step),
  502. })
  503. })
  504. send(agent, 'go')
  505. await waitForIdle(ctx, agent)
  506. expect(observed).toHaveLength(1)
  507. expect(observed[0]).toMatchObject({ turn: 1, step: 1, lastEventType: 'step/start', sawStepStart: true })
  508. })
  509. })
  510. describe('P1-5: a started turn (and any open step) is always closed on a boundary throw', () => {
  511. // Harness with the invariants plugin loaded as an oracle: it throws on
  512. // append if the log goes unbalanced (turn/end while a step is open,
  513. // turn/start while a turn is open, etc.), so a regression surfaces as an
  514. // InvariantError on the NEXT turn's append rather than a silent imbalance.
  515. async function balancedHarness(adapter: MockAdapter) {
  516. const ctx = new Context()
  517. await ctx.plugin(LlmService)
  518. await ctx.plugin(SessionStore)
  519. await ctx.plugin(SystemPrompt)
  520. await ctx.plugin(ToolRegistry)
  521. await ctx.plugin(AgentRegistry)
  522. await ctx.plugin(AgentLoop, { agents: [] })
  523. await ctx.plugin(Invariants, { freeze: false })
  524. ctx.llm.registerAdapter(['mock'], adapter)
  525. return ctx
  526. }
  527. /** Count turn/step boundary events for balance assertions. */
  528. function boundaryCounts(agent: ReactLoopAgent) {
  529. const e = [...agent.session.events]
  530. return {
  531. turnStart: e.filter(x => x.type === 'turn/start').length,
  532. turnEnd: e.filter(x => x.type === 'turn/end').length,
  533. stepStart: e.filter(x => x.type === 'step/start').length,
  534. stepEnd: e.filter(x => x.type === 'step/end').length,
  535. errors: e.filter(x => x.type === 'turn/end' && x.data.reason.kind === 'error').length,
  536. lastTurnEnd: e.findLast(x => x.type === 'turn/end'),
  537. }
  538. }
  539. it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => {
  540. const adapter = new MockAdapter([textResponse('never reached')])
  541. const ctx = await balancedHarness(adapter)
  542. const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
  543. // Step boundaries have no agent/* mirror; a throwing step/start session-event
  544. // listener is the surviving step-boundary-listener failure. The loop marks
  545. // the step open BEFORE appending step/start (Session.append pushes before
  546. // notifying, so a post-push listener throw still leaves stepOpen=true), so
  547. // the outer catch's closeStep() appends the balancing step/end — the turn
  548. // stays enclosed. The invariants oracle (balancedHarness) rejects any
  549. // imbalance, so a green run proves turn/start → step/start → step/end →
  550. // turn/end nesting holds.
  551. let threw = false
  552. ctx.on('session/event', (_s, event) => {
  553. if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
  554. })
  555. const errors: Error[] = []
  556. ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
  557. send(agent, 'go')
  558. await waitForIdle(ctx, agent)
  559. const e = [...agent.session.events]
  560. const c = boundaryCounts(agent)
  561. expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
  562. expect(errors.map(x => x.message)).toEqual(['boom step-start'])
  563. // step/end precedes turn/end (the invariants oracle would reject
  564. // turn/end-while-step-open, but assert the order explicitly too).
  565. const stepEndIdx = e.findIndex(x => x.type === 'step/end')
  566. const turnEndIdx = e.findIndex(x => x.type === 'turn/end')
  567. expect(stepEndIdx).toBeGreaterThanOrEqual(0)
  568. expect(stepEndIdx).toBeLessThan(turnEndIdx)
  569. })
  570. it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
  571. // First turn: model stream ends with a finish-error → step error path →
  572. // failTurn emits agent/error, whose listener throws. The turn must still
  573. // close balanced. Second turn proves the loop survived.
  574. const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
  575. const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
  576. const ctx = await balancedHarness(adapter)
  577. const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { model: 'mock' })
  578. let threw = false
  579. ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } })
  580. send(agent, 'go')
  581. await waitForIdle(ctx, agent)
  582. const c = boundaryCounts(agent)
  583. // turn 1 balanced despite the throwing agent/error listener.
  584. expect(c.turnStart).toBe(1)
  585. expect(c.turnEnd).toBe(1)
  586. expect(c.stepStart).toBe(c.stepEnd)
  587. expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider 500' })
  588. // loop survives: a second turn runs to completion (invariants oracle would
  589. // throw on its turn/start if turn 1 had been left open).
  590. send(agent, 'again')
  591. await waitForIdle(ctx, agent)
  592. const c2 = boundaryCounts(agent)
  593. expect(c2.turnStart).toBe(2)
  594. expect(c2.turnEnd).toBe(2)
  595. expect(c2.stepStart).toBe(c2.stepEnd)
  596. })
  597. it('disposal during a running turn ends the turn with reason disposed (balanced)', async () => {
  598. // The 'hang' adapter blocks in stream() until the signal aborts; disposing
  599. // the agent's fiber mid-turn aborts the in-flight step. The turn must close
  600. // balanced with reason disposed (no error event for a disposal).
  601. const adapter = new MockAdapter(['hang'])
  602. const ctx = await balancedHarness(adapter)
  603. let agent!: ReactLoopAgent
  604. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  605. agent = inner.agentLoop.create(AgentId('a-dispose'), { model: 'mock' })
  606. }, { inject: ['agentLoop'] }))
  607. const reasons: TurnEndReason[] = []
  608. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  609. send(agent, 'go')
  610. await new Promise(r => setTimeout(r, 30))
  611. await fiber.dispose() // dispose during the hanging step
  612. await agent.done
  613. const e = [...agent.session.events]
  614. const turnStarts = e.filter(x => x.type === 'turn/start').length
  615. const turnEnds = e.filter(x => x.type === 'turn/end').length
  616. expect(turnStarts).toBe(1)
  617. expect(turnEnds).toBe(1) // balanced — the turn was closed despite disposal
  618. expect(reasons).toEqual([{ kind: 'disposed' }])
  619. // no error reason: disposal is not a failure.
  620. expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
  621. })
  622. it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
  623. // Reach the OUTER catch while disposed: an `agent/pre-step` listener requests
  624. // disposal AND throws. The throw escapes the pre-step `await` (line ~419) to
  625. // the loop's outer catch — BEFORE the post-pre-step disposal check at ~422
  626. // gets to run — so the catch sees `isDisposed() && !errorReported` and must
  627. // PRESERVE reason=disposed rather than overwrite it with the listener's throw
  628. // (disposal is not a failure). This is the surviving path to that sub-branch
  629. // now that there is no turn-boundary emit to throw from.
  630. const adapter = new MockAdapter([textResponse('never reached')])
  631. const ctx = await balancedHarness(adapter)
  632. let agent!: ReactLoopAgent
  633. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  634. agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { model: 'mock' })
  635. }, { inject: ['agentLoop'] }))
  636. let threw = false
  637. ctx.on('agent/pre-step', () => {
  638. if (threw) return
  639. threw = true
  640. // Request disposal, then throw in the same synchronous tick: status flips
  641. // to 'disposed' (the disposer aborts the step controller) and the throw
  642. // drives control into the outer catch with isDisposed() already true.
  643. void fiber.dispose()
  644. throw new Error('boom pre-step during disposal')
  645. })
  646. const errorEmits: Error[] = []
  647. ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error))
  648. send(agent, 'go')
  649. await agent.done
  650. const e = [...agent.session.events]
  651. // Balanced: one turn/start, one turn/end carrying disposed (NOT error).
  652. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  653. const turnEnd = e.findLast(x => x.type === 'turn/end')
  654. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
  655. expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
  656. // No step opened (the throw was before step/start) and disposal is not a
  657. // failure, so no agent/error for the contained throw.
  658. expect(e.some(x => x.type === 'step/start')).toBe(false)
  659. expect(errorEmits).toHaveLength(0)
  660. })
  661. it('a throwing session/event listener on the turn/start append still balances the turn', async () => {
  662. // Session.append pushes the event BEFORE notifying session/event listeners,
  663. // so a listener throwing on turn/start leaves turn/start IN THE LOG. The
  664. // loop must therefore still owe (and append) a turn/end — deciding "owed"
  665. // from the log via isTurnOpen, not a "turn started" flag that the throw
  666. // skipped. Otherwise the turn stays permanently open and poisons the next
  667. // turn/replay (the turn-enclosure RFC). (Uses the plain harness — NOT the invariants
  668. // oracle — because the throwing listener is itself a session/event
  669. // subscriber.)
  670. const adapter = new MockAdapter([textResponse('turn 2')])
  671. const ctx = await harness(adapter)
  672. const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
  673. let threw = false
  674. ctx.on('session/event', (_session, event) => {
  675. if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') }
  676. })
  677. const errors: Error[] = []
  678. ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
  679. send(agent, 'go')
  680. await waitForIdle(ctx, agent)
  681. // The error was surfaced exactly once via agent/error.
  682. expect(errors.map(e => e.message)).toEqual(['boom turn/start append'])
  683. // The turn is BALANCED: turn/start is in the log (it was pushed before the
  684. // listener threw), so a turn/end was owed and appended — no open turn. The
  685. // last turn-boundary event being turn/end is exactly the loop's isTurnOpen
  686. // check (no open turn remains).
  687. const types = [...agent.session.events].map(e => e.type)
  688. expect(types.filter(t => t === 'turn/start')).toHaveLength(1)
  689. expect(types.filter(t => t === 'turn/end')).toHaveLength(1)
  690. const lastBoundary = [...agent.session.events].reverse().find(e => e.type === 'turn/start' || e.type === 'turn/end')
  691. expect(lastBoundary?.type).toBe('turn/end')
  692. expect(agent.session.events.at(-1)?.type).toBe('turn/end')
  693. // loop survives: a second turn runs normally.
  694. send(agent, 'second')
  695. await waitForIdle(ctx, agent)
  696. expect(adapter.requests).toHaveLength(1)
  697. })
  698. it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => {
  699. // closeStep() must surface a throwing step/end listener via failTurn so the
  700. // turn ends with reason error, not a silent "completed" with the throw
  701. // swallowed. Regression test for the closeStep() catch that previously
  702. // swallowed the throw in the normal (no-tool, no-steering) path. (Step
  703. // boundaries have no agent/* mirror; the session-event listener is the path.)
  704. const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
  705. const ctx = await balancedHarness(adapter)
  706. const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
  707. let threw = false
  708. ctx.on('session/event', (_s, event) => {
  709. if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') }
  710. })
  711. const errors: Error[] = []
  712. ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
  713. send(agent, 'go')
  714. await waitForIdle(ctx, agent)
  715. const c = boundaryCounts(agent)
  716. // step opened and closed; exactly one error turn-end; turn balanced.
  717. expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
  718. expect(errors.map(e => e.message)).toEqual(['boom step-end'])
  719. expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason)
  720. .toEqual({ kind: 'error', step: 1, message: 'boom step-end' })
  721. // step/end precedes turn/end (ordering contract)
  722. const e = [...agent.session.events]
  723. const stepEndIdx = e.findIndex(x => x.type === 'step/end')
  724. const turnEndIdx = e.findIndex(x => x.type === 'turn/end')
  725. expect(stepEndIdx).toBeGreaterThanOrEqual(0)
  726. expect(stepEndIdx).toBeLessThan(turnEndIdx)
  727. // loop survives: a subsequent turn runs to completion
  728. send(agent, 'again')
  729. await waitForIdle(ctx, agent)
  730. const c2 = boundaryCounts(agent)
  731. expect(c2.turnStart).toBe(2)
  732. expect(c2.turnEnd).toBe(2)
  733. expect(c2.stepStart).toBe(c2.stepEnd)
  734. })
  735. it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => {
  736. // A finish-error stream opens a step then fails it, driving finalization
  737. // through closeStep() with the step open. closeStep appends step/end; a
  738. // session/event listener throwing on THAT must not abort the catch before
  739. // closeTurn — step/end is already logged (balance holds) and the throw is
  740. // contained + surfaced via failTurn, so turn/end is still appended. (The
  741. // failed step itself also routes through failTurn; the step/end-listener
  742. // throw is the second, contained, failure.)
  743. const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
  744. const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
  745. const ctx = await harness(adapter)
  746. const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' })
  747. let threw = false
  748. ctx.on('session/event', (_s, event) => {
  749. if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
  750. })
  751. const errors: Error[] = []
  752. ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
  753. send(agent, 'go')
  754. await waitForIdle(ctx, agent)
  755. const e = [...agent.session.events]
  756. // Both step/end and turn/end are present — finalization ran to completion.
  757. expect(e.some(x => x.type === 'step/end')).toBe(true)
  758. expect(e.some(x => x.type === 'turn/end')).toBe(true)
  759. expect(e.at(-1)?.type).toBe('turn/end')
  760. expect(errors.length).toBeGreaterThanOrEqual(1) // surfaced via agent/error
  761. // loop survives.
  762. send(agent, 'again')
  763. await waitForIdle(ctx, agent)
  764. expect(e.filter(x => x.type === 'turn/start').length).toBeGreaterThanOrEqual(1)
  765. })
  766. it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
  767. // closeTurn appends turn/end; Session.append pushes it BEFORE notifying
  768. // session/event listeners, so a throwing listener leaves turn/end in the log
  769. // (the turn is balanced) but must not escape — from the normal-path closeTurn
  770. // it would otherwise propagate; the append is contained so the loop continues.
  771. // Turn boundaries are durable session events only (no agent/* mirror), so this
  772. // session/event append-notify throw is the sole turn-end-listener failure path.
  773. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
  774. const ctx = await harness(adapter)
  775. const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
  776. let threw = false
  777. ctx.on('session/event', (_s, event) => {
  778. if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end listener') }
  779. })
  780. send(agent, 'go')
  781. await waitForIdle(ctx, agent)
  782. // turn 1 is balanced despite the throwing turn/end listener.
  783. const e1 = [...agent.session.events]
  784. expect(e1.filter(x => x.type === 'turn/start')).toHaveLength(1)
  785. expect(e1.filter(x => x.type === 'turn/end')).toHaveLength(1)
  786. expect(e1.at(-1)?.type).toBe('turn/end')
  787. // loop survives: a second turn runs to completion.
  788. send(agent, 'again')
  789. await waitForIdle(ctx, agent)
  790. expect(adapter.requests).toHaveLength(2)
  791. expect([...agent.session.events].filter(x => x.type === 'turn/end')).toHaveLength(2)
  792. })
  793. })
  794. describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => {
  795. it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
  796. // Model emits a tool-call with id "c1", then a final text turn.
  797. const adapter = new MockAdapter([
  798. toolCallResponse('c1', 'echo', { x: 1 }),
  799. textResponse('done'),
  800. ])
  801. const ctx = await harness(adapter)
  802. ctx.tools.register(defineTool({
  803. name: 'echo',
  804. description: 'echo',
  805. parameters: { x: { type: 'number' } },
  806. async execute() { return [{ type: 'text', text: 'ok' }] },
  807. }))
  808. // A post-execute listener transforms the result (accept-with-replacement).
  809. // The loop must still record the tool/result under the model's authoritative
  810. // call.id (the loop ignores result.callId — which the registry always sets to
  811. // exec.callId anyway — and uses call.id, the model-transcript id).
  812. ctx.on('tools/post-execute', (exec, _result) => {
  813. expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
  814. return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
  815. }, { prepend: true })
  816. const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' })
  817. send(agent, 'use tool')
  818. await waitForIdle(ctx, agent)
  819. // The logged tool/result.callId is the originating call.id, NOT the
  820. // listener's wrong id.
  821. const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result')
  822. expect(resultEvent?.type).toBe('tool/result')
  823. if (resultEvent?.type === 'tool/result') {
  824. expect(resultEvent.data.callId).toBe(CallId('c1'))
  825. }
  826. // And deriveMessages pairs the tool-result with the assistant tool-call:
  827. // the derived tool-result block's toolCallId equals the original call.id.
  828. const messages = agent.session.deriveMessages()
  829. const toolResultBlock = messages
  830. .flatMap(m => m.content)
  831. .find(b => b.type === 'tool-result')
  832. expect(toolResultBlock?.type).toBe('tool-result')
  833. if (toolResultBlock?.type === 'tool-result') {
  834. expect(toolResultBlock.toolCallId).toBe(CallId('c1'))
  835. }
  836. })
  837. })
  838. describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => {
  839. it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => {
  840. // An empty stream yields zero assistant/chunk events (finish defaults to
  841. // `stop`), so chunkSeqs is empty. A step-result listener injects content, so
  842. // the content-or-usage guard fires and an assistant/message is appended. Its
  843. // sourceEventSeqs MUST be omitted (not `[]`) — the surface invariant rejects
  844. // an empty sourceEventSeqs, and the dev invariants plugin would throw on it.
  845. const adapter = new MockAdapter([[]])
  846. const ctx = await harness(adapter)
  847. await ctx.plugin(Invariants)
  848. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  849. ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
  850. role: 'assistant' as const,
  851. content: [{ type: 'text' as const, text: 'injected' }],
  852. }))
  853. send(agent, 'go')
  854. await waitForIdle(ctx, agent)
  855. const recorded = agent.session.events.find(e => e.type === 'assistant/message')!
  856. expect(recorded.type).toBe('assistant/message')
  857. expect(recorded.surfaceOp).toBe('append')
  858. expect(recorded.sourceEventSeqs).toBeUndefined()
  859. // The injected content reaches derived history.
  860. expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected')
  861. })
  862. })
  863. describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
  864. it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
  865. // Block `system-prompt/assemble` on a promise. Start disposal (which
  866. // calls stop() synchronously, setting status=disposed), then release the
  867. // block. The loop must check isDisposed() after assembly and end the turn
  868. // `disposed` — no LLM call. Don't await fiber.dispose() before releasing
  869. // the blocker: the dispose chain awaits agent.done, which hangs until the
  870. // loop unblocks.
  871. const adapter = new MockAdapter(['hang'])
  872. let releaseAssemble!: () => void
  873. const blocked = new Promise<void>(r => void (releaseAssemble = r))
  874. const ctx = new Context()
  875. await ctx.plugin(LlmService)
  876. await ctx.plugin(SessionStore)
  877. await ctx.plugin(SystemPrompt)
  878. await ctx.plugin(ToolRegistry)
  879. await ctx.plugin(AgentRegistry)
  880. await ctx.plugin(AgentLoop, { agents: [] })
  881. await ctx.plugin(Invariants, { freeze: false })
  882. ctx.llm.registerAdapter(['mock'], adapter)
  883. // Blocking listener on the parent context (survives fiber disposal).
  884. const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) {
  885. await blocked
  886. return next()
  887. })
  888. let agent!: ReactLoopAgent
  889. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  890. agent = inner.agentLoop.create(AgentId('a-dispose-assemble'), { model: 'mock' })
  891. }, { inject: ['agentLoop'] }))
  892. const reasons: TurnEndReason[] = []
  893. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  894. send(agent, 'go')
  895. // Give the loop time to enter the step and reach assemble().
  896. await new Promise(r => setTimeout(r, 50))
  897. // Start disposal — stop() sets status=disposed synchronously, then the
  898. // disposer's await agent.done hangs because the loop is blocked in the
  899. // waterfall. Do NOT await yet; release the blocker first.
  900. const disposalDone = fiber.dispose()
  901. // Now release the blocked waterfall — the loop unblocks, checks
  902. // isDisposed(), and exits, which resolves agent.done and disposalDone.
  903. releaseAssemble()
  904. await disposalDone
  905. await agent.done
  906. unlisten()
  907. const e = [...agent.session.events]
  908. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  909. expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
  910. const turnEnd = e.findLast(x => x.type === 'turn/end')
  911. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
  912. // No step was opened, no LLM call was made.
  913. expect(e.some(x => x.type === 'step/start')).toBe(false)
  914. expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
  915. // The durable turn/end record is the authoritative turn-boundary signal
  916. // (turn boundaries have no agent/* mirror), so this asserts on the log.
  917. })
  918. it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => {
  919. const adapter = new MockAdapter([textResponse('should not appear')])
  920. let releaseAssemble!: () => void
  921. const blocker = new Promise<void>(r => void (releaseAssemble = r))
  922. const ctx = new Context()
  923. await ctx.plugin(LlmService)
  924. await ctx.plugin(SessionStore)
  925. await ctx.plugin(SystemPrompt)
  926. await ctx.plugin(ToolRegistry)
  927. await ctx.plugin(AgentRegistry)
  928. await ctx.plugin(AgentLoop, { agents: [] })
  929. await ctx.plugin(Invariants, { freeze: false })
  930. ctx.llm.registerAdapter(['mock'], adapter)
  931. const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) {
  932. await blocker
  933. return next()
  934. })
  935. let agent!: ReactLoopAgent
  936. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  937. agent = inner.agentLoop.create(AgentId('a-cancel-assemble'), { model: 'mock' })
  938. }, { inject: ['agentLoop'] }))
  939. const reasons: TurnEndReason[] = []
  940. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  941. send(agent, 'go')
  942. await new Promise(r => setTimeout(r, 50))
  943. agent.cancel('user cancelled during assembly')
  944. releaseAssemble()
  945. await waitForIdle(ctx, agent)
  946. await fiber.dispose()
  947. await agent.done
  948. unlisten()
  949. const e = [...agent.session.events]
  950. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  951. expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
  952. const turnEnd = e.findLast(x => x.type === 'turn/end')
  953. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
  954. kind: 'aborted',
  955. reason: 'user cancelled during assembly',
  956. })
  957. expect(e.some(x => x.type === 'step/start')).toBe(false)
  958. expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
  959. expect(e.some(x => x.type === 'assistant/message')).toBe(false)
  960. expect(adapter.requests).toHaveLength(0)
  961. expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled during assembly' }])
  962. })
  963. it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
  964. // Block the `agent/pre-step` serial seam on a promise we control, then
  965. // dispose the agent's fiber. When the block releases, the loop must see
  966. // isDisposed() at the post-seam check and end the turn disposed.
  967. const adapter = new MockAdapter(['hang'])
  968. let releasePreStep!: () => void
  969. const blocker = new Promise<void>(r => void (releasePreStep = r))
  970. const ctx = new Context()
  971. await ctx.plugin(LlmService)
  972. await ctx.plugin(SessionStore)
  973. await ctx.plugin(SystemPrompt)
  974. await ctx.plugin(ToolRegistry)
  975. await ctx.plugin(AgentRegistry)
  976. await ctx.plugin(AgentLoop, { agents: [] })
  977. await ctx.plugin(Invariants, { freeze: false })
  978. ctx.llm.registerAdapter(['mock'], adapter)
  979. ctx.on('agent/pre-step', async () => {
  980. await blocker
  981. })
  982. let agent!: ReactLoopAgent
  983. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  984. agent = inner.agentLoop.create(AgentId('a-dispose-prestep'), { model: 'mock' })
  985. }, { inject: ['agentLoop'] }))
  986. const reasons: TurnEndReason[] = []
  987. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  988. send(agent, 'go')
  989. await new Promise(r => setTimeout(r, 50))
  990. // Start disposal, then release the block, then await disposal.
  991. const disposalDone = fiber.dispose()
  992. releasePreStep()
  993. await disposalDone
  994. await agent.done
  995. // After the pre-step seam finishes, the post-seam cancel/dispose check
  996. // catches disposal. The step was never opened, no LLM call was made.
  997. const e = [...agent.session.events]
  998. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  999. expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
  1000. const turnEnd = e.findLast(x => x.type === 'turn/end')
  1001. // Disposal wins the post-seam check — reason is `disposed`.
  1002. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
  1003. expect(e.some(x => x.type === 'step/start')).toBe(false)
  1004. expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
  1005. // The durable turn/end record is the authoritative turn-boundary signal
  1006. // (turn boundaries have no agent/* mirror).
  1007. })
  1008. it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => {
  1009. // Block `agent/pre-step`, then cancel() the agent. When the block releases,
  1010. // the post-seam check catches cancellation and ends the turn aborted.
  1011. const adapter = new MockAdapter(['hang'])
  1012. let releasePreStep!: () => void
  1013. const blocker = new Promise<void>(r => void (releasePreStep = r))
  1014. const ctx = new Context()
  1015. await ctx.plugin(LlmService)
  1016. await ctx.plugin(SessionStore)
  1017. await ctx.plugin(SystemPrompt)
  1018. await ctx.plugin(ToolRegistry)
  1019. await ctx.plugin(AgentRegistry)
  1020. await ctx.plugin(AgentLoop, { agents: [] })
  1021. await ctx.plugin(Invariants, { freeze: false })
  1022. ctx.llm.registerAdapter(['mock'], adapter)
  1023. ctx.on('agent/pre-step', async () => {
  1024. await blocker
  1025. })
  1026. let agent!: ReactLoopAgent
  1027. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  1028. agent = inner.agentLoop.create(AgentId('a-cancel-prestep'), { model: 'mock' })
  1029. }, { inject: ['agentLoop'] }))
  1030. const reasons: TurnEndReason[] = []
  1031. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  1032. send(agent, 'go')
  1033. await new Promise(r => setTimeout(r, 30))
  1034. agent.cancel('user cancelled')
  1035. releasePreStep()
  1036. await waitForIdle(ctx, agent)
  1037. await fiber.dispose()
  1038. await agent.done
  1039. const e = [...agent.session.events]
  1040. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  1041. expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
  1042. const turnEnd = e.findLast(x => x.type === 'turn/end')
  1043. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' })
  1044. expect(e.some(x => x.type === 'step/start')).toBe(false)
  1045. expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
  1046. expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }])
  1047. })
  1048. it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
  1049. // The key assertion from the original bug report: after disposal, no
  1050. // assistant/chunk or assistant/message appears — the turn ends disposed
  1051. // before any model interaction.
  1052. const adapter = new MockAdapter([textResponse('should not appear')])
  1053. let releaseAssemble!: () => void
  1054. const blocker = new Promise<void>(r => void (releaseAssemble = r))
  1055. const ctx = new Context()
  1056. await ctx.plugin(LlmService)
  1057. await ctx.plugin(SessionStore)
  1058. await ctx.plugin(SystemPrompt)
  1059. await ctx.plugin(ToolRegistry)
  1060. await ctx.plugin(AgentRegistry)
  1061. await ctx.plugin(AgentLoop, { agents: [] })
  1062. await ctx.plugin(Invariants, { freeze: false })
  1063. ctx.llm.registerAdapter(['mock'], adapter)
  1064. ctx.on('system-prompt/assemble', async function (_assembly, next) {
  1065. await blocker
  1066. return next()
  1067. })
  1068. let agent!: ReactLoopAgent
  1069. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  1070. agent = inner.agentLoop.create(AgentId('a-dispose-no-leak'), { model: 'mock' })
  1071. }, { inject: ['agentLoop'] }))
  1072. send(agent, 'go')
  1073. await new Promise(r => setTimeout(r, 50))
  1074. const disposalDone = fiber.dispose()
  1075. releaseAssemble()
  1076. await disposalDone
  1077. await agent.done
  1078. const e = [...agent.session.events]
  1079. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  1080. expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
  1081. // The critical assertions: after disposal, the turn has no assistant
  1082. // artifacts — the turn ended disposed before the model was invoked.
  1083. expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
  1084. expect(e.some(x => x.type === 'assistant/message')).toBe(false)
  1085. expect(adapter.requests).toHaveLength(0)
  1086. // The durable turn/end reason is the authoritative turn-boundary record
  1087. // (turn boundaries have no agent/* mirror).
  1088. })
  1089. })