resume.spec.ts 38 KB

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