resume.spec.ts 41 KB

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