dispose.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  2. import { mkdtemp, rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
  6. import { SessionId } from '@deepseek-ai/dsh-session'
  7. import { makeBridgeHarness, textResponse } from './harness.ts'
  8. describe('acp bridge — disposal & HMR safety', () => {
  9. let storageDir: string
  10. beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-dispose-')) })
  11. afterEach(async () => { await rm(storageDir, { recursive: true, force: true }) })
  12. it('disposal reaches quiescence: a running turn is aborted and awaited before dispose returns', async () => {
  13. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
  14. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  15. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  16. const agent = harness.ctx.agents.get(SessionId(sessionId))!
  17. // Start a prompt that hangs in the model stream.
  18. const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
  19. await new Promise(r => setTimeout(r, 30))
  20. expect(agent.status).toBe('running')
  21. // Dispose the whole context. The bridge's teardown must abort the agent and
  22. // AWAIT whenIdle() — so right after dispose resolves, the agent is settled
  23. // (not still running). Proves disposal waited, not just requested.
  24. await harness.ctx.fiber.dispose()
  25. expect(agent.status).not.toBe('running')
  26. // The in-flight prompt settled (cancelled) rather than hanging forever.
  27. const res = await promptDone
  28. expect(res.stopReason).toBe('cancelled')
  29. })
  30. it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => {
  31. // Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop
  32. // stay up and the transport is still live. A late session/new must hit the
  33. // `closed` guard and reject — NOT create an agent the disposed bridge can no
  34. // longer stream or settle. Verify the world: no agent appeared.
  35. const harness = await makeBridgeHarness({ storageDir, script: [] })
  36. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  37. const before = harness.ctx.agents.list().length
  38. await harness.acpFiber.dispose() // tear down ONLY the bridge
  39. await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }))
  40. .rejects.toThrow(/disposed/)
  41. expect(harness.ctx.agents.list().length).toBe(before)
  42. await harness.dispose()
  43. })
  44. it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
  45. // The factory (`ctx.agents.create`) is reached through the bridge's
  46. // traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
  47. // registration binds to the CALLER context — the bridge fiber — not the
  48. // AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
  49. // must therefore reclaim the agent's registry entry, even though agents/
  50. // agent-loop stay up. This pins the fiber-ownership the bridge's teardown
  51. // doc comment relies on; if a refactor rebinds the registration to the
  52. // AgentLoop fiber, the agent would survive bridge dispose and this fails.
  53. const harness = await makeBridgeHarness({ storageDir, script: [] })
  54. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  55. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  56. expect(harness.ctx.agents.get(SessionId(sessionId))).toBeDefined()
  57. await harness.acpFiber.dispose() // tear down ONLY the bridge
  58. expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
  59. await harness.dispose()
  60. })
  61. it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
  62. // After teardown (here a client disconnect sets `closed`), a late
  63. // `session/new` must NOT create an orphan agent the bridge can no longer
  64. // drive/settle. The transport is gone so the RPC rejects; assert the world:
  65. // no new agent appeared in the registry.
  66. const harness = await makeBridgeHarness({ storageDir, script: [] })
  67. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  68. const before = harness.ctx.agents.list().length
  69. await harness.closeClientTransport() // teardown → closed = true
  70. await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }).catch(() => {})
  71. await new Promise(r => setTimeout(r, 10))
  72. expect(harness.ctx.agents.list().length).toBe(before)
  73. await harness.dispose()
  74. })
  75. it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => {
  76. // The ACP transport closes (editor quits) while a turn runs. The bridge must
  77. // settle the in-flight prompt cancelled and DISPOSE the agent (the session's
  78. // per-agent AgentHandle teardown) rather than leaving an orphaned running —
  79. // or even idled-but-still-registered — agent whose updates are swallowed.
  80. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
  81. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  82. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  83. const agent = harness.ctx.agents.get(SessionId(sessionId))!
  84. // Start a prompt that hangs in the model stream. The prompt RPC will never
  85. // return (its transport is severed), so do not await it.
  86. void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
  87. await new Promise(r => setTimeout(r, 30))
  88. expect(agent.status).toBe('running')
  89. // Sever the transport — the bridge's conn.closed teardown runs and drives the
  90. // agent's AgentHandle dispose to quiescence on its OWN (before any dispose()).
  91. await harness.closeClientTransport()
  92. await agent.whenIdle()
  93. // The retired object is quiescent; registry membership carries liveness.
  94. expect(agent.status).toBe('idle')
  95. // Await the bridge teardown to completion WITHOUT tearing down the root
  96. // agents/sessions services (so we can still query them). acpFiber.dispose()
  97. // invokes the SAME memoized quiesce() the disconnect started and awaits its
  98. // promise — which resolves only after every rec.dispose() (loop exit +
  99. // session removal) has finished, closing the whenIdle()/owned.dispose()
  100. // microtask race. The AgentHandle dispose has run: the agent is unregistered
  101. // and its session removed from the store, not merely idled (the old
  102. // behavior). The services live on the root ctx, so they survive this.
  103. await harness.acpFiber.dispose()
  104. expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
  105. expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
  106. await harness.dispose()
  107. })
  108. it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => {
  109. // conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously.
  110. // They must share one teardown promise: dispose() must NOT return before the
  111. // disconnect teardown's whenIdle() has settled (a `record === undefined`-only
  112. // guard would let the second caller return early mid-teardown).
  113. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
  114. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  115. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  116. const agent = harness.ctx.agents.get(SessionId(sessionId))!
  117. void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
  118. await new Promise(r => setTimeout(r, 30))
  119. expect(agent.status).toBe('running')
  120. // Fire both teardown paths without awaiting the first, then await both.
  121. const close = harness.closeClientTransport()
  122. const dispose = harness.ctx.fiber.dispose()
  123. await Promise.all([close, dispose])
  124. // After BOTH settle, the agent has fully drained (not still running).
  125. expect(agent.status).not.toBe('running')
  126. })
  127. it('after dispose, session/update listeners are gone (no further updates emitted)', async () => {
  128. const harness = await makeBridgeHarness({ storageDir, script: [] })
  129. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  130. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  131. const session = harness.ctx.agents.get(SessionId(sessionId))!.session
  132. await harness.ctx.fiber.dispose()
  133. const before = harness.updates.length
  134. // Append an event directly to the (now-detached) session: the bridge's
  135. // session/event listener should have been disposed, so no update fires.
  136. session.append('turn/start', { turn: 99, trigger: { kind: 'message', source: { kind: 'user' } } })
  137. await new Promise(r => setTimeout(r, 10))
  138. expect(harness.updates.length).toBe(before)
  139. })
  140. it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
  141. // The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
  142. // AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
  143. // through the still-attached store observer → `session/event`), and only
  144. // THEN remove its publication hooks and session entry. If the order were inverted
  145. // (detach first), the closing events would never reach persistence. Drive a
  146. // CLEAN turn to completion, dispose JUST the bridge, then re-load the
  147. // persisted log from disk and assert the closing turn/end is on disk — the
  148. // world, not the agent's self-report.
  149. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] })
  150. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  151. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  152. await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
  153. const liveEvents = harness.ctx.agents.get(SessionId(sessionId))!.session.events.length
  154. expect(liveEvents).toBeGreaterThan(0)
  155. // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence).
  156. await harness.acpFiber.dispose()
  157. expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
  158. // Re-load the session from disk: every live event (incl. the closing
  159. // turn/end) was flushed before the session was detached.
  160. const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId))
  161. expect(reloaded.events.length).toBe(liveEvents)
  162. const last = reloaded.events.at(-1)!
  163. expect(last.type).toBe('turn/end')
  164. await harness.dispose()
  165. })
  166. it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => {
  167. // The teardown-order contract only earns its keep when the closing events are
  168. // produced BY the dispose itself. Here the model stream HANGS, so the turn is
  169. // still open when teardown runs: the composite agent effect stops the loop,
  170. // the loop unwinds and appends `turn/end {disposed}` + runs its final
  171. // `session/flush` — all while the store-owned publication hooks are still attached (the session
  172. // detach is the LAST disposer in the same effect's LIFO chain) — and only
  173. // THEN is the session detached. If the order were inverted (or the session
  174. // were a racing SIBLING effect), the abort-produced `turn/end` would never
  175. // reach disk and a re-load would instead show crash-recovery's synthetic
  176. // `interrupted` closer. Re-load from disk and assert the REAL `disposed`
  177. // reason landed — proving the loop's own closing event was captured, not a
  178. // recovered substitute.
  179. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
  180. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  181. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  182. const agent = harness.ctx.agents.get(SessionId(sessionId))!
  183. void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
  184. await new Promise(r => setTimeout(r, 30))
  185. expect(agent.status).toBe('running')
  186. // The turn is OPEN in the log (turn/start appended, no turn/end yet).
  187. const openTurnEnds = agent.session.events.filter(e => e.type === 'turn/end').length
  188. // Dispose JUST the bridge: a fiber unload that must STILL honor the ordered
  189. // teardown (the composite effect runs its disposer chain as a unit).
  190. await harness.acpFiber.dispose()
  191. expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
  192. // The loop's own `turn/end {disposed}` is on disk (re-load: the world, not
  193. // self-report) — NOT a crash-recovery `interrupted` substitute.
  194. const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId))
  195. const persistedTurnEnds = reloaded.events.filter(e => e.type === 'turn/end')
  196. expect(persistedTurnEnds.length).toBe(openTurnEnds + 1)
  197. expect(persistedTurnEnds.at(-1)!.data.reason).toMatchObject({ kind: 'disposed' })
  198. await harness.dispose()
  199. })
  200. it('per-session AgentHandle dispose leaves sibling agents untouched', async () => {
  201. // The factory returns a per-agent AgentHandle whose dispose() tears down
  202. // EXACTLY that agent + its session — the registry's per-handle isolation
  203. // contract. Create two agents
  204. // directly through the registry factory (the same path the ACP bridge uses),
  205. // dispose one handle, and assert the other survives, registered and
  206. // queryable, with its session still in the store.
  207. const harness = await makeBridgeHarness({ storageDir, script: [] })
  208. const handleA = await harness.ctx.agents.create({
  209. sessionId: SessionId('sib-a'), agentOptions: { provider: 'mock', model: 'mock' },
  210. })
  211. const handleB = await harness.ctx.agents.create({
  212. sessionId: SessionId('sib-b'), agentOptions: { provider: 'mock', model: 'mock' },
  213. })
  214. expect(harness.ctx.agents.get(SessionId('sib-a'))).toBe(handleA.agent)
  215. expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
  216. await handleA.dispose()
  217. // A is gone — unregistered AND its session removed from the store.
  218. expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined()
  219. expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
  220. expect(handleA.agent.status).toBe('idle')
  221. // B is wholly unaffected.
  222. expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
  223. expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
  224. expect(handleB.agent.status).toBe('idle')
  225. await harness.dispose()
  226. })
  227. it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => {
  228. // The AgentHandle teardown folds session-detach, register, and loop-stop
  229. // into ONE composite effect whose disposers run as a `.then()` chain. The
  230. // register disposer emits `agent/disposed`; if a listener throws and the
  231. // emit is UNCONTAINED, the rejected chain skips the LATER session-detach
  232. // disposer — stranding the session in the store with its publication hooks attached (a
  233. // leak AND a durability hole, since the new design relies on detach
  234. // running). The emit must be contained. Register a throwing listener, drive
  235. // a clean turn, dispose, and assert the session was STILL removed.
  236. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
  237. harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
  238. const handle = await harness.ctx.agents.create({
  239. sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' },
  240. })
  241. handle.agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
  242. await handle.agent.whenIdle()
  243. expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined()
  244. // Dispose: the throwing listener must NOT break the chain before detach.
  245. await handle.dispose()
  246. expect(harness.ctx.agents.get(SessionId('guard-a'))).toBeUndefined()
  247. expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran
  248. await harness.dispose()
  249. })
  250. it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => {
  251. // The handle's dispose() must memoize: the underlying cordis effect disposer
  252. // is single-shot, so a second dispose() while the first is mid-teardown would
  253. // otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the
  254. // first call's await agent.done + final flush finished. Every caller must
  255. // observe the same quiescence boundary.
  256. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
  257. const handle = await harness.ctx.agents.create({
  258. sessionId: SessionId('conc-a'), agentOptions: { provider: 'mock', model: 'mock' },
  259. })
  260. // Drive a turn that hangs in the model stream, so the loop is mid-turn when
  261. // disposed — its exit runs a final session/flush we can gate to hold the
  262. // teardown observably in-flight.
  263. handle.agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
  264. await new Promise(r => setTimeout(r, 30))
  265. expect(handle.agent.status).toBe('running')
  266. // Both callers join the same teardown and observe registry removal.
  267. const first = handle.dispose()
  268. const second = handle.dispose()
  269. await Promise.all([first, second])
  270. expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined()
  271. expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined()
  272. await harness.dispose()
  273. })
  274. })