contract-regressions.spec.ts 56 KB

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