agent.spec.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry from '@deepseek-ai/dsh-tools'
  7. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  8. import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
  9. import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts'
  10. import { MockAdapter, textResponse } from './mock-adapter.ts'
  11. function driverDone(agent: Agent): Promise<void> {
  12. return (agent as Agent & { done: Promise<void> }).done
  13. }
  14. async function harness(adapter: MockAdapter) {
  15. const ctx = new Context()
  16. await ctx.plugin(LlmService)
  17. await ctx.plugin(SessionStore)
  18. await ctx.plugin(SystemPrompt)
  19. await ctx.plugin(ToolRegistry)
  20. await ctx.plugin(AgentRegistry)
  21. await ctx.plugin(AgentLoop, { agents: [] })
  22. ctx.llm.registerAdapter(['mock'], adapter)
  23. return ctx
  24. }
  25. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  26. return new Promise((resolve) => {
  27. const dispose = ctx.on('agent/status', (subject, status) => {
  28. if (subject === agent && status === 'idle') {
  29. dispose()
  30. resolve()
  31. }
  32. })
  33. })
  34. }
  35. function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise<void> {
  36. return new Promise((resolve) => {
  37. const dispose = ctx.on('agent/status', (subject, status) => {
  38. if (subject === agent && status === expected) {
  39. dispose()
  40. resolve()
  41. }
  42. })
  43. })
  44. }
  45. function send(agent: Agent, text: string) {
  46. agent.followup([{ type: 'text', text }])
  47. }
  48. describe('Agent', () => {
  49. it('rejects access before context binding and a second driver for one session', async () => {
  50. const ctx = new Context()
  51. await ctx.plugin(SessionStore)
  52. const session = ctx.sessions.create(SessionId('exclusive-driver'))
  53. const prepared = prepareReactLoopAgent(
  54. ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
  55. )
  56. expect(() => prepared.agent.ctx).toThrow('context is not bound')
  57. expect(() => prepareReactLoopAgent(
  58. ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
  59. ))
  60. .toThrow('already has a concrete agent driver')
  61. await prepared.dispose()
  62. await ctx.fiber.dispose()
  63. })
  64. it('borrows caller options and binds its scoped context exactly once', async () => {
  65. const ctx = await harness(new MockAdapter([textResponse('unused')]))
  66. const options = { provider: 'mock', model: 'mock' }
  67. const agent = ctx.agentLoop.create(SessionId('owned-bindings'), options)
  68. expect(agent.options).toBe(options)
  69. expect(agent.id).toBe('owned-bindings')
  70. expect(agent.session.id).toBe(agent.id)
  71. expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/)
  72. await ctx.fiber.dispose()
  73. })
  74. it('send exposes the fully resolved delivery path without applying helper defaults', async () => {
  75. const adapter = new MockAdapter([textResponse('accepted')])
  76. const ctx = await harness(adapter)
  77. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  78. const enqueued = Promise.withResolvers<{ id: string; source: unknown; wakeup: boolean }>()
  79. ctx.on('agent/inbox/enqueue', (subject, message) => {
  80. if (subject === agent) enqueued.resolve(message)
  81. })
  82. const id = agent.send({
  83. content: [{ type: 'text', text: 'advanced input' }],
  84. source: { kind: 'plugin', plugin: 'advanced-caller' },
  85. contexts: [],
  86. meta: { caller: 'advanced' },
  87. target: 'next-turn',
  88. wakeup: true,
  89. })
  90. await waitForIdle(ctx, agent)
  91. expect(await enqueued.promise).toMatchObject({
  92. id,
  93. source: { kind: 'plugin', plugin: 'advanced-caller' },
  94. wakeup: true,
  95. })
  96. expect(agent.session.events.find(event => event.type === 'user/message'))
  97. .toMatchObject({
  98. data: {
  99. source: { kind: 'plugin', plugin: 'advanced-caller' },
  100. meta: { caller: 'advanced' },
  101. },
  102. })
  103. await ctx.fiber.dispose()
  104. })
  105. it('followup() throws after disposal', async () => {
  106. const adapter = new MockAdapter(['hang'])
  107. const ctx = await harness(adapter)
  108. let agent!: Agent
  109. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  110. agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
  111. }, { inject: ['agentLoop'] }))
  112. send(agent, 'go')
  113. await new Promise(r => setTimeout(r, 30))
  114. await fiber.dispose()
  115. await driverDone(agent)
  116. expect(() => { agent.followup([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
  117. })
  118. it('disposal discards still-pending inbox items so every id gets a terminal event', async () => {
  119. const adapter = new MockAdapter(['hang'])
  120. const ctx = await harness(adapter)
  121. let agent!: Agent
  122. const discarded: string[] = []
  123. ctx.on('agent/inbox/discard', (subject, messages) => {
  124. if (subject === agent) discarded.push(...messages.map(m => m.id))
  125. })
  126. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  127. agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
  128. }, { inject: ['agentLoop'] }))
  129. // A quiet (non-waking) item stays parked in the inbox; disposal must drop it
  130. // WITH a discard so its enqueued id is not left dangling forever.
  131. const id = agent.queue([{ type: 'text', text: 'never runs' }])
  132. await fiber.dispose()
  133. await driverDone(agent)
  134. expect(discarded).toEqual([id])
  135. })
  136. it('steer() throws after disposal', async () => {
  137. const adapter = new MockAdapter(['hang'])
  138. const ctx = await harness(adapter)
  139. let agent!: Agent
  140. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  141. agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
  142. }, { inject: ['agentLoop'] }))
  143. send(agent, 'go')
  144. await new Promise(r => setTimeout(r, 30))
  145. await fiber.dispose()
  146. await driverDone(agent)
  147. expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
  148. })
  149. it('inject() throws after disposal', async () => {
  150. const adapter = new MockAdapter(['hang'])
  151. const ctx = await harness(adapter)
  152. let agent!: Agent
  153. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  154. agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
  155. }, { inject: ['agentLoop'] }))
  156. send(agent, 'go')
  157. await new Promise(r => setTimeout(r, 30))
  158. await fiber.dispose()
  159. await driverDone(agent)
  160. expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
  161. })
  162. it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
  163. const adapter = new MockAdapter([textResponse('ok')])
  164. const ctx = await harness(adapter)
  165. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  166. // Status is idle while the log has an open turn; enclosure must follow the log.
  167. agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  168. agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
  169. expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
  170. expect(agent.session.events.at(-1)!.type).toBe('user/message')
  171. agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  172. agent.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } })
  173. const starts = agent.session.events.filter(e => e.type === 'turn/start')
  174. expect(starts).toHaveLength(2)
  175. const last = starts[1]!
  176. expect(last.type === 'turn/start' && last.data.trigger.kind).toBe('injection')
  177. expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
  178. })
  179. it('inject() defaults its source to an empty plugin, never user', async () => {
  180. const adapter = new MockAdapter([textResponse('ok')])
  181. const ctx = await harness(adapter)
  182. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  183. agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  184. agent.inject([{ type: 'text', text: 'no explicit source' }])
  185. const injected = agent.session.events.at(-1)!
  186. expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' })
  187. })
  188. it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => {
  189. const adapter = new MockAdapter([textResponse('ok')])
  190. const ctx = await harness(adapter)
  191. // A persistence-like listener whose flush rejects.
  192. ctx.on('session/flush', () => { throw new Error('disk gone') })
  193. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  194. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  195. // inject() is synchronous and fires a fire-and-forget flush; a rejecting
  196. // flush must be contained (logged), never thrown into the caller.
  197. expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
  198. await new Promise(r => setTimeout(r, 20)) // let the contained flush settle
  199. expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed'))
  200. warn.mockRestore()
  201. })
  202. it('idle inject() validates its payload BEFORE opening a turn, so invalid input appends nothing', async () => {
  203. const adapter = new MockAdapter([textResponse('ok')])
  204. const ctx = await harness(adapter)
  205. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  206. let flushes = 0
  207. ctx.on('session/flush', () => { flushes += 1 })
  208. // Non-serializable injected content is rejected by the up-front snapshot
  209. // BEFORE any append (the unified send contract: invalid input throws before
  210. // mutating the log). No one-shot turn opens and no durability checkpoint fires.
  211. expect(() => {
  212. agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
  213. }).toThrow(/losslessly JSON-serializable/)
  214. expect(agent.session.events).toHaveLength(0)
  215. await new Promise(r => setTimeout(r, 10)) // give any (erroneous) flush a chance
  216. expect(flushes).toBe(0) // nothing was appended, so no checkpoint
  217. })
  218. it('idle inject() re-entered from a session/event listener is rejected pre-commit and opens no turn', async () => {
  219. const adapter = new MockAdapter([textResponse('ok')])
  220. const ctx = await harness(adapter)
  221. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  222. let flushes = 0
  223. ctx.on('session/flush', () => { flushes += 1 })
  224. // Injecting from inside a session/event listener re-enters Session.append,
  225. // which rejects pre-commit — so turn/start never commits. The finally sees
  226. // no open turn (closes nothing) and no recorded turn (no checkpoint), and
  227. // the reentrant throw is contained by Session's post-commit dispatch.
  228. // Fire on turn/end: at that instant the outer one-shot turn is closed (no
  229. // turn open), so the reentrant inject takes the idle one-shot-turn path and
  230. // its turn/start append re-enters Session and is rejected pre-commit.
  231. let reentered = false
  232. ctx.on('session/event', (_s, event) => {
  233. if (!reentered && event.type === 'turn/end') {
  234. reentered = true
  235. agent.inject([{ type: 'text', text: 'reentrant' }], { source: { kind: 'plugin', plugin: 'p' } })
  236. }
  237. })
  238. agent.inject([{ type: 'text', text: 'outer' }], { source: { kind: 'plugin', plugin: 'p' } })
  239. // The outer injection's own one-shot turn is balanced; the reentrant one
  240. // opened no turn (its turn/start was rejected pre-commit).
  241. const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
  242. expect(turnStarts).toHaveLength(1)
  243. const injected = agent.session.events.filter(e => e.type === 'user/message')
  244. expect(injected).toHaveLength(1) // the reentrant user/message never committed
  245. await new Promise(r => setTimeout(r, 10))
  246. expect(flushes).toBe(1) // only the outer accepted turn checkpointed
  247. })
  248. it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
  249. const adapter = new MockAdapter([textResponse('ok')])
  250. const ctx = await harness(adapter)
  251. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  252. let flushes = 0
  253. ctx.on('session/flush', () => { flushes += 1 })
  254. // Session contains a throwing post-commit turn/end observer. The accepted
  255. // boundary still triggers the idle injection's durability checkpoint.
  256. let threw = false
  257. ctx.on('session/event', (_s, event) => {
  258. if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
  259. })
  260. expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
  261. const types = agent.session.events.map(e => e.type)
  262. expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced
  263. await new Promise(r => setTimeout(r, 10))
  264. expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener
  265. })
  266. it('idle inject() reports a failing flush via agent/error (step 0) AND the logger', async () => {
  267. const adapter = new MockAdapter([textResponse('ok')])
  268. const ctx = await harness(adapter)
  269. // A non-Error rejection exercises the String() normalization branch.
  270. ctx.on('session/flush', () => { throw 'disk gone' })
  271. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  272. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  273. const errors: { turn: number; step: number; message: string }[] = []
  274. ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
  275. agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } })
  276. await new Promise(r => setTimeout(r, 20)) // let the contained flush settle
  277. // Reported via agent/error (step 0 — the idle-injection convention) so
  278. // plugins monitoring agent/error see idle-injection persistence failures,
  279. // mirroring the loop's post-turn/end flush path. A non-Error throw is
  280. // normalized to an Error.
  281. expect(errors).toEqual([{ turn: 1, step: 0, message: 'disk gone' }])
  282. expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed'))
  283. warn.mockRestore()
  284. })
  285. it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
  286. const adapter = new MockAdapter([textResponse('ok')])
  287. const ctx = await harness(adapter)
  288. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  289. // A non-serializable source is rejected by the up-front snapshot BEFORE any
  290. // append, so NO turn opens and the log stays empty.
  291. expect(() => {
  292. agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
  293. }).toThrow(/losslessly JSON-serializable/)
  294. expect(agent.session.events).toHaveLength(0)
  295. })
  296. it('steer() when idle falls through to send() and starts a turn', async () => {
  297. const adapter = new MockAdapter([textResponse('ok')])
  298. const ctx = await harness(adapter)
  299. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  300. agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
  301. await waitForIdle(ctx, agent)
  302. expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
  303. expect(adapter.requests).toHaveLength(1)
  304. })
  305. it('disposer is idempotent (double-stop)', async () => {
  306. // Create a bare Agent and start it through the package-internal
  307. // test seam. Then call its disposer twice — the second call hits the
  308. // early-return branch.
  309. const ctx = new Context()
  310. await ctx.plugin(SessionStore)
  311. await ctx.plugin(AgentRegistry)
  312. const session = ctx.sessions.create(SessionId('test'))
  313. const prepared = prepareReactLoopAgent(
  314. ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
  315. )
  316. const { agent } = prepared
  317. // Start the loop to get the disposer; the agent waits for messages
  318. // (idle, never-resolving cancel), so it will stay idle.
  319. prepared.markPublished()
  320. const dispose = prepared.startDriver()
  321. const firstDisposal = dispose()
  322. expect(agent.status).toBe('disposed')
  323. await firstDisposal
  324. await expect(dispose()).resolves.toBeUndefined()
  325. expect(agent.status).toBe('disposed')
  326. })
  327. it('a pre-start disposal makes a later driver-start attempt inert', async () => {
  328. const ctx = new Context()
  329. await ctx.plugin(SessionStore)
  330. const session = ctx.sessions.create(SessionId('pre-start-dispose'))
  331. const prepared = prepareReactLoopAgent(
  332. ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
  333. )
  334. await prepared.dispose()
  335. expect(prepared.agent.status).toBe('disposed')
  336. const dispose = prepared.startDriver()
  337. await dispose()
  338. await expect(prepared.agent.done).resolves.toBeUndefined()
  339. expect(prepared.agent.session.events).toEqual([])
  340. await ctx.fiber.dispose()
  341. })
  342. it('setting the same status does not emit agent/status again', async () => {
  343. const adapter = new MockAdapter([textResponse('ok')])
  344. const ctx = await harness(adapter)
  345. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  346. const statuses: string[] = []
  347. ctx.on('agent/status', (subject, status) => {
  348. if (subject === agent) statuses.push(status)
  349. })
  350. send(agent, 'hi')
  351. await waitForIdle(ctx, agent)
  352. // After the turn, agent is idle. Send again to trigger another attempt
  353. // to go idle — but it's already idle, so no emission.
  354. const idleTransitionCount = statuses.filter(s => s === 'idle').length
  355. expect(idleTransitionCount).toBe(1) // only the final transition from running
  356. })
  357. it('whenIdle() resolves immediately when the agent is not running', async () => {
  358. const adapter = new MockAdapter([textResponse('ok')])
  359. const ctx = await harness(adapter)
  360. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  361. // Fresh agent is idle — whenIdle() takes the not-running fast path and
  362. // resolves without subscribing. await must not hang.
  363. await agent.whenIdle()
  364. expect(agent.status).not.toBe('running')
  365. })
  366. it('whenIdle() waits for queued work that has not flipped status yet', async () => {
  367. const adapter = new MockAdapter(['hang'])
  368. const ctx = await harness(adapter)
  369. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  370. send(agent, 'queued')
  371. let settled = false
  372. const idle = agent.whenIdle().then(() => { settled = true })
  373. await Promise.resolve()
  374. expect(settled).toBe(false)
  375. await waitForStatus(ctx, agent, 'running')
  376. agent.cancel({ kind: 'user' })
  377. await idle
  378. expect(settled).toBe(true)
  379. expect(agent.status).toBe('idle')
  380. })
  381. it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
  382. const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
  383. const ctx = await harness(adapter)
  384. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  385. const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
  386. // Drive `agent` into `running`, then await whenIdle() — it subscribes to
  387. // agent/status and resolves on the first transition out of running.
  388. const running = new Promise<void>((resolve) => {
  389. const dispose = ctx.on('agent/status', (subject, status) => {
  390. if (subject === agent && status === 'running') { dispose(); resolve() }
  391. })
  392. })
  393. send(agent, 'go')
  394. await running
  395. expect(agent.status).toBe('running')
  396. // While `agent`'s whenIdle is pending, churn `other` through running→idle:
  397. // every status event it emits hits whenIdle's guard with `subject !== this`,
  398. // so the wait must ignore them and only resolve on `agent`'s own idle.
  399. send(other, 'go')
  400. await agent.whenIdle()
  401. expect(agent.status).toBe('idle')
  402. })
  403. it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
  404. // Covers the waiter's disposed arm: whenIdle() queues an internal waiter
  405. // while running (not the fast path), then the disposer settles it and chains
  406. // `done` (loop exit), not an eager resolve. A bare Agent + direct
  407. // internal driver disposer keeps the emit synchronous.
  408. const ctx = new Context()
  409. await ctx.plugin(LlmService)
  410. await ctx.plugin(SessionStore)
  411. await ctx.plugin(SystemPrompt)
  412. await ctx.plugin(ToolRegistry)
  413. await ctx.plugin(AgentRegistry)
  414. const adapter = new MockAdapter(['hang'])
  415. ctx.llm.registerAdapter(['mock'], adapter)
  416. const session = ctx.sessions.create(SessionId('bare'))
  417. const prepared = prepareReactLoopAgent(
  418. ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
  419. )
  420. const { agent } = prepared
  421. prepared.markPublished()
  422. const dispose = prepared.startDriver()
  423. agent.followup([{ type: 'text', text: 'go' }])
  424. await new Promise(r => setTimeout(r, 30))
  425. expect(agent.status).toBe('running')
  426. const idle = agent.whenIdle() // queues an internal waiter (running)
  427. const disposal = dispose() // settles the waiter synchronously; whenIdle chains done
  428. await idle
  429. expect(agent.status).toBe('disposed')
  430. await disposal
  431. })
  432. it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
  433. // The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
  434. // disposing the OWNING fiber runs the agent's listener disposers, which would
  435. // have dropped a ctx.on-based waiter before the 'disposed' transition and
  436. // hung the promise. With internal waiters, the fiber disposer still settles it.
  437. const adapter = new MockAdapter(['hang'])
  438. const ctx = await harness(adapter)
  439. let agent!: Agent
  440. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  441. agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
  442. }, { inject: ['agentLoop'] }))
  443. send(agent, 'go')
  444. await new Promise(r => setTimeout(r, 30))
  445. expect(agent.status).toBe('running')
  446. const idle = agent.whenIdle() // queued while running
  447. await fiber.dispose() // tears the fiber down (drops agent listeners)
  448. await idle // must resolve, not hang
  449. expect(agent.status).toBe('disposed')
  450. })
  451. it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
  452. // The disposer emits agent/status('disposed') BEFORE the driver loop
  453. // unwinds, so whenIdle() must chain `done` (true quiescence) on the
  454. // disposed path. Dispose a running agent, then assert whenIdle() resolves
  455. // only after `done` — i.e. the loop has actually exited.
  456. const adapter = new MockAdapter(['hang'])
  457. const ctx = await harness(adapter)
  458. let agent!: Agent
  459. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  460. agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
  461. }, { inject: ['agentLoop'] }))
  462. send(agent, 'go')
  463. await new Promise(r => setTimeout(r, 30))
  464. let doneResolved = false
  465. void driverDone(agent).then(() => { doneResolved = true })
  466. await fiber.dispose() // sets status disposed, aborts, drains the loop
  467. expect(agent.status).toBe('disposed')
  468. // whenIdle() must not resolve before `done` has — chaining `done` is the
  469. // quiescence guarantee. By here dispose() awaited the loop, so done is
  470. // settled; whenIdle resolves and done is observed resolved.
  471. await agent.whenIdle()
  472. expect(doneResolved).toBe(true)
  473. })
  474. it('contains a throwing agent/status listener on the running transition', async () => {
  475. const adapter = new MockAdapter([textResponse('ok')])
  476. const ctx = await harness(adapter)
  477. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  478. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  479. ctx.on('agent/status', (_subject, status) => {
  480. if (status === 'running') throw new Error('bad running listener')
  481. })
  482. send(agent, 'go')
  483. await agent.whenIdle()
  484. expect(adapter.requests).toHaveLength(1)
  485. expect(agent.status).toBe('idle')
  486. expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
  487. warn.mockRestore()
  488. })
  489. it('contains a throwing agent/status listener on the idle transition', async () => {
  490. const adapter = new MockAdapter([textResponse('ok')])
  491. const ctx = await harness(adapter)
  492. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  493. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  494. ctx.on('agent/status', (_subject, status) => {
  495. if (status === 'idle') throw new Error('bad idle listener')
  496. })
  497. send(agent, 'go')
  498. await agent.whenIdle()
  499. expect(adapter.requests).toHaveLength(1)
  500. expect(agent.status).toBe('idle')
  501. expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
  502. warn.mockRestore()
  503. })
  504. })