contract-regressions.spec.ts 60 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService, { createUserMessage, CallId, MessageSource, ProviderRequestId, 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, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
  7. import AgentRegistry, { type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent'
  8. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  9. import { ReactLoopAgent } from '../src/agent.ts'
  10. import InvariantService from '@deepseek-ai/dsh-invariants'
  11. import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
  12. import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
  13. import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
  14. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  15. async function mountInvariants(ctx: Context): Promise<void> {
  16. await ctx.plugin(InvariantService)
  17. await ctx.plugin(SessionInvariant)
  18. await ctx.plugin(AgentInvariant)
  19. await ctx.plugin(AgentLoopInvariant)
  20. }
  21. function driverDone(agent: Agent): Promise<void> {
  22. return (agent as Agent & { done: Promise<void> }).done
  23. }
  24. /** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
  25. async function harness(adapter: MockAdapter) {
  26. const ctx = new Context()
  27. await ctx.plugin(LlmService)
  28. await ctx.plugin(SessionStore)
  29. await ctx.plugin(SystemPrompt)
  30. await ctx.plugin(ToolRegistry)
  31. await ctx.plugin(AgentRegistry)
  32. await ctx.plugin(AgentLoop, { agents: [] })
  33. ctx.llm.registerAdapter(['mock'], adapter)
  34. return ctx
  35. }
  36. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  37. return new Promise((resolve) => {
  38. const dispose = ctx.on('agent/status', (subject, status) => {
  39. if (subject === agent && status === 'idle') {
  40. dispose()
  41. resolve()
  42. }
  43. })
  44. })
  45. }
  46. function send(agent: Agent, text: string) {
  47. agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
  48. }
  49. function inboxText(item: InboxItem): string {
  50. return item.message.content
  51. .flatMap(block => block.type === 'text' ? [block.text] : [])
  52. .join('')
  53. }
  54. describe('addressable inbox operations', () => {
  55. it('edits in place and removes exactly one queued item', async () => {
  56. const adapter = new MockAdapter([
  57. textResponse('first reply'),
  58. textResponse('edited reply'),
  59. ])
  60. const ctx = await harness(adapter)
  61. const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' })
  62. const admission = Promise.withResolvers<undefined>()
  63. const release = Promise.withResolvers<undefined>()
  64. ctx.on('agent/prompt-submit', async (_subject, message, _signal, next) => {
  65. if (message.content[0]?.type === 'text' && message.content[0].text === 'first') {
  66. admission.resolve(undefined)
  67. await release.promise
  68. }
  69. return next()
  70. })
  71. const pending: InboxItem[] = []
  72. const updates: { id: string; text: string }[] = []
  73. const discards: string[][] = []
  74. ctx.on('agent/inbox/enqueue', (subject, item) => {
  75. if (subject === agent && inboxText(item) !== 'first') pending.push(item)
  76. })
  77. ctx.on('agent/inbox/update', (subject, item) => {
  78. if (subject === agent) updates.push({ id: item.id, text: inboxText(item) })
  79. })
  80. ctx.on('agent/inbox/discard', (subject, items) => {
  81. if (subject === agent) discards.push(items.map(item => item.id))
  82. })
  83. send(agent, 'first')
  84. await admission.promise
  85. send(agent, 'remove me')
  86. send(agent, 'edit me')
  87. expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
  88. const remove = pending[0]!
  89. const edit = pending[1]!
  90. expect(agent.updateInbox(edit.id, {
  91. kind: 'edit',
  92. content: [{ type: 'text', text: 'edited' }],
  93. })).toBe('applied')
  94. expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied')
  95. expect(updates).toEqual([{ id: edit.id, text: 'edited' }])
  96. expect(discards).toEqual([[remove.id]])
  97. const idle = waitForIdle(ctx, agent)
  98. release.resolve(undefined)
  99. await idle
  100. expect(agent.session.events
  101. .filter(event => event.type === 'user/message')
  102. .map(event => event.type === 'user/message'
  103. ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
  104. : ''))
  105. .toEqual(['first', 'edited'])
  106. expect(agent.updateInbox(edit.id, { kind: 'remove' })).toBe('not-found')
  107. })
  108. it('does not mutate steering occurrences', async () => {
  109. const adapter = new MockAdapter([textResponse('done')])
  110. const ctx = await harness(adapter)
  111. const agent = ctx.agentLoop.create(SessionId('steering-inbox-actions'), { provider: 'mock', model: 'mock' })
  112. const entered = Promise.withResolvers<undefined>()
  113. const decision = Promise.withResolvers<{ kind: 'allow' }>()
  114. ctx.on('agent/prompt-submit', async () => {
  115. entered.resolve(undefined)
  116. return decision.promise
  117. })
  118. const pending: InboxItem[] = []
  119. ctx.on('agent/inbox/enqueue', (subject, item) => {
  120. if (subject === agent && item.placement === 'steering') pending.push(item)
  121. })
  122. const idle = waitForIdle(ctx, agent)
  123. send(agent, 'admitted prompt')
  124. await entered.promise
  125. agent.steer(createUserMessage({ content: [{ type: 'text', text: 'keep me' }], source: { kind: 'user' } }))
  126. expect(pending.map(inboxText)).toEqual(['keep me'])
  127. const steering = pending[0]!
  128. expect(agent.updateInbox(steering.id, {
  129. kind: 'edit',
  130. content: [{ type: 'text', text: 'edited' }],
  131. })).toBe('not-found')
  132. expect(agent.updateInbox(steering.id, { kind: 'remove' })).toBe('not-found')
  133. decision.resolve({ kind: 'allow' })
  134. await idle
  135. expect(agent.session.events
  136. .filter(event => event.type === 'steering/message')
  137. .map(event => event.type === 'steering/message'
  138. ? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
  139. : ''))
  140. .toEqual(['keep me'])
  141. })
  142. })
  143. describe('assistant replay provenance', () => {
  144. it('records adapter replay state with the assembled assistant content', async () => {
  145. const response = textResponse('unchanged')
  146. const replayState = { private: 'state' }
  147. response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState }
  148. const adapter = new MockAdapter([response])
  149. const ctx = await harness(adapter)
  150. const agent = ctx.agentLoop.create(SessionId('replay-state'), { provider: 'mock', model: 'next-model' })
  151. send(agent, 'go')
  152. await waitForIdle(ctx, agent)
  153. const recorded = agent.session.events.find(event => event.type === 'assistant/message')
  154. expect(recorded?.type === 'assistant/message' && recorded.data.message.source).toEqual({
  155. kind: 'model', provider: 'mock', model: 'next-model', replayState,
  156. })
  157. expect(agent.session.deriveMessages().at(-1)?.source).toEqual({
  158. kind: 'model', provider: 'mock', model: 'next-model', replayState,
  159. })
  160. })
  161. })
  162. describe('abort during tool execution ends the turn', () => {
  163. it('records context accepted before a tool-step abort in the same turn', async () => {
  164. const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
  165. const ctx = await harness(adapter)
  166. const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
  167. ctx.tools.register(defineContentToolFixture({
  168. name: 'aborter',
  169. description: '',
  170. parameters: {},
  171. async execute() {
  172. agent.inject(createUserMessage({ content: [{ type: 'text', text: 'accepted before abort' }], source: { kind: 'plugin', plugin: 'test' } }))
  173. agent.cancel({ kind: 'user' })
  174. return [{ type: 'text', text: 'done' }]
  175. },
  176. }))
  177. ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
  178. kind: 'accept',
  179. additionalContexts: [createUserMessage({
  180. content: [{ type: 'text', text: 'accepted result context after abort' }],
  181. source: { kind: 'plugin', plugin: 'test' },
  182. })],
  183. }))
  184. send(agent, 'go')
  185. await waitForIdle(ctx, agent)
  186. const events = [...agent.session.events]
  187. expect(events
  188. .filter(event => event.type === 'tool/result'
  189. || (event.type === 'user/message' && event.data.source.kind === 'plugin')
  190. || event.type === 'step/end' || event.type === 'turn/end')
  191. .map(event => event.type))
  192. .toEqual(['tool/result', 'user/message', 'step/end', 'turn/end'])
  193. expect(events
  194. .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
  195. ? [event.data.content]
  196. : []))
  197. .toEqual([
  198. [{ type: 'text', text: 'accepted result context after abort' }],
  199. ])
  200. })
  201. it('records post-tool context when a later call aborts the batch', async () => {
  202. const adapter = new MockAdapter([[
  203. { type: 'block-start', index: 0, blockType: 'tool-call' },
  204. { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'first', arguments: '{}' } },
  205. { type: 'block-start', index: 1, blockType: 'tool-call' },
  206. { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'aborter', arguments: '{}' } },
  207. { type: 'finish', reason: { kind: 'tool-calls' } },
  208. ] satisfies StreamChunk[]])
  209. const ctx = await harness(adapter)
  210. const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' })
  211. ctx.tools.register(defineContentToolFixture({
  212. name: 'first',
  213. description: '',
  214. parameters: {},
  215. async execute() {
  216. return [{ type: 'text', text: 'first done' }]
  217. },
  218. }))
  219. ctx.tools.register(defineContentToolFixture({
  220. name: 'aborter',
  221. description: '',
  222. parameters: {},
  223. async execute() {
  224. agent.cancel({ kind: 'user' })
  225. return [{ type: 'text', text: 'aborted' }]
  226. },
  227. }))
  228. ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
  229. if (exec.callId !== CallId('c1')) return next()
  230. return {
  231. kind: 'accept',
  232. additionalContexts: [createUserMessage({
  233. content: [{ type: 'text', text: 'accepted after first result' }],
  234. source: { kind: 'plugin', plugin: 'test' },
  235. })],
  236. }
  237. })
  238. send(agent, 'go')
  239. await waitForIdle(ctx, agent)
  240. const events = [...agent.session.events]
  241. expect(events
  242. .filter(event => event.type === 'tool/result'
  243. || (event.type === 'user/message' && event.data.source.kind === 'plugin')
  244. || event.type === 'step/end' || event.type === 'turn/end')
  245. .map(event => event.type))
  246. .toEqual(['tool/result', 'tool/result', 'step/end', 'turn/end'])
  247. expect(events.flatMap(event =>
  248. event.type === 'user/message' && event.data.source.kind === 'plugin'
  249. ? [event.data.content]
  250. : [])[0])
  251. .toBeUndefined()
  252. })
  253. it('records result context finalized after disposal cancellation', async () => {
  254. const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
  255. const ctx = await harness(adapter)
  256. const started = Promise.withResolvers<undefined>()
  257. let agent!: Agent
  258. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  259. agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
  260. }, { inject: ['agentLoop'] }))
  261. ctx.tools.register(defineContentToolFixture({
  262. name: 'waiter',
  263. description: '',
  264. parameters: {},
  265. async execute(_args, exec) {
  266. agent.inject(createUserMessage({ content: [{ type: 'text', text: 'accepted before disposal' }], source: { kind: 'plugin', plugin: 'test' } }))
  267. started.resolve(undefined)
  268. const signal = exec.signal
  269. if (!signal) throw new Error('tool execution signal is missing')
  270. await new Promise<void>((resolve) => {
  271. if (signal.aborted) resolve()
  272. else signal.addEventListener('abort', () => { resolve() }, { once: true })
  273. })
  274. return [{ type: 'text', text: 'done' }]
  275. },
  276. }))
  277. ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
  278. kind: 'accept',
  279. additionalContexts: [createUserMessage({
  280. content: [{ type: 'text', text: 'accepted result context during disposal' }],
  281. source: { kind: 'plugin', plugin: 'test' },
  282. })],
  283. }))
  284. send(agent, 'go')
  285. await started.promise
  286. await fiber.dispose()
  287. expect(agent.session.events
  288. .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
  289. ? [event.data.content]
  290. : []))
  291. .toEqual([
  292. [{ type: 'text', text: 'accepted result context during disposal' }],
  293. ])
  294. expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason)
  295. .toEqual({ kind: 'disposed' })
  296. })
  297. it('limits injection deferral to the current tool batch', async () => {
  298. const adapter = new MockAdapter([
  299. [
  300. { type: 'block-start', index: 0, blockType: 'tool-call' },
  301. { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'aborter', arguments: '{}' } },
  302. { type: 'block-start', index: 1, blockType: 'tool-call' },
  303. { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'second', arguments: '{}' } },
  304. { type: 'finish', reason: { kind: 'tool-calls' } },
  305. ] satisfies StreamChunk[],
  306. textResponse('later turn'),
  307. ])
  308. const ctx = await harness(adapter)
  309. const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
  310. ctx.tools.register(defineContentToolFixture({
  311. name: 'aborter',
  312. description: '',
  313. parameters: {},
  314. async execute() {
  315. agent.cancel({ kind: 'user' })
  316. return [{ type: 'text', text: 'done' }]
  317. },
  318. }))
  319. ctx.tools.register(defineContentToolFixture({
  320. name: 'second',
  321. description: '',
  322. parameters: {},
  323. async execute() {
  324. return [{ type: 'text', text: 'must not run' }]
  325. },
  326. }))
  327. send(agent, 'leave an unmatched historical call')
  328. await waitForIdle(ctx, agent)
  329. ctx.on('agent/step', (subject, turn) => {
  330. if (subject === agent && turn === 2) {
  331. agent.inject(createUserMessage({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } }))
  332. }
  333. })
  334. send(agent, 'start a text-only turn')
  335. await waitForIdle(ctx, agent)
  336. expect(agent.session.events.flatMap(event =>
  337. event.type === 'user/message' && event.data.source.kind === 'plugin'
  338. ? [event.data.content]
  339. : [])[0])
  340. .toEqual([{ type: 'text', text: 'new turn context' }])
  341. expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context')
  342. })
  343. })
  344. describe('steering from late extension points is never stranded', () => {
  345. it('steer() from an agent/turn-stopping listener continues the same turn', async () => {
  346. const adapter = new MockAdapter([
  347. textResponse('no tools, would stop here'),
  348. textResponse('continued because of steering'),
  349. ])
  350. const ctx = await harness(adapter)
  351. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  352. let steeredOnce = false
  353. ctx.on('agent/turn-stopping', () => {
  354. if (!steeredOnce) {
  355. steeredOnce = true
  356. agent.steer(createUserMessage({ content: [{ type: 'text', text: 'one more thing' }], source: { kind: 'user' } }))
  357. }
  358. })
  359. send(agent, 'go')
  360. await waitForIdle(ctx, agent)
  361. // the default decision was false (no tools), but steering forced step 2
  362. expect(adapter.requests).toHaveLength(2)
  363. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
  364. })
  365. it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => {
  366. // Assert the same-turn shape; content alone cannot distinguish re-enqueue.
  367. const adapter = new MockAdapter([
  368. textResponse('no tools, would stop'),
  369. textResponse('after goal reminder'),
  370. ])
  371. const ctx = await harness(adapter)
  372. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  373. let steeredOnce = false
  374. ctx.on('session/event', (subject, event) => {
  375. if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return
  376. steeredOnce = true
  377. agent.steer(createUserMessage({ content: [{ type: 'text', text: 'goal reminder from step/end' }], source: { kind: 'user' } }))
  378. })
  379. send(agent, 'go')
  380. await waitForIdle(ctx, agent)
  381. const events = [...agent.session.events]
  382. expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1)
  383. expect(events.filter(e => e.type === 'step/start')).toHaveLength(2)
  384. // Same-turn steering precedes the second step.
  385. const steeringIdx = events.findIndex(e => e.type === 'steering/message')
  386. const step2Idx = events.map(e => e.type).lastIndexOf('step/start')
  387. expect(steeringIdx).toBeGreaterThanOrEqual(0)
  388. expect(steeringIdx).toBeLessThan(step2Idx)
  389. // and it reached the next model request.
  390. expect(adapter.requests).toHaveLength(2)
  391. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end')
  392. })
  393. it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => {
  394. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
  395. const ctx = await harness(adapter)
  396. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  397. const turns: number[] = []
  398. let steeredOnce = false
  399. ctx.on('session/event', (subject, event) => {
  400. if (subject !== agent.session) return
  401. if (event.type === 'turn/start') turns.push(event.data.turn)
  402. if (event.type === 'turn/end' && !steeredOnce) {
  403. steeredOnce = true
  404. expect(agent.acceptsNextStep).toBe(false)
  405. agent.steer(createUserMessage({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } }))
  406. }
  407. })
  408. send(agent, 'go')
  409. await waitForIdle(ctx, agent)
  410. // the loop chains directly into turn 2 (status never returns to idle in
  411. // between), so the first idle transition means both turns are complete
  412. expect(turns).toEqual([1, 2])
  413. expect(adapter.requests).toHaveLength(2)
  414. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn')
  415. })
  416. })
  417. describe('plugin exceptions are contained', () => {
  418. it('a throwing agent/turn-stopping listener ends the turn with an error, loop survives', async () => {
  419. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  420. const ctx = await harness(adapter)
  421. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  422. let threwOnce = false
  423. ctx.on('agent/turn-stopping', async () => {
  424. if (!threwOnce) {
  425. threwOnce = true
  426. throw new Error('broken continuation plugin')
  427. }
  428. })
  429. const errors: Error[] = []
  430. ctx.on('agent/error', (_agent, _turn, _step, error) => {
  431. if (error instanceof Error) errors.push(error)
  432. })
  433. send(agent, 'first')
  434. await waitForIdle(ctx, agent)
  435. expect(errors.map(e => e.message)).toEqual(['broken continuation plugin'])
  436. // the loop is still alive: a second send works normally
  437. send(agent, 'second')
  438. await waitForIdle(ctx, agent)
  439. expect(adapter.requests).toHaveLength(2)
  440. expect(agent.status).toBe('idle')
  441. })
  442. })
  443. describe('disposal leaves the two-state status contract balanced', () => {
  444. it('disposing the fiber ends the active turn and never starts its queued tail', async () => {
  445. const adapter = new MockAdapter(['hang'])
  446. const ctx = await harness(adapter)
  447. let agent!: Agent
  448. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  449. agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
  450. }, { inject: ['agentLoop'] }))
  451. const statuses: string[] = []
  452. const reasons: TurnEndReason[] = []
  453. ctx.on('agent/status', (_agent, status) => void statuses.push(status))
  454. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  455. send(agent, 'go')
  456. await new Promise(r => setTimeout(r, 30))
  457. send(agent, 'queued tail')
  458. await fiber.dispose()
  459. await driverDone(agent)
  460. expect(statuses).toEqual(['running', 'idle'])
  461. expect(reasons).toEqual([{ kind: 'disposed' }])
  462. expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
  463. const messages = agent.session.events
  464. .filter(event => event.type === 'user/message')
  465. .flatMap(event => event.data.content)
  466. .flatMap(block => block.type === 'text' ? [block.text] : [])
  467. expect(messages).toEqual(['go'])
  468. expect(adapter.requests).toHaveLength(1)
  469. })
  470. it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => {
  471. const adapter = new MockAdapter(['hang'])
  472. const ctx = await harness(adapter)
  473. let agent!: Agent
  474. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  475. agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
  476. }, { inject: ['agentLoop'] }))
  477. ctx.on('agent/status', (_agent, status) => {
  478. if (status === 'idle') throw new Error('broken status listener')
  479. })
  480. send(agent, 'go')
  481. await new Promise(r => setTimeout(r, 30))
  482. await fiber.dispose()
  483. await driverDone(agent) // must not hang
  484. expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
  485. })
  486. })
  487. describe('adapter registration, routing, and accepted-input ownership', () => {
  488. it('duplicate adapter registration is rejected', async () => {
  489. const ctx = new Context()
  490. await ctx.plugin(LlmService)
  491. const adapter = new MockAdapter([])
  492. ctx.llm.registerAdapter(['m1'], adapter)
  493. expect(() => ctx.llm.registerAdapter(['m1'], new MockAdapter([])))
  494. .toThrow('already registered')
  495. // the original registration survives the failed attempt
  496. expect(ctx.llm.listProviders()).toEqual([{ id: 'm1', name: 'm1' }])
  497. })
  498. it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => {
  499. const adapter = new MockAdapter([textResponse('never')])
  500. const ctx = await harness(adapter)
  501. const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model
  502. const errors: Error[] = []
  503. ctx.on('agent/error', (_agent, _turn, _step, error) => {
  504. if (error instanceof Error) errors.push(error)
  505. })
  506. send(agent, 'go')
  507. await waitForIdle(ctx, agent)
  508. expect(errors).toHaveLength(1)
  509. expect(errors[0]!.message).toContain('has no provider/model')
  510. expect(errors[0]!.message).toContain('agent/request')
  511. })
  512. it('the agent/request waterfall can supply the model for a model-less agent', async () => {
  513. const adapter = new MockAdapter([textResponse('routed')])
  514. const ctx = await harness(adapter)
  515. const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides
  516. ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
  517. return { ...await next(), provider: 'mock', model: 'mock' }
  518. })
  519. send(agent, 'go')
  520. await waitForIdle(ctx, agent)
  521. expect(adapter.requests).toHaveLength(1)
  522. expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
  523. })
  524. it('agent/inbox/enqueue carries the exact message; steering/message records its source', async () => {
  525. const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
  526. const ctx = await harness(adapter)
  527. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  528. ctx.tools.register(defineContentToolFixture({
  529. name: 'noop',
  530. description: '',
  531. parameters: {},
  532. async execute() {
  533. agent.steer(createUserMessage({ content: [{ type: 'text', text: 's' }], source: { kind: 'plugin', plugin: 'goal' } }))
  534. return []
  535. },
  536. }))
  537. const queuedSources: MessageSource[] = []
  538. const queuedShapes: string[][] = []
  539. const placements: InboxPlacement[] = []
  540. ctx.on('agent/inbox/enqueue', (_agent, item) => {
  541. queuedSources.push(item.message.source)
  542. queuedShapes.push(Object.keys(item.message).sort())
  543. placements.push(item.placement)
  544. })
  545. send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
  546. await waitForIdle(ctx, agent)
  547. expect(queuedSources).toEqual([
  548. { kind: 'user' },
  549. { kind: 'plugin', plugin: 'goal' },
  550. ])
  551. expect(queuedShapes).toEqual([
  552. ['content', 'id', 'role', 'source'],
  553. ['content', 'id', 'role', 'source'],
  554. ])
  555. expect(placements).toEqual(['queued', 'steering'])
  556. // The drain appends the durable steering/message with the caller's source
  557. // intact — the log, not a transient emit, is where consumers read it.
  558. const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.message.source] : [])
  559. expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
  560. })
  561. })
  562. describe('turn numbering continues across seeded sessions', () => {
  563. it('a forked agent continues turn numbers after the seed log', async () => {
  564. const first = new MockAdapter([textResponse('turn one')])
  565. const ctx = await harness(first)
  566. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  567. send(agent, 'first')
  568. await waitForIdle(ctx, agent)
  569. // fork: seed a second context's agent with the first session's log
  570. const second = new MockAdapter([textResponse('turn two')])
  571. const ctx2 = new Context()
  572. await ctx2.plugin(LlmService)
  573. await ctx2.plugin(SessionStore)
  574. await ctx2.plugin(SystemPrompt)
  575. await ctx2.plugin(ToolRegistry)
  576. await ctx2.plugin(AgentRegistry)
  577. await ctx2.plugin(AgentLoop, { agents: [] })
  578. ctx2.llm.registerAdapter(['mock'], second)
  579. const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
  580. const forked = new ReactLoopAgent(
  581. ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded,
  582. )
  583. const turns: number[] = []
  584. ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
  585. forked.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
  586. await new Promise<void>((resolve) => {
  587. ctx2.on('agent/status', (subject, status) => {
  588. if (subject === forked && status === 'idle') resolve()
  589. })
  590. })
  591. expect(turns).toEqual([2])
  592. })
  593. })
  594. describe('discriminated SessionEvent narrows without casts', () => {
  595. it('narrows event.data from event.type', () => {
  596. const session = new Session(SessionId('s'))
  597. const appended: SessionEvent = session.append('tool/call', {
  598. turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}',
  599. })
  600. // compile-time: this switch narrows; runtime: values flow through
  601. switch (appended.type) {
  602. case 'tool/call': {
  603. expect(appended.data.callId).toBe('c1')
  604. expect(appended.data.name).toBe('echo')
  605. break
  606. }
  607. default: throw new Error('wrong narrow')
  608. }
  609. })
  610. })
  611. describe('a finish-error stream chunk ends the turn as error, not completed', () => {
  612. it('translates finish {kind:error} into a turn error with a logged error event', async () => {
  613. // A finish-error chunk must not produce a completed assistant turn.
  614. const failure = {
  615. message: 'provider 401',
  616. code: 'AUTH',
  617. status: 401,
  618. providerRetryAfterMs: 2_000,
  619. requestId: ProviderRequestId('finish-request-1'),
  620. }
  621. const errorStream: StreamChunk[] = [
  622. { type: 'finish', reason: { kind: 'error', failure } },
  623. ]
  624. const adapter = new MockAdapter([errorStream])
  625. const ctx = await harness(adapter)
  626. const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' })
  627. const reasons: TurnEndReason[] = []
  628. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  629. send(agent, 'go')
  630. await waitForIdle(ctx, agent)
  631. expect(reasons).toEqual([{ kind: 'error', step: 1, failure }])
  632. const events = [...agent.session.events]
  633. // The durable failure lives on turn/end.reason (with the failing step), not
  634. // a standalone error event.
  635. const turnEnd = events.find(event => event.type === 'turn/end')
  636. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, failure })
  637. // A failed step must not synthesize an assistant message.
  638. expect(events.some(event => event.type === 'assistant/message')).toBe(false)
  639. })
  640. it('translates finish {kind:aborted} into a turn error coded ABORTED', async () => {
  641. const abortedStream: StreamChunk[] = [
  642. { type: 'finish', reason: { kind: 'aborted', failure: { message: 'model stream aborted', code: 'ABORTED' } } },
  643. ]
  644. const adapter = new MockAdapter([abortedStream])
  645. const ctx = await harness(adapter)
  646. const agent = ctx.agentLoop.create(SessionId('a-finish-aborted'), { provider: 'mock', model: 'mock' })
  647. const reasons: TurnEndReason[] = []
  648. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  649. send(agent, 'go')
  650. await waitForIdle(ctx, agent)
  651. expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'model stream aborted', code: 'ABORTED' } }])
  652. expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false)
  653. })
  654. it('handles a finish error without a code (code key omitted)', async () => {
  655. const errorStream: StreamChunk[] = [
  656. { type: 'finish', reason: { kind: 'error', failure: { message: 'codeless failure', code: 'UNKNOWN' } } },
  657. ]
  658. const adapter = new MockAdapter([errorStream])
  659. const ctx = await harness(adapter)
  660. const agent = ctx.agentLoop.create(SessionId('a-finish-error-nocode'), { provider: 'mock', model: 'mock' })
  661. const reasons: TurnEndReason[] = []
  662. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  663. send(agent, 'go')
  664. await waitForIdle(ctx, agent)
  665. expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'codeless failure', code: 'UNKNOWN' } }])
  666. })
  667. })
  668. describe('step boundary publication order', () => {
  669. it('the step/start event is in session.events when its session/event listener fires', async () => {
  670. const adapter = new MockAdapter([textResponse('done')])
  671. const ctx = await harness(adapter)
  672. const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' })
  673. const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
  674. ctx.on('session/event', (subject, event) => {
  675. if (subject !== agent.session || event.type !== 'step/start') return
  676. const events = [...subject.events]
  677. const last = events.at(-1)
  678. observed.push({
  679. turn: event.data.turn,
  680. step: event.data.step,
  681. lastEventType: last?.type,
  682. sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === event.data.turn && e.data.step === event.data.step),
  683. })
  684. })
  685. send(agent, 'go')
  686. await waitForIdle(ctx, agent)
  687. expect(observed).toHaveLength(1)
  688. expect(observed[0]).toMatchObject({ turn: 1, step: 1, lastEventType: 'step/start', sawStepStart: true })
  689. })
  690. })
  691. describe('turn and step boundary recovery', () => {
  692. // The session invariant companion makes an unbalanced log fail the test.
  693. async function balancedHarness(adapter: MockAdapter) {
  694. const ctx = new Context()
  695. await ctx.plugin(LlmService)
  696. await ctx.plugin(SessionStore)
  697. await ctx.plugin(SystemPrompt)
  698. await ctx.plugin(ToolRegistry)
  699. await ctx.plugin(AgentRegistry)
  700. await ctx.plugin(AgentLoop, { agents: [] })
  701. await mountInvariants(ctx)
  702. ctx.llm.registerAdapter(['mock'], adapter)
  703. return ctx
  704. }
  705. /** Count turn/step boundary events for balance assertions. */
  706. function boundaryCounts(agent: Agent) {
  707. const e = [...agent.session.events]
  708. return {
  709. turnStart: e.filter(x => x.type === 'turn/start').length,
  710. turnEnd: e.filter(x => x.type === 'turn/end').length,
  711. stepStart: e.filter(x => x.type === 'step/start').length,
  712. stepEnd: e.filter(x => x.type === 'step/end').length,
  713. errors: e.filter(x => x.type === 'turn/end' && x.data.reason.kind === 'error').length,
  714. lastTurnEnd: e.findLast(x => x.type === 'turn/end'),
  715. }
  716. }
  717. it('a throwing step/start observer cannot change a successful turn', async () => {
  718. const adapter = new MockAdapter([textResponse('request completed')])
  719. const ctx = await balancedHarness(adapter)
  720. const agent = ctx.agentLoop.create(SessionId('a-stepstart'), { provider: 'mock', model: 'mock' })
  721. // Session owns post-commit containment. The loop sees a successful append,
  722. // runs the request, and balances the ordinary step and turn boundaries.
  723. let threw = false
  724. ctx.on('session/event', (_s, event) => {
  725. if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
  726. })
  727. const errors: Error[] = []
  728. ctx.on('agent/error', (_a, _t, _s, error) => {
  729. if (error instanceof Error) errors.push(error)
  730. })
  731. send(agent, 'go')
  732. await waitForIdle(ctx, agent)
  733. const e = [...agent.session.events]
  734. const c = boundaryCounts(agent)
  735. expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
  736. expect(errors).toEqual([])
  737. // step/end precedes turn/end (the invariants oracle would reject
  738. // turn/end-while-step-open, but assert the order explicitly too).
  739. const stepEndIdx = e.findIndex(x => x.type === 'step/end')
  740. const turnEndIdx = e.findIndex(x => x.type === 'turn/end')
  741. expect(stepEndIdx).toBeGreaterThanOrEqual(0)
  742. expect(stepEndIdx).toBeLessThan(turnEndIdx)
  743. })
  744. it('a pre-commit turn/start rejection leaves no turn state for the next prompt', async () => {
  745. const adapter = new MockAdapter([textResponse('after recovery')])
  746. const ctx = await balancedHarness(adapter)
  747. const agent = ctx.agentLoop.create(SessionId('a-turnstart-veto'), { provider: 'mock', model: 'mock' })
  748. let rejected = false
  749. ctx.on('internal/dispatch', (_mode, name, args) => {
  750. if (name !== 'session/event') return
  751. const event = args[1] as SessionEvent
  752. if (event.type === 'turn/start' && !rejected) {
  753. rejected = true
  754. throw new Error('reject turn-start before commit')
  755. }
  756. })
  757. const errors: Error[] = []
  758. ctx.on('agent/error', (_agent, _turn, _step, error) => {
  759. if (error instanceof Error) errors.push(error)
  760. })
  761. send(agent, 'rejected')
  762. await waitForIdle(ctx, agent)
  763. // The rejected turn left nothing behind: no events, no admitted prompt.
  764. expect(agent.session.events).toEqual([])
  765. expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit'])
  766. // The next prompt reuses the never-committed turn number and carries only
  767. // its own admitted content — invariants (mounted) accept the log.
  768. send(agent, 'go')
  769. await waitForIdle(ctx, agent)
  770. expect(boundaryCounts(agent)).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1 })
  771. const turnStart = agent.session.events.find(event => event.type === 'turn/start')
  772. expect(turnStart?.type === 'turn/start' && turnStart.data.turn).toBe(1)
  773. const prompts = agent.session.events.filter(event => event.type === 'user/message')
  774. expect(prompts.map(event => event.type === 'user/message' && event.data.content)).toEqual([
  775. [{ type: 'text', text: 'go' }],
  776. ])
  777. expect(adapter.requests).toHaveLength(1)
  778. })
  779. it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
  780. const adapter = new MockAdapter([textResponse('never reached')])
  781. const ctx = await balancedHarness(adapter)
  782. const agent = ctx.agentLoop.create(SessionId('a-stepstart-veto'), { provider: 'mock', model: 'mock' })
  783. let rejected = false
  784. ctx.on('internal/dispatch', (_mode, name, args) => {
  785. if (name !== 'session/event') return
  786. const event = args[1] as SessionEvent
  787. if (event.type === 'step/start' && !rejected) {
  788. rejected = true
  789. throw new Error('reject step-start before commit')
  790. }
  791. })
  792. const errors: Error[] = []
  793. ctx.on('agent/error', (_agent, _turn, _step, error) => {
  794. if (error instanceof Error) errors.push(error)
  795. })
  796. send(agent, 'go')
  797. await waitForIdle(ctx, agent)
  798. expect(adapter.requests).toEqual([])
  799. expect(boundaryCounts(agent)).toMatchObject({
  800. turnStart: 1,
  801. turnEnd: 1,
  802. stepStart: 0,
  803. stepEnd: 0,
  804. errors: 1,
  805. })
  806. expect(errors.map(error => error.message)).toEqual(['reject step-start before commit'])
  807. })
  808. it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
  809. const adapter = new MockAdapter([textResponse('completed before close validation')])
  810. const ctx = await balancedHarness(adapter)
  811. const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { provider: 'mock', model: 'mock' })
  812. let rejected = false
  813. ctx.on('internal/dispatch', (_mode, name, args) => {
  814. if (name !== 'session/event') return
  815. const event = args[1] as SessionEvent
  816. if (event.type === 'step/end' && !rejected) {
  817. rejected = true
  818. throw new Error('reject first step-end')
  819. }
  820. })
  821. const errors: Error[] = []
  822. ctx.on('agent/error', (_agent, _turn, _step, error) => {
  823. if (error instanceof Error) errors.push(error)
  824. })
  825. send(agent, 'go')
  826. await waitForIdle(ctx, agent)
  827. expect(adapter.requests).toHaveLength(1)
  828. expect(errors.map(error => error.message)).toEqual(['reject first step-end'])
  829. expect(boundaryCounts(agent)).toMatchObject({
  830. turnStart: 1,
  831. turnEnd: 1,
  832. stepStart: 1,
  833. stepEnd: 1,
  834. errors: 1,
  835. })
  836. })
  837. it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
  838. // Listener failure cannot interrupt error finalization or the next turn.
  839. const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }]
  840. const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
  841. const ctx = await balancedHarness(adapter)
  842. const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' })
  843. let threw = false
  844. ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } })
  845. send(agent, 'go')
  846. await waitForIdle(ctx, agent)
  847. const c = boundaryCounts(agent)
  848. // turn 1 balanced despite the throwing agent/error listener.
  849. expect(c.turnStart).toBe(1)
  850. expect(c.turnEnd).toBe(1)
  851. expect(c.stepStart).toBe(c.stepEnd)
  852. expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({
  853. kind: 'error',
  854. step: 1,
  855. failure: { message: 'provider 500', code: 'SERVER' },
  856. })
  857. // loop survives: a second turn runs to completion (invariants oracle would
  858. // throw on its turn/start if turn 1 had been left open).
  859. send(agent, 'again')
  860. await waitForIdle(ctx, agent)
  861. const c2 = boundaryCounts(agent)
  862. expect(c2.turnStart).toBe(2)
  863. expect(c2.turnEnd).toBe(2)
  864. expect(c2.stepStart).toBe(c2.stepEnd)
  865. })
  866. it('disposal during a running turn ends the turn with reason disposed (balanced)', async () => {
  867. // The 'hang' adapter blocks in stream() until the signal aborts; disposing
  868. // the agent's fiber mid-turn aborts the in-flight step. The turn must close
  869. // balanced with reason disposed (no error event for a disposal).
  870. const adapter = new MockAdapter(['hang'])
  871. const ctx = await balancedHarness(adapter)
  872. let agent!: Agent
  873. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  874. agent = inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' })
  875. }, { inject: ['agentLoop'] }))
  876. const reasons: TurnEndReason[] = []
  877. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  878. send(agent, 'go')
  879. await new Promise(r => setTimeout(r, 30))
  880. await fiber.dispose() // dispose during the hanging step
  881. await driverDone(agent)
  882. const e = [...agent.session.events]
  883. const turnStarts = e.filter(x => x.type === 'turn/start').length
  884. const turnEnds = e.filter(x => x.type === 'turn/end').length
  885. expect(turnStarts).toBe(1)
  886. expect(turnEnds).toBe(1) // balanced — the turn was closed despite disposal
  887. expect(reasons).toEqual([{ kind: 'disposed' }])
  888. // no error reason: disposal is not a failure.
  889. expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
  890. })
  891. it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
  892. // Disposal remains authoritative when the listener also throws.
  893. const adapter = new MockAdapter([textResponse('never reached')])
  894. const ctx = await balancedHarness(adapter)
  895. let agent!: Agent
  896. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  897. agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' })
  898. }, { inject: ['agentLoop'] }))
  899. let threw = false
  900. ctx.on('agent/step', () => {
  901. if (threw) return
  902. threw = true
  903. void fiber.dispose()
  904. throw new Error('boom pre-step during disposal')
  905. })
  906. const errorEmits: Error[] = []
  907. ctx.on('agent/error', (_a, _t, _s, error) => {
  908. if (error instanceof Error) errorEmits.push(error)
  909. })
  910. send(agent, 'go')
  911. await agent.whenIdle()
  912. const e = [...agent.session.events]
  913. // Balanced: one turn/start, one turn/end carrying disposed (NOT error).
  914. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  915. const turnEnd = e.findLast(x => x.type === 'turn/end')
  916. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
  917. expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
  918. // No step opened (the throw was before step/start) and disposal is not a
  919. // failure, so no agent/error for the contained throw.
  920. expect(e.some(x => x.type === 'step/start')).toBe(false)
  921. expect(errorEmits).toHaveLength(0)
  922. })
  923. it('a throwing turn/start observer cannot starve the loop or later turns', async () => {
  924. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
  925. const ctx = await harness(adapter)
  926. const agent = ctx.agentLoop.create(SessionId('a-preturn'), { provider: 'mock', model: 'mock' })
  927. let threw = false
  928. ctx.on('session/event', (_session, event) => {
  929. if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') }
  930. })
  931. const errors: Error[] = []
  932. ctx.on('agent/error', (_a, _t, _s, error) => {
  933. if (error instanceof Error) errors.push(error)
  934. })
  935. send(agent, 'go')
  936. await waitForIdle(ctx, agent)
  937. expect(errors).toEqual([])
  938. // Session contains the observer failure per listener, so the committed turn
  939. // remains visible to later observers and executes normally.
  940. const types = [...agent.session.events].map(e => e.type)
  941. expect(types.filter(t => t === 'turn/start')).toHaveLength(1)
  942. expect(types.filter(t => t === 'turn/end')).toHaveLength(1)
  943. const lastBoundary = [...agent.session.events].reverse().find(e => e.type === 'turn/start' || e.type === 'turn/end')
  944. expect(lastBoundary?.type).toBe('turn/end')
  945. expect(agent.session.events.at(-1)?.type).toBe('turn/end')
  946. // loop survives: a second turn runs normally.
  947. send(agent, 'second')
  948. await waitForIdle(ctx, agent)
  949. expect(adapter.requests).toHaveLength(2)
  950. })
  951. it('a throwing step/end observer cannot rewrite the turn outcome', async () => {
  952. const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
  953. const ctx = await balancedHarness(adapter)
  954. const agent = ctx.agentLoop.create(SessionId('a-stepend-throw'), { provider: 'mock', model: 'mock' })
  955. let threw = false
  956. ctx.on('session/event', (_s, event) => {
  957. if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') }
  958. })
  959. const errors: Error[] = []
  960. ctx.on('agent/error', (_a, _t, _s, error) => {
  961. if (error instanceof Error) errors.push(error)
  962. })
  963. send(agent, 'go')
  964. await waitForIdle(ctx, agent)
  965. const c = boundaryCounts(agent)
  966. expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
  967. expect(errors).toEqual([])
  968. expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason)
  969. .toEqual({ kind: 'completed' })
  970. // step/end precedes turn/end (ordering contract)
  971. const e = [...agent.session.events]
  972. const stepEndIdx = e.findIndex(x => x.type === 'step/end')
  973. const turnEndIdx = e.findIndex(x => x.type === 'turn/end')
  974. expect(stepEndIdx).toBeGreaterThanOrEqual(0)
  975. expect(stepEndIdx).toBeLessThan(turnEndIdx)
  976. // loop survives: a subsequent turn runs to completion
  977. send(agent, 'again')
  978. await waitForIdle(ctx, agent)
  979. const c2 = boundaryCounts(agent)
  980. expect(c2.turnStart).toBe(2)
  981. expect(c2.turnEnd).toBe(2)
  982. expect(c2.stepStart).toBe(c2.stepEnd)
  983. })
  984. it('a throwing step/end observer cannot interrupt error finalization', async () => {
  985. // Observer failure after step/end commit cannot interrupt turn finalization.
  986. const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }]
  987. const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
  988. const ctx = await harness(adapter)
  989. const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' })
  990. let threw = false
  991. ctx.on('session/event', (_s, event) => {
  992. if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
  993. })
  994. const errors: Error[] = []
  995. ctx.on('agent/error', (_a, _t, _s, error) => {
  996. if (error instanceof Error) errors.push(error)
  997. })
  998. send(agent, 'go')
  999. await waitForIdle(ctx, agent)
  1000. const e = [...agent.session.events]
  1001. // Both step/end and turn/end are present — finalization ran to completion.
  1002. expect(e.some(x => x.type === 'step/end')).toBe(true)
  1003. expect(e.some(x => x.type === 'turn/end')).toBe(true)
  1004. expect(e.at(-1)?.type).toBe('turn/end')
  1005. expect(errors.map(error => error.message)).toEqual(['provider 500'])
  1006. // loop survives.
  1007. send(agent, 'again')
  1008. await waitForIdle(ctx, agent)
  1009. expect(e.filter(x => x.type === 'turn/start').length).toBeGreaterThanOrEqual(1)
  1010. })
  1011. it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
  1012. // Session contains the observer failure after committing turn/end, so the
  1013. // boundary stays authoritative and the loop continues normally.
  1014. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
  1015. const ctx = await harness(adapter)
  1016. const agent = ctx.agentLoop.create(SessionId('a-turnendappend'), { provider: 'mock', model: 'mock' })
  1017. let threw = false
  1018. ctx.on('session/event', (_s, event) => {
  1019. if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end listener') }
  1020. })
  1021. send(agent, 'go')
  1022. await waitForIdle(ctx, agent)
  1023. // turn 1 is balanced despite the throwing turn/end listener.
  1024. const e1 = [...agent.session.events]
  1025. expect(e1.filter(x => x.type === 'turn/start')).toHaveLength(1)
  1026. expect(e1.filter(x => x.type === 'turn/end')).toHaveLength(1)
  1027. expect(e1.at(-1)?.type).toBe('turn/end')
  1028. // loop survives: a second turn runs to completion.
  1029. send(agent, 'again')
  1030. await waitForIdle(ctx, agent)
  1031. expect(adapter.requests).toHaveLength(2)
  1032. expect([...agent.session.events].filter(x => x.type === 'turn/end')).toHaveLength(2)
  1033. })
  1034. })
  1035. describe('tool result call identity', () => {
  1036. it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
  1037. // Model emits a tool-call with id "c1", then a final text turn.
  1038. const adapter = new MockAdapter([
  1039. toolCallResponse('c1', 'echo', { x: 1 }),
  1040. textResponse('done'),
  1041. ])
  1042. const ctx = await harness(adapter)
  1043. ctx.tools.register(defineContentToolFixture({
  1044. name: 'echo',
  1045. description: 'echo',
  1046. parameters: { x: { type: 'number' } },
  1047. async execute() { return [{ type: 'text', text: 'ok' }] },
  1048. }))
  1049. // A post-execute listener transforms the result (accept-with-replacement).
  1050. // The loop must still record the tool/result under the model's authoritative
  1051. // call.id, which is the immutable identity carried by the execution input.
  1052. ctx.on('tools/post-execute', (exec, _result) => {
  1053. expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
  1054. return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
  1055. }, { prepend: true })
  1056. const agent = ctx.agentLoop.create(SessionId('a-callid'), { provider: 'mock', model: 'mock' })
  1057. send(agent, 'use tool')
  1058. await waitForIdle(ctx, agent)
  1059. // The logged tool/result.callId is the originating call.id.
  1060. const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result')
  1061. expect(resultEvent?.type).toBe('tool/result')
  1062. if (resultEvent?.type === 'tool/result') {
  1063. expect(resultEvent.data.message.source.callId).toBe(CallId('c1'))
  1064. }
  1065. // And deriveMessages pairs the tool-result with the assistant tool-call:
  1066. // the derived tool-result block's toolCallId equals the original call.id.
  1067. const messages = agent.session.deriveMessages()
  1068. const toolResultBlock = messages
  1069. .flatMap(m => m.content)
  1070. .find(b => b.type === 'tool-result')
  1071. expect(toolResultBlock?.type).toBe('tool-result')
  1072. if (toolResultBlock?.type === 'tool-result') {
  1073. expect(toolResultBlock.toolCallId).toBe(CallId('c1'))
  1074. }
  1075. })
  1076. })
  1077. describe('disposal and cancellation during pre-step assembly', () => {
  1078. it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
  1079. // Start disposal, then release assembly. Do not await disposal first: it
  1080. // waits for the blocked driver to exit.
  1081. const adapter = new MockAdapter(['hang'])
  1082. let releaseAssemble!: () => void
  1083. const blocked = new Promise<void>(r => void (releaseAssemble = r))
  1084. const ctx = new Context()
  1085. await ctx.plugin(LlmService)
  1086. await ctx.plugin(SessionStore)
  1087. await ctx.plugin(SystemPrompt)
  1088. await ctx.plugin(ToolRegistry)
  1089. await ctx.plugin(AgentRegistry)
  1090. await ctx.plugin(AgentLoop, { agents: [] })
  1091. await mountInvariants(ctx)
  1092. ctx.llm.registerAdapter(['mock'], adapter)
  1093. // Parent-owned listener survives agent-fiber disposal.
  1094. const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
  1095. await blocked
  1096. return next()
  1097. })
  1098. let agent!: Agent
  1099. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  1100. agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' })
  1101. }, { inject: ['agentLoop'] }))
  1102. const reasons: TurnEndReason[] = []
  1103. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  1104. send(agent, 'go')
  1105. // Give the loop time to enter the step and reach assemble().
  1106. await new Promise(r => setTimeout(r, 50))
  1107. // Release assembly before awaiting disposal because disposal joins the blocked driver.
  1108. const disposalDone = fiber.dispose()
  1109. releaseAssemble()
  1110. await disposalDone
  1111. await driverDone(agent)
  1112. unlisten()
  1113. // Turn boundaries are durable rows; there is no `agent/*` mirror to assert.
  1114. const e = [...agent.session.events]
  1115. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  1116. expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
  1117. const turnEnd = e.findLast(x => x.type === 'turn/end')
  1118. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
  1119. expect(e.some(x => x.type === 'step/start')).toBe(false)
  1120. expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
  1121. })
  1122. it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => {
  1123. const adapter = new MockAdapter([textResponse('should not appear')])
  1124. let releaseAssemble!: () => void
  1125. const blocker = new Promise<void>(r => void (releaseAssemble = r))
  1126. const ctx = new Context()
  1127. await ctx.plugin(LlmService)
  1128. await ctx.plugin(SessionStore)
  1129. await ctx.plugin(SystemPrompt)
  1130. await ctx.plugin(ToolRegistry)
  1131. await ctx.plugin(AgentRegistry)
  1132. await ctx.plugin(AgentLoop, { agents: [] })
  1133. await mountInvariants(ctx)
  1134. ctx.llm.registerAdapter(['mock'], adapter)
  1135. const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
  1136. await blocker
  1137. return next()
  1138. })
  1139. let agent!: Agent
  1140. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  1141. agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' })
  1142. }, { inject: ['agentLoop'] }))
  1143. const reasons: TurnEndReason[] = []
  1144. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  1145. send(agent, 'go')
  1146. await new Promise(r => setTimeout(r, 50))
  1147. agent.cancel({ kind: 'user' })
  1148. releaseAssemble()
  1149. await waitForIdle(ctx, agent)
  1150. await fiber.dispose()
  1151. await driverDone(agent)
  1152. unlisten()
  1153. const e = [...agent.session.events]
  1154. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  1155. expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
  1156. const turnEnd = e.findLast(x => x.type === 'turn/end')
  1157. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
  1158. expect(e.some(x => x.type === 'step/start')).toBe(false)
  1159. expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
  1160. expect(e.some(x => x.type === 'assistant/message')).toBe(false)
  1161. expect(adapter.requests).toHaveLength(0)
  1162. expect(reasons).toEqual([{ kind: 'aborted' }])
  1163. })
  1164. it('disposal during agent/step listeners ends the turn disposed', { timeout: 15000 }, async () => {
  1165. // Start disposal, then release pre-step; awaiting disposal first would
  1166. // deadlock on the blocked driver.
  1167. const adapter = new MockAdapter(['hang'])
  1168. let releasePreStep!: () => void
  1169. const blocker = new Promise<void>(r => void (releasePreStep = r))
  1170. const ctx = new Context()
  1171. await ctx.plugin(LlmService)
  1172. await ctx.plugin(SessionStore)
  1173. await ctx.plugin(SystemPrompt)
  1174. await ctx.plugin(ToolRegistry)
  1175. await ctx.plugin(AgentRegistry)
  1176. await ctx.plugin(AgentLoop, { agents: [] })
  1177. await mountInvariants(ctx)
  1178. ctx.llm.registerAdapter(['mock'], adapter)
  1179. ctx.on('agent/step', async () => {
  1180. await blocker
  1181. })
  1182. let agent!: Agent
  1183. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  1184. agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' })
  1185. }, { inject: ['agentLoop'] }))
  1186. const reasons: TurnEndReason[] = []
  1187. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  1188. send(agent, 'go')
  1189. await new Promise(r => setTimeout(r, 50))
  1190. const disposalDone = fiber.dispose()
  1191. releasePreStep()
  1192. await disposalDone
  1193. await driverDone(agent)
  1194. // After the agent/step listeners finish, the post-listener cancel/dispose check
  1195. // catches disposal. The step was never opened, no LLM call was made.
  1196. const e = [...agent.session.events]
  1197. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  1198. expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
  1199. const turnEnd = e.findLast(x => x.type === 'turn/end')
  1200. // Disposal wins the post-listener check — reason is `disposed`.
  1201. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
  1202. expect(e.some(x => x.type === 'step/start')).toBe(false)
  1203. expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
  1204. // The durable turn/end record is the authoritative turn-boundary signal
  1205. // (turn boundaries have no agent/* mirror).
  1206. })
  1207. it('cancel during agent/step listeners ends the turn aborted', { timeout: 15000 }, async () => {
  1208. // Release agent/step after cancellation to exercise the post-listener check.
  1209. const adapter = new MockAdapter(['hang'])
  1210. let releasePreStep!: () => void
  1211. const blocker = new Promise<void>(r => void (releasePreStep = r))
  1212. const ctx = new Context()
  1213. await ctx.plugin(LlmService)
  1214. await ctx.plugin(SessionStore)
  1215. await ctx.plugin(SystemPrompt)
  1216. await ctx.plugin(ToolRegistry)
  1217. await ctx.plugin(AgentRegistry)
  1218. await ctx.plugin(AgentLoop, { agents: [] })
  1219. await mountInvariants(ctx)
  1220. ctx.llm.registerAdapter(['mock'], adapter)
  1221. ctx.on('agent/step', async () => {
  1222. await blocker
  1223. })
  1224. let agent!: Agent
  1225. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  1226. agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' })
  1227. }, { inject: ['agentLoop'] }))
  1228. const reasons: TurnEndReason[] = []
  1229. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  1230. send(agent, 'go')
  1231. await new Promise(r => setTimeout(r, 30))
  1232. agent.cancel({ kind: 'user' })
  1233. releasePreStep()
  1234. await waitForIdle(ctx, agent)
  1235. await fiber.dispose()
  1236. await driverDone(agent)
  1237. const e = [...agent.session.events]
  1238. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  1239. expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
  1240. const turnEnd = e.findLast(x => x.type === 'turn/end')
  1241. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
  1242. expect(e.some(x => x.type === 'step/start')).toBe(false)
  1243. expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
  1244. expect(reasons).toEqual([{ kind: 'aborted' }])
  1245. })
  1246. it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
  1247. // The key assertion from the original bug report: after disposal, no
  1248. // assistant/chunk or assistant/message appears — the turn ends disposed
  1249. // before any model interaction.
  1250. const adapter = new MockAdapter([textResponse('should not appear')])
  1251. let releaseAssemble!: () => void
  1252. const blocker = new Promise<void>(r => void (releaseAssemble = r))
  1253. const ctx = new Context()
  1254. await ctx.plugin(LlmService)
  1255. await ctx.plugin(SessionStore)
  1256. await ctx.plugin(SystemPrompt)
  1257. await ctx.plugin(ToolRegistry)
  1258. await ctx.plugin(AgentRegistry)
  1259. await ctx.plugin(AgentLoop, { agents: [] })
  1260. await mountInvariants(ctx)
  1261. ctx.llm.registerAdapter(['mock'], adapter)
  1262. ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
  1263. await blocker
  1264. return next()
  1265. })
  1266. let agent!: Agent
  1267. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  1268. agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' })
  1269. }, { inject: ['agentLoop'] }))
  1270. send(agent, 'go')
  1271. await new Promise(r => setTimeout(r, 50))
  1272. const disposalDone = fiber.dispose()
  1273. releaseAssemble()
  1274. await disposalDone
  1275. await driverDone(agent)
  1276. const e = [...agent.session.events]
  1277. expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
  1278. expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
  1279. // The critical assertions: after disposal, the turn has no assistant
  1280. // artifacts — the turn ended disposed before the model was invoked.
  1281. expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
  1282. expect(e.some(x => x.type === 'assistant/message')).toBe(false)
  1283. expect(adapter.requests).toHaveLength(0)
  1284. // The durable turn/end reason is the authoritative turn-boundary record
  1285. // (turn boundaries have no agent/* mirror).
  1286. })
  1287. })