resume.spec.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { mkdtemp, rm } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import LlmService from '@deepseek-ai/dsh-llm'
  7. import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
  8. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  9. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRegistry from '@deepseek-ai/dsh-tools'
  11. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  12. import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
  13. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  14. import { MockAdapter, textResponse } from './mock-adapter.ts'
  15. const dirs: string[] = []
  16. afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
  17. async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
  18. const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
  19. dirs.push(root)
  20. return { ctx: await mountPersistentHarness(root, adapter), root }
  21. }
  22. async function mountPersistentHarness(root: string, adapter: MockAdapter): Promise<Context> {
  23. const ctx = new Context()
  24. await ctx.plugin(LlmService)
  25. await ctx.plugin(SessionStore)
  26. await ctx.plugin(SystemPrompt)
  27. await ctx.plugin(ToolRegistry)
  28. await ctx.plugin(AgentRegistry)
  29. await ctx.plugin(AgentLoop, { agents: [] })
  30. await ctx.plugin(SessionPersistenceJsonl, { root })
  31. ctx.llm.registerAdapter(['mock'], adapter)
  32. return ctx
  33. }
  34. async function persistSession(sessionId: SessionId): Promise<string> {
  35. const { ctx, root } = await persistentHarness(new MockAdapter([textResponse('seed')]))
  36. // Persistence deliberately has no artifact for a truly empty session. A
  37. // balanced completed turn is the smallest resumable log and avoids running
  38. // the model merely to construct this lifecycle fixture.
  39. const seed: SessionEvent[] = [
  40. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  41. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
  42. ]
  43. const session = ctx.sessions.create(sessionId, { seed })
  44. await ctx.sessions.flush(session)
  45. await ctx.fiber.dispose()
  46. return root
  47. }
  48. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  49. return new Promise((resolve) => {
  50. const dispose = ctx.on('agent/status', (subject, status) => {
  51. if (subject === agent && status === 'idle') { dispose(); resolve() }
  52. })
  53. })
  54. }
  55. /** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
  56. async function promptly<T>(task: Promise<T>): Promise<T> {
  57. const timeout = Promise.withResolvers<never>()
  58. const timer = setTimeout(() => { timeout.reject(new Error('lifecycle task did not settle promptly')) }, 1000)
  59. try {
  60. return await Promise.race([task, timeout.promise])
  61. } finally {
  62. clearTimeout(timer)
  63. }
  64. }
  65. /** Throw an arbitrary callback value to exercise the public unknown-error boundary. */
  66. function throwUnknown(value: unknown): never {
  67. throw value
  68. }
  69. describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => {
  70. it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => {
  71. const sessionId = SessionId('unknown-resume-failure-s')
  72. const root = await persistSession(sessionId)
  73. const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
  74. const failure = { source: 'resume' }
  75. ctx.on('session/created', () => throwUnknown(failure))
  76. await expect(ctx.agents.resume({
  77. resumeSessionId: sessionId,
  78. })).rejects.toBe(failure)
  79. expect(ctx.agents.get(SessionId('unknown-resume-failure'))).toBeUndefined()
  80. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  81. await ctx.fiber.dispose()
  82. })
  83. it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
  84. const adapter = new MockAdapter([textResponse('hi')])
  85. const { ctx } = await persistentHarness(adapter)
  86. const { agent } = await ctx.agents.create({ sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
  87. expect(agent.session.id).toBe('custom-session')
  88. expect(agent.session.header.cwd).toBe('/w')
  89. await ctx.fiber.dispose()
  90. })
  91. it('createAgent rejects a duplicate identity without orphaning a session', async () => {
  92. const adapter = new MockAdapter([textResponse('hi')])
  93. const { ctx } = await persistentHarness(adapter)
  94. const sessionId = SessionId('sess-a')
  95. await ctx.agents.create({ sessionId })
  96. await expect(ctx.agents.create({ sessionId })).rejects.toThrow(/already exists/)
  97. expect(ctx.sessions.list()).toHaveLength(1)
  98. await ctx.fiber.dispose()
  99. })
  100. it('resume cannot crash-repair a turn owned by a live agent', async () => {
  101. const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')]))
  102. const sessionId = SessionId('live-resume-race')
  103. const first = (await ctx.agents.create({ sessionId })).agent
  104. first.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  105. await ctx.sessions.flush(first.session)
  106. await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
  107. .rejects.toThrow(/live turn is open/)
  108. first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  109. await ctx.sessions.flush(first.session)
  110. const loaded = await ctx.sessionPersistence.load(sessionId)
  111. expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
  112. expect(loaded.events.at(-1)).toMatchObject({
  113. type: 'turn/end',
  114. data: { reason: { kind: 'completed' } },
  115. })
  116. await ctx.fiber.dispose()
  117. })
  118. it('createAgent works without meta (no cwd)', async () => {
  119. const adapter = new MockAdapter([textResponse('hi')])
  120. const { ctx } = await persistentHarness(adapter)
  121. const { agent } = await ctx.agents.create({ sessionId: SessionId('nometa-session') })
  122. expect(agent.session.id).toBe('nometa-session')
  123. expect(agent.session.header.cwd).toBeUndefined()
  124. await ctx.fiber.dispose()
  125. })
  126. it('resume of a session with no cwd carries an undefined cwd header', async () => {
  127. // Lifecycle 1: create a no-cwd session and run a turn.
  128. const adapter1 = new MockAdapter([textResponse('a')])
  129. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  130. const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
  131. a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
  132. await waitForIdle(ctx1, a1)
  133. await ctx1.fiber.dispose()
  134. // Lifecycle 2: resume it; the header cwd stays undefined (no-cwd branch).
  135. const adapter2 = new MockAdapter([textResponse('b')])
  136. const ctx2 = new Context()
  137. await ctx2.plugin(LlmService)
  138. await ctx2.plugin(SessionStore)
  139. await ctx2.plugin(SystemPrompt)
  140. await ctx2.plugin(ToolRegistry)
  141. await ctx2.plugin(AgentRegistry)
  142. await ctx2.plugin(AgentLoop, { agents: [] })
  143. await ctx2.plugin(SessionPersistenceJsonl, { root })
  144. ctx2.llm.registerAdapter(['mock'], adapter2)
  145. const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent
  146. expect(a2.session.header.cwd).toBeUndefined()
  147. await ctx2.fiber.dispose()
  148. })
  149. it('agent/session-start fires "startup" for createAgent and "resume" for resume()', async () => {
  150. // Lifecycle 1: a fresh createAgent emits session-start with source 'startup'.
  151. const adapter1 = new MockAdapter([textResponse('a')])
  152. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  153. const sources1: string[] = []
  154. ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
  155. const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
  156. expect(sources1).toEqual(['startup'])
  157. a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
  158. await waitForIdle(ctx1, a1)
  159. await ctx1.fiber.dispose()
  160. // Lifecycle 2: resuming the persisted session emits session-start 'resume'.
  161. const adapter2 = new MockAdapter([textResponse('b')])
  162. const ctx2 = new Context()
  163. await ctx2.plugin(LlmService)
  164. await ctx2.plugin(SessionStore)
  165. await ctx2.plugin(SystemPrompt)
  166. await ctx2.plugin(ToolRegistry)
  167. await ctx2.plugin(AgentRegistry)
  168. await ctx2.plugin(AgentLoop, { agents: [] })
  169. await ctx2.plugin(SessionPersistenceJsonl, { root })
  170. ctx2.llm.registerAdapter(['mock'], adapter2)
  171. const sources2: string[] = []
  172. ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source))
  173. await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') })
  174. expect(sources2).toEqual(['resume'])
  175. await ctx2.fiber.dispose()
  176. })
  177. it('resume awaits setup while unpublished, then publishes a fully composed world in order', async () => {
  178. const sessionId = SessionId('resume-setup-success')
  179. const root = await persistSession(sessionId)
  180. const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
  181. const gate = Promise.withResolvers<undefined>()
  182. const setupStarted = Promise.withResolvers<undefined>()
  183. const order: string[] = []
  184. ctx.on('session/created', (session) => {
  185. expect(ctx.sessions.get(session.id)).toBe(session)
  186. expect(ctx.agents.get(sessionId)?.session).toBe(session)
  187. order.push('session/created')
  188. })
  189. ctx.on('agent/created', (agent) => {
  190. expect(agent.status).toBe('idle')
  191. order.push('agent/created')
  192. })
  193. ctx.on('agent/session-start', (agent) => {
  194. expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow()
  195. order.push('agent/session-start')
  196. })
  197. const resuming = ctx.agents.resume({
  198. resumeSessionId: sessionId,
  199. agentOptions: { provider: 'mock', model: 'mock' },
  200. setup: async (agentCtx) => {
  201. expect(agentCtx.agent?.id).toBe(sessionId)
  202. expect(agentCtx.agent?.session.events).toHaveLength(2)
  203. agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
  204. agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
  205. order.push('setup:start')
  206. setupStarted.resolve(undefined)
  207. await gate.promise
  208. order.push('setup:end')
  209. },
  210. })
  211. await setupStarted.promise
  212. expect(ctx.agents.get(sessionId)).toBeUndefined()
  213. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  214. expect(order).toEqual(['setup:start'])
  215. gate.resolve(undefined)
  216. const handle = await resuming
  217. expect(order).toEqual([
  218. 'setup:start',
  219. 'setup:end',
  220. 'session/created',
  221. 'setup-listener:session/created',
  222. 'agent/created',
  223. 'setup-listener:agent/created',
  224. 'agent/session-start',
  225. ])
  226. await handle.dispose()
  227. await ctx.fiber.dispose()
  228. })
  229. it('successful resume disposal retires its caller-owned transaction effects', async () => {
  230. const sessionId = SessionId('resume-retired-effects-s')
  231. const root = await persistSession(sessionId)
  232. const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
  233. const handle = await ctx.agents.resume({
  234. resumeSessionId: sessionId,
  235. agentOptions: { provider: 'mock', model: 'mock' },
  236. })
  237. const transactionLabels = [`agentLoop.lifecycle(${sessionId})`]
  238. expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
  239. await handle.dispose()
  240. expect(ctx.fiber.getEffects().filter(effect => transactionLabels.includes(effect.label))).toEqual([])
  241. await ctx.fiber.dispose()
  242. })
  243. it('resume setup rejection publishes nothing, unwinds, and releases the identity', async () => {
  244. const sessionId = SessionId('resume-setup-reject')
  245. const root = await persistSession(sessionId)
  246. const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
  247. const published: string[] = []
  248. ctx.on('session/created', () => void published.push('session/created'))
  249. ctx.on('agent/created', () => void published.push('agent/created'))
  250. ctx.on('agent/session-start', () => void published.push('agent/session-start'))
  251. await expect(ctx.agents.resume({
  252. resumeSessionId: sessionId,
  253. agentOptions: { provider: 'mock', model: 'mock' },
  254. setup: async () => {
  255. await Promise.resolve()
  256. throw new Error('resume setup failed')
  257. },
  258. })).rejects.toThrow('resume setup failed')
  259. expect(published).toEqual([])
  260. expect(ctx.agents.get(sessionId)).toBeUndefined()
  261. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  262. const retry = await ctx.agents.resume({
  263. resumeSessionId: sessionId,
  264. agentOptions: { provider: 'mock', model: 'mock' },
  265. })
  266. await retry.dispose()
  267. await ctx.fiber.dispose()
  268. })
  269. it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
  270. const sessionId = SessionId('resume-setup-owner-unload')
  271. const root = await persistSession(sessionId)
  272. const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
  273. const gate = Promise.withResolvers<undefined>()
  274. const setupStarted = Promise.withResolvers<undefined>()
  275. const published: string[] = []
  276. ctx.on('session/created', () => void published.push('session/created'))
  277. ctx.on('agent/created', () => void published.push('agent/created'))
  278. let resuming!: ReturnType<typeof ctx.agents.resume>
  279. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  280. resuming = inner.agents.resume({
  281. resumeSessionId: sessionId,
  282. agentOptions: { provider: 'mock', model: 'mock' },
  283. setup: async () => {
  284. setupStarted.resolve(undefined)
  285. await gate.promise
  286. },
  287. })
  288. }, { inject: ['agents'] }))
  289. await setupStarted.promise
  290. await owner.dispose()
  291. await expect(resuming).rejects.toThrow(/owner disposed during setup/)
  292. expect(published).toEqual([])
  293. expect(ctx.agents.get(sessionId)).toBeUndefined()
  294. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  295. gate.resolve(undefined)
  296. await Promise.resolve()
  297. expect(published).toEqual([])
  298. await ctx.fiber.dispose()
  299. })
  300. it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => {
  301. const sessionId = SessionId('resume-load-owner-unload')
  302. const root = await persistSession(sessionId)
  303. const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
  304. const snapshot = await ctx.sessionPersistence.load(sessionId)
  305. const lateLoad = Promise.withResolvers<typeof snapshot>()
  306. const loadStarted = Promise.withResolvers<undefined>()
  307. let loads = 0
  308. ctx.sessionPersistence.load = (id) => {
  309. expect(id).toBe(sessionId)
  310. loads += 1
  311. if (loads === 1) {
  312. loadStarted.resolve(undefined)
  313. return lateLoad.promise
  314. }
  315. return Promise.resolve(structuredClone(snapshot))
  316. }
  317. const published: string[] = []
  318. ctx.on('session/created', () => void published.push('session/created'))
  319. ctx.on('agent/created', () => void published.push('agent/created'))
  320. ctx.on('agent/session-start', () => void published.push('agent/session-start'))
  321. let resuming!: ReturnType<typeof ctx.agents.resume>
  322. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  323. resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
  324. }, { inject: ['agents'] }))
  325. await loadStarted.promise
  326. const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
  327. await promptly(owner.dispose())
  328. expect(published).toEqual([])
  329. expect(ctx.agents.get(sessionId)).toBeUndefined()
  330. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  331. // owner.dispose() awaited transaction settlement, so the same identities
  332. // can be reused before awaiting the public rejection.
  333. const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }))
  334. await rejection
  335. expect(loads).toBe(2)
  336. expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
  337. // Settlement of the abandoned backend promise cannot resume the old
  338. // transaction or emit a second publication after the retry owns the ids.
  339. lateLoad.resolve(structuredClone(snapshot))
  340. await Promise.resolve()
  341. await Promise.resolve()
  342. expect(ctx.agents.get(sessionId)).toBe(retry.agent)
  343. expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
  344. expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
  345. await retry.dispose()
  346. await ctx.fiber.dispose()
  347. })
  348. it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
  349. const sessionId = SessionId('resume-load-factory-unload')
  350. const root = await persistSession(sessionId)
  351. const ctx = new Context()
  352. await ctx.plugin(LlmService)
  353. await ctx.plugin(SessionStore)
  354. await ctx.plugin(SystemPrompt)
  355. await ctx.plugin(ToolRegistry)
  356. await ctx.plugin(AgentRegistry)
  357. const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
  358. await ctx.plugin(SessionPersistenceJsonl, { root })
  359. ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
  360. const snapshot = await ctx.sessionPersistence.load(sessionId)
  361. const lateLoad = Promise.withResolvers<typeof snapshot>()
  362. const loadStarted = Promise.withResolvers<undefined>()
  363. ctx.sessionPersistence.load = (id) => {
  364. expect(id).toBe(sessionId)
  365. loadStarted.resolve(undefined)
  366. return lateLoad.promise
  367. }
  368. const published: string[] = []
  369. ctx.on('session/created', () => void published.push('session/created'))
  370. ctx.on('agent/created', () => void published.push('agent/created'))
  371. const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
  372. await loadStarted.promise
  373. const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
  374. await promptly(loopFiber.dispose())
  375. await rejection
  376. expect(published).toEqual([])
  377. expect(ctx.agents.get(sessionId)).toBeUndefined()
  378. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  379. lateLoad.resolve(structuredClone(snapshot))
  380. await Promise.resolve()
  381. await Promise.resolve()
  382. expect(published).toEqual([])
  383. await ctx.fiber.dispose()
  384. })
  385. it('resume of a forked session preserves the lineage, seed boundary, and delegation depth in the header', async () => {
  386. // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
  387. // in its header) by creating it with a complete-turn seed — the write path
  388. // materializes the fork (header + seed) on disk.
  389. const seed: SessionEvent[] = [
  390. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  391. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
  392. ]
  393. const adapter1 = new MockAdapter([textResponse('a')])
  394. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  395. const forked = ctx1.sessions.create(SessionId('forked-sess'), {
  396. seed,
  397. meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length, delegationDepth: 1 },
  398. })
  399. await ctx1.sessions.flush(forked)
  400. await ctx1.fiber.dispose()
  401. // Lifecycle 2: resume it; the parentSession + seedLength header survives the
  402. // round-trip (exercises resume's parentSession- and seedLength-present
  403. // branches). seedLength must come from the PERSISTED header, not from the
  404. // resume seed length (which is the whole stored log, not the original
  405. // boundary).
  406. const adapter2 = new MockAdapter([textResponse('b')])
  407. const ctx2 = new Context()
  408. await ctx2.plugin(LlmService)
  409. await ctx2.plugin(SessionStore)
  410. await ctx2.plugin(SystemPrompt)
  411. await ctx2.plugin(ToolRegistry)
  412. await ctx2.plugin(AgentRegistry)
  413. await ctx2.plugin(AgentLoop, { agents: [] })
  414. await ctx2.plugin(SessionPersistenceJsonl, { root })
  415. ctx2.llm.registerAdapter(['mock'], adapter2)
  416. const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent
  417. expect(a2.session.header.parentSession).toBe('parent-sess')
  418. expect(a2.session.header.cwd).toBe('/w')
  419. expect(a2.session.header.seedLength).toBe(seed.length)
  420. // The recursion budget survives resume — a dropped depth would let a
  421. // resumed child delegate as if it were top-level.
  422. expect(a2.session.header.delegationDepth).toBe(1)
  423. await ctx2.fiber.dispose()
  424. })
  425. it('an idle inject() survives persist + resume without a synthetic turn', async () => {
  426. const adapter1 = new MockAdapter([textResponse('answer')])
  427. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  428. const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
  429. a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
  430. await waitForIdle(ctx1, a1)
  431. a1.inject({ content: [{ type: 'text', text: 'background task 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } })
  432. await a1.whenIdle()
  433. await ctx1.fiber.dispose()
  434. // Lifecycle 2: resume; the injected context is still in the derived history.
  435. const adapter2 = new MockAdapter([textResponse('next')])
  436. const ctx2 = new Context()
  437. await ctx2.plugin(LlmService)
  438. await ctx2.plugin(SessionStore)
  439. await ctx2.plugin(SystemPrompt)
  440. await ctx2.plugin(ToolRegistry)
  441. await ctx2.plugin(AgentRegistry)
  442. await ctx2.plugin(AgentLoop, { agents: [] })
  443. await ctx2.plugin(SessionPersistenceJsonl, { root })
  444. ctx2.llm.registerAdapter(['mock'], adapter2)
  445. const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent
  446. const flat = JSON.stringify(a2.session.deriveMessages())
  447. expect(flat).toContain('background task 42 finished')
  448. await ctx2.fiber.dispose()
  449. })
  450. it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => {
  451. // Lifecycle 1: run one full turn, persisting it.
  452. const adapter1 = new MockAdapter([textResponse('first answer')])
  453. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  454. const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
  455. a1.followup({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })
  456. await waitForIdle(ctx1, a1)
  457. const events1 = [...a1.session.events]
  458. const seqs1 = events1.map(e => e.seq)
  459. expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous
  460. await ctx1.fiber.dispose()
  461. // Lifecycle 2: a brand-new context over the SAME root; resume the session.
  462. const adapter2 = new MockAdapter([textResponse('second answer')])
  463. const ctx2 = new Context()
  464. await ctx2.plugin(LlmService)
  465. await ctx2.plugin(SessionStore)
  466. await ctx2.plugin(SystemPrompt)
  467. await ctx2.plugin(ToolRegistry)
  468. await ctx2.plugin(AgentRegistry)
  469. await ctx2.plugin(AgentLoop, { agents: [] })
  470. await ctx2.plugin(SessionPersistenceJsonl, { root })
  471. ctx2.llm.registerAdapter(['mock'], adapter2)
  472. const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
  473. // The resumed session carries the prior history…
  474. expect(a2.session.id).toBe('sess-resume')
  475. expect(a2.session.events.length).toBe(events1.length)
  476. const replay = new Session(SessionId('replay'), events1)
  477. expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
  478. // …and a new turn continues numbering (turn 2) with contiguous seqs.
  479. a2.followup({ content: [{ type: 'text', text: 'second question' }], source: { kind: 'user' } })
  480. await waitForIdle(ctx2, a2)
  481. const allSeqs = a2.session.events.map(e => e.seq)
  482. expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates
  483. const turnStarts = a2.session.events.filter(e => e.type === 'turn/start')
  484. expect(turnStarts.map(e => e.type === 'turn/start' && e.data.turn)).toEqual([1, 2])
  485. await ctx2.fiber.dispose()
  486. })
  487. it('resume rejects when session persistence is not configured', async () => {
  488. // A harness WITHOUT the persistence plugin.
  489. const adapter = new MockAdapter([textResponse('x')])
  490. const ctx = new Context()
  491. await ctx.plugin(LlmService)
  492. await ctx.plugin(SessionStore)
  493. await ctx.plugin(SystemPrompt)
  494. await ctx.plugin(ToolRegistry)
  495. await ctx.plugin(AgentRegistry)
  496. await ctx.plugin(AgentLoop, { agents: [] })
  497. ctx.llm.registerAdapter(['mock'], adapter)
  498. await expect(ctx.agents.resume({ resumeSessionId: SessionId('nope') }))
  499. .rejects.toThrow(/session persistence is not configured/)
  500. await ctx.fiber.dispose()
  501. })
  502. })
  503. describe('creation and resume cancellation edges', () => {
  504. it('rejects create() with a pre-aborted signal, including a non-Error reason', async () => {
  505. const { ctx } = await persistentHarness(new MockAdapter([]))
  506. const errorReason = new AbortController()
  507. errorReason.abort(new Error('caller gave up'))
  508. await expect(promptly(ctx.agents.create({
  509. sessionId: SessionId('pre-aborted-error'),
  510. agentOptions: { provider: 'mock', model: 'mock' },
  511. signal: errorReason.signal,
  512. }))).rejects.toThrow('caller gave up')
  513. // A non-Error reason is wrapped into the creation-aborted error.
  514. const stringReason = new AbortController()
  515. stringReason.abort('operator string reason')
  516. await expect(promptly(ctx.agents.create({
  517. sessionId: SessionId('pre-aborted-string'),
  518. agentOptions: { provider: 'mock', model: 'mock' },
  519. signal: stringReason.signal,
  520. }))).rejects.toThrow(/creation aborted/)
  521. expect(ctx.agents.get(SessionId('pre-aborted-error'))).toBeUndefined()
  522. expect(ctx.agents.get(SessionId('pre-aborted-string'))).toBeUndefined()
  523. await ctx.fiber.dispose()
  524. })
  525. it('a non-Error abort reason arriving during setup is wrapped for the caller', async () => {
  526. const { ctx } = await persistentHarness(new MockAdapter([]))
  527. const controller = new AbortController()
  528. const setupEntered = Promise.withResolvers<undefined>()
  529. const setupGate = Promise.withResolvers<undefined>()
  530. const creating = ctx.agents.create({
  531. sessionId: SessionId('setup-string-abort'),
  532. agentOptions: { provider: 'mock', model: 'mock' },
  533. signal: controller.signal,
  534. async setup() {
  535. setupEntered.resolve(undefined)
  536. await setupGate.promise
  537. },
  538. })
  539. await setupEntered.promise
  540. controller.abort('mid-setup string reason')
  541. setupGate.resolve(undefined)
  542. await expect(promptly(creating)).rejects.toThrow(/creation aborted/)
  543. expect(ctx.agents.get(SessionId('setup-string-abort'))).toBeUndefined()
  544. await ctx.fiber.dispose()
  545. })
  546. it('resume with a pre-aborted caller signal rejects out of the load race', async () => {
  547. const sessionId = SessionId('resume-pre-aborted')
  548. const root = await persistSession(sessionId)
  549. const ctx = await mountPersistentHarness(root, new MockAdapter([]))
  550. const controller = new AbortController()
  551. controller.abort(new Error('resume abandoned'))
  552. await expect(promptly(ctx.agents.resume({
  553. resumeSessionId: sessionId,
  554. agentOptions: { provider: 'mock', model: 'mock' },
  555. signal: controller.signal,
  556. }))).rejects.toThrow('resume abandoned')
  557. expect(ctx.agents.get(sessionId)).toBeUndefined()
  558. await ctx.fiber.dispose()
  559. })
  560. it('factory teardown during a hung resume load rejects with loop-inactive', async () => {
  561. const sessionId = SessionId('resume-loop-teardown')
  562. const root = await persistSession(sessionId)
  563. const ctx = await mountPersistentHarness(root, new MockAdapter([]))
  564. const snapshot = await ctx.sessionPersistence.load(sessionId)
  565. const gate = Promise.withResolvers<typeof snapshot>()
  566. const loadStarted = Promise.withResolvers<undefined>()
  567. ctx.sessionPersistence.load = () => {
  568. loadStarted.resolve(undefined)
  569. return gate.promise
  570. }
  571. const resuming = ctx.agents.resume({
  572. resumeSessionId: sessionId,
  573. agentOptions: { provider: 'mock', model: 'mock' },
  574. })
  575. await loadStarted.promise
  576. // Resolve the load only after teardown began: the post-load ownership
  577. // check, not the abort race, must reject the wrapper.
  578. const rejection = expect(promptly(resuming)).rejects.toThrow()
  579. const disposal = ctx.fiber.dispose()
  580. gate.resolve(structuredClone(snapshot))
  581. await rejection
  582. await disposal
  583. })
  584. })
  585. describe('configured-start failure edges', () => {
  586. it('a non-Error mid-load abort reason is wrapped for the resume caller', async () => {
  587. const sessionId = SessionId('resume-string-mid-abort')
  588. const root = await persistSession(sessionId)
  589. const ctx = await mountPersistentHarness(root, new MockAdapter([]))
  590. const gate = Promise.withResolvers<never>()
  591. gate.promise.catch(() => undefined)
  592. const loadStarted = Promise.withResolvers<undefined>()
  593. ctx.sessionPersistence.load = () => {
  594. loadStarted.resolve(undefined)
  595. return gate.promise
  596. }
  597. const controller = new AbortController()
  598. const resuming = ctx.agents.resume({
  599. resumeSessionId: sessionId,
  600. agentOptions: { provider: 'mock', model: 'mock' },
  601. signal: controller.signal,
  602. })
  603. await loadStarted.promise
  604. controller.abort('operator string reason')
  605. await expect(promptly(resuming)).rejects.toThrow(/creation aborted/)
  606. expect(ctx.agents.get(sessionId)).toBeUndefined()
  607. await ctx.fiber.dispose()
  608. })
  609. it('a failing exact-id restore over an existing artifact stays loud', async () => {
  610. const sessionId = SessionId('config-existing-corrupt')
  611. const root = await persistSession(sessionId)
  612. const ctx = await mountPersistentHarness(root, new MockAdapter([]))
  613. // The artifact exists (list reports it) but its load fails: this is
  614. // corruption, not first creation — the failure must be reported, and no
  615. // fresh same-id session may shadow the broken one.
  616. ctx.sessionPersistence.load = () => Promise.reject(new Error('artifact corrupt'))
  617. const configured = new Context()
  618. await configured.plugin(LlmService)
  619. await configured.plugin(SessionStore)
  620. await configured.plugin(SystemPrompt)
  621. await configured.plugin(ToolRegistry)
  622. await configured.plugin(AgentRegistry)
  623. await configured.plugin(SessionPersistenceJsonl, { root })
  624. configured.llm.registerAdapter(['mock'], new MockAdapter([]))
  625. configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id)
  626. const configFailures: unknown[] = []
  627. configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) })
  628. const configWarnings: string[] = []
  629. const configWarn = configured.logger.warn.bind(configured.logger)
  630. configured.logger.warn = ((...args: unknown[]) => {
  631. if (typeof args[0] === 'string') configWarnings.push(args[0])
  632. return (configWarn as (...a: unknown[]) => unknown)(...args)
  633. }) as typeof configured.logger.warn
  634. const loop = await configured.plugin(AgentLoop, {
  635. agents: [{ id: 'main', sessionId, provider: 'mock', model: 'mock' }],
  636. })
  637. await expect.poll(() => configFailures.length).toBe(1)
  638. expect(configFailures[0]).toBeInstanceOf(Error)
  639. expect((configFailures[0] as Error).message).toBe('artifact corrupt')
  640. expect(configWarnings.some(w => w.includes('config-driven restore'))).toBe(true)
  641. expect(configured.agents.get(sessionId)).toBeUndefined()
  642. await loop.dispose()
  643. await configured.fiber.dispose()
  644. await ctx.fiber.dispose()
  645. })
  646. it('suppresses a configured-resume failure that lands after teardown', async () => {
  647. const sessionId = SessionId('config-late-resume-failure')
  648. const root = await persistSession(sessionId)
  649. const ctx = await mountPersistentHarness(root, new MockAdapter([]))
  650. const gate = Promise.withResolvers<never>()
  651. gate.promise.catch(() => undefined)
  652. const loadStarted = Promise.withResolvers<undefined>()
  653. ctx.sessionPersistence.load = () => {
  654. loadStarted.resolve(undefined)
  655. return gate.promise
  656. }
  657. const failures: unknown[] = []
  658. ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
  659. const configured = new Context()
  660. await configured.plugin(LlmService)
  661. await configured.plugin(SessionStore)
  662. await configured.plugin(SystemPrompt)
  663. await configured.plugin(ToolRegistry)
  664. await configured.plugin(AgentRegistry)
  665. await configured.plugin(SessionPersistenceJsonl, { root })
  666. configured.llm.registerAdapter(['mock'], new MockAdapter([]))
  667. configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id)
  668. configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
  669. const loop = await configured.plugin(AgentLoop, {
  670. agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }],
  671. })
  672. await loadStarted.promise
  673. const disposal = loop.dispose()
  674. gate.reject(new Error('late backend failure'))
  675. await disposal
  676. await new Promise(r => setTimeout(r, 20))
  677. // Ownership deactivated before the failure landed: the report is dropped.
  678. expect(failures).toEqual([])
  679. await configured.fiber.dispose()
  680. await ctx.fiber.dispose()
  681. })
  682. })