coordinator-contract.ts 62 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472
  1. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  2. /**
  3. * Shared write-path orchestration contract for backends using {@link PersistenceCoordinator}.
  4. * Unlike the public storage-semantics suite in `contract.ts`, it covers SessionStore event wiring,
  5. * lazy creation, fork seed persistence, four adoption/collision cases, crash-tail repair, reload,
  6. * flush, and disposal quiescence through public APIs rather than storage primitives.
  7. *
  8. * Each real backend supplies a shared storage scope and optional torn-tail injector; backend specs
  9. * retain only storage-mechanics tests, while these scenarios run once per backend.
  10. * @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract
  11. */
  12. import { describe, expect, it, vi } from 'vitest'
  13. import { Context, type Fiber } from '@deepseek-ai/cordis'
  14. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  15. import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
  16. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  17. import { meta, oneTurnLog, appendLog } from './contract.ts'
  18. /**
  19. * The backend-specific capabilities the orchestration suite needs beyond the
  20. * public service API. A fresh fixture is created per test (isolated storage);
  21. * the suite mounts/disposes backend instances on it and cleans it up at the end.
  22. */
  23. export interface CoordinatorFixture {
  24. /** Mount the real backend through `ctx.plugin` over shared storage and return only that fiber. */
  25. mount: (ctx: Context) => Promise<Fiber>
  26. /**
  27. * Inject a never-committed partial record after the durable region so `loadCore` reaches
  28. * `commitRepair`. Omit only when the backend structurally cannot produce torn tails.
  29. */
  30. corruptTail?: (id: SessionId, cwd: string | undefined) => Promise<void>
  31. /** Tear down the storage scope (remove the temp dir / file). */
  32. cleanup: () => Promise<void>
  33. }
  34. /** A constant absolute cwd; jsonl keys directories off it, memory/sqlite ignore it. */
  35. const WORK = '/w'
  36. const OTHER = '/other'
  37. /** Append a whole event log to a live session, event by event (drives session/event). */
  38. function send(session: Session, events: readonly SessionEvent[]): void {
  39. appendLog(session, events)
  40. }
  41. /** A valid persisted log from immediately before messages gained wrappers and identities. */
  42. function legacyMessageLog(): SessionEvent[] {
  43. return [
  44. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  45. {
  46. type: 'user/message',
  47. seq: 1,
  48. time: 2,
  49. data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
  50. surfaceOp: 'append',
  51. },
  52. { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
  53. {
  54. type: 'assistant/message',
  55. seq: 3,
  56. time: 4,
  57. data: {
  58. turn: 1,
  59. step: 1,
  60. content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }],
  61. provenance: { provider: 'mock', model: 'mock' },
  62. },
  63. surfaceOp: 'append',
  64. },
  65. {
  66. type: 'tool/call',
  67. seq: 4,
  68. time: 5,
  69. data: { turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}' },
  70. },
  71. {
  72. type: 'tool/result',
  73. seq: 5,
  74. time: 6,
  75. data: {
  76. turn: 1,
  77. step: 1,
  78. callId: 'call-1',
  79. content: [{ type: 'text', text: 'full result' }],
  80. isError: false,
  81. },
  82. sourceEventSeqs: [4],
  83. surfaceOp: 'append',
  84. },
  85. {
  86. type: 'tool/result',
  87. seq: 6,
  88. time: 8,
  89. data: {
  90. turn: 1,
  91. step: 1,
  92. callId: 'call-1',
  93. content: [{ type: 'text', text: 'pruned' }],
  94. isError: false,
  95. },
  96. sourceEventSeqs: [5],
  97. surfaceOp: { op: 'replace', start: 5, end: 5 },
  98. },
  99. { type: 'step/end', seq: 7, time: 9, data: { turn: 1, step: 1 } },
  100. { type: 'turn/end', seq: 8, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
  101. ] as unknown as SessionEvent[]
  102. }
  103. /** A complete log in the durable event vocabulary of the react-loop refactor base. */
  104. function preReactLoopLog(): SessionEvent[] {
  105. const prompt = createUserMessage({
  106. content: [{ type: 'text', text: 'old prompt' }],
  107. source: { kind: 'user' },
  108. })
  109. const steering = createUserMessage({
  110. content: [{ type: 'text', text: 'old steering' }],
  111. source: { kind: 'user' },
  112. })
  113. return [
  114. {
  115. type: 'turn/start', seq: 0, time: 1,
  116. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  117. },
  118. { type: 'user/message', seq: 1, time: 2, data: prompt, surfaceOp: 'append' },
  119. { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
  120. {
  121. type: 'steering/message', seq: 3, time: 4,
  122. data: { turn: 1, message: steering },
  123. surfaceOp: 'append',
  124. },
  125. { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
  126. { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
  127. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'retry' } } },
  128. { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
  129. { type: 'step/end', seq: 8, time: 9, data: { turn: 2, step: 1 } },
  130. {
  131. type: 'turn/end', seq: 9, time: 10,
  132. data: {
  133. turn: 2,
  134. reason: {
  135. kind: 'error',
  136. step: 1,
  137. failure: { message: 'old provider failure', code: 'SERVER' },
  138. },
  139. },
  140. },
  141. {
  142. type: 'turn/start', seq: 10, time: 11,
  143. data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } },
  144. },
  145. { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'aborted' } } },
  146. {
  147. type: 'turn/start', seq: 12, time: 13,
  148. data: { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } },
  149. },
  150. { type: 'turn/end', seq: 13, time: 14, data: { turn: 4, reason: { kind: 'disposed' } } },
  151. {
  152. type: 'turn/start', seq: 14, time: 15,
  153. data: { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } },
  154. },
  155. { type: 'step/start', seq: 15, time: 16, data: { turn: 5, step: 1 } },
  156. { type: 'step/end', seq: 16, time: 17, data: { turn: 5, step: 1 } },
  157. {
  158. type: 'turn/end', seq: 17, time: 18,
  159. data: { turn: 5, reason: { kind: 'error', step: 1, message: 'old thrown value' } },
  160. },
  161. {
  162. type: 'turn/start', seq: 18, time: 19,
  163. data: { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } },
  164. },
  165. {
  166. type: 'turn/end', seq: 19, time: 20,
  167. data: {
  168. turn: 6,
  169. reason: {
  170. kind: 'error',
  171. step: 0,
  172. failure: {
  173. message: 'old detailed provider failure',
  174. code: 'RATE_LIMIT',
  175. status: 429,
  176. providerRetryAfterMs: 1000,
  177. requestId: 'request-1',
  178. },
  179. },
  180. },
  181. },
  182. {
  183. type: 'turn/start', seq: 20, time: 21,
  184. data: { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } },
  185. },
  186. {
  187. type: 'turn/end', seq: 21, time: 22,
  188. data: { turn: 7, reason: { kind: 'error', step: 0, message: 'old coded error', code: 'CODED' } },
  189. },
  190. ] as unknown as SessionEvent[]
  191. }
  192. /** A live session created inside its OWN fiber, so it survives a backend reload. */
  193. async function liveSessionInFiber(
  194. ctx: Context, id: string, cwd: string | undefined,
  195. ): Promise<Session> {
  196. let session!: Session
  197. await ctx.plugin(Object.assign((inner: Context) => {
  198. session = inner.sessions.create(SessionId(id), cwd !== undefined ? { meta: { cwd } } : undefined)
  199. }, { inject: ['sessions'] }))
  200. return session
  201. }
  202. /**
  203. * Run the coordinator orchestration suite against a backend. `makeFixture()`
  204. * MUST return a fresh fixture (isolated storage) each call.
  205. */
  206. export function runCoordinatorContract(name: string, makeFixture: () => Promise<CoordinatorFixture>): void {
  207. describe(`PersistenceCoordinator orchestration: ${name}`, () => {
  208. /** Mount SessionStore + a backend instance on a fresh context over the fixture's storage. */
  209. async function freshCtx(fix: CoordinatorFixture): Promise<{ ctx: Context; fiber: Fiber }> {
  210. const ctx = new Context()
  211. await ctx.plugin(SessionStore)
  212. const fiber = await fix.mount(ctx)
  213. return { ctx, fiber }
  214. }
  215. // --- write path: live session → flush → reload ---
  216. it('persists a live session driven through the store, surviving reload', async () => {
  217. const fix = await makeFixture()
  218. const { ctx, fiber } = await freshCtx(fix)
  219. try {
  220. const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } })
  221. send(session, oneTurnLog())
  222. await ctx.sessions.flush(session)
  223. const loaded = await ctx.sessionPersistence.load(SessionId('live'))
  224. expect(loaded.events).toHaveLength(6)
  225. expect(loaded.meta.cwd).toBe(WORK)
  226. } finally {
  227. await fiber.dispose()
  228. await fix.cleanup()
  229. }
  230. })
  231. it('rejects crash-repair load while a live session owns the persisted prefix', async () => {
  232. const fix = await makeFixture()
  233. const { ctx, fiber } = await freshCtx(fix)
  234. let session!: Session
  235. const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
  236. session = inner.sessions.create(SessionId('live-load'), { meta: { cwd: WORK } })
  237. }, { inject: ['sessions'] }))
  238. try {
  239. session.append('turn/start', { turn: 1 })
  240. await ctx.sessions.flush(session)
  241. await expect(ctx.sessionPersistence.load(session.id))
  242. .rejects.toThrow(`cannot load session "${session.id}" while its live turn is open`)
  243. send(session, oneTurnLog().slice(1))
  244. await ctx.sessions.flush(session)
  245. await sessionFiber.dispose()
  246. await vi.waitFor(async () => {
  247. const loaded = await ctx.sessionPersistence.load(session.id)
  248. expect(loaded.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type))
  249. expect(loaded.events.at(-1)).toMatchObject({
  250. type: 'turn/end',
  251. data: { reason: { kind: 'completed' } },
  252. })
  253. })
  254. } finally {
  255. await sessionFiber.dispose()
  256. await fiber.dispose()
  257. await fix.cleanup()
  258. }
  259. })
  260. it('rechecks live ownership after a cold load enters the per-id chain', async () => {
  261. const fix = await makeFixture()
  262. const { ctx, fiber } = await freshCtx(fix)
  263. try {
  264. const id = SessionId('queued-load-live-race')
  265. const header = meta(id, WORK)
  266. const start: SessionEvent = {
  267. type: 'turn/start',
  268. seq: 0,
  269. time: 1,
  270. data: { turn: 1 },
  271. }
  272. await ctx.sessionPersistence.create(header)
  273. await ctx.sessionPersistence.append(id, [start])
  274. const loading = ctx.sessionPersistence.load(id)
  275. const live = ctx.sessions.create(id, { seed: [start], meta: header })
  276. await expect(loading).rejects.toThrow(/live turn is open/)
  277. live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  278. await ctx.sessions.flush(live)
  279. const loaded = await ctx.sessionPersistence.load(id)
  280. // The constructor's end-seed event persisted between the stored
  281. // turn/start and the turn/end appended live.
  282. expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed', 'turn/end'])
  283. expect(loaded.events.at(-1)).toMatchObject({
  284. type: 'turn/end',
  285. data: { reason: { kind: 'completed' } },
  286. })
  287. } finally {
  288. await fiber.dispose()
  289. await fix.cleanup()
  290. }
  291. })
  292. it('does not load an unmaterialized empty live session', async () => {
  293. const fix = await makeFixture()
  294. const { ctx, fiber } = await freshCtx(fix)
  295. try {
  296. const session = ctx.sessions.create(SessionId('empty-live'), { meta: { cwd: WORK } })
  297. await expect(ctx.sessionPersistence.load(session.id)).rejects.toThrow(/not found/)
  298. } finally {
  299. await fiber.dispose()
  300. await fix.cleanup()
  301. }
  302. })
  303. it('round-trips the seed boundary (seedLength) through persistence', async () => {
  304. // A forked child records how many leading events were inherited via the seed; the
  305. // boundary must survive a reload (so a resume/replay can tell the inherited prefix from
  306. // the child's own events). JSONL stores it in the header; SQLite uses `seed_length`.
  307. const fix = await makeFixture()
  308. const { ctx, fiber } = await freshCtx(fix)
  309. try {
  310. let session!: Session
  311. const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
  312. session = inner.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } })
  313. }, { inject: ['sessions'] }))
  314. send(session, oneTurnLog())
  315. await ctx.sessions.flush(session)
  316. await sessionFiber.dispose()
  317. const loaded = await ctx.sessionPersistence.load(SessionId('forked-child'))
  318. expect(loaded.meta.seedLength).toBe(3)
  319. } finally {
  320. await fiber.dispose()
  321. await fix.cleanup()
  322. }
  323. })
  324. it('round-trips the delegation depth through persistence', async () => {
  325. // A subagent child's recursion budget lives in its header; a reload that
  326. // dropped it would reset the child to top-level and un-bound maxDepth
  327. // (JSONL stores it in the header line; SQLite uses `delegation_depth`).
  328. const fix = await makeFixture()
  329. const { ctx, fiber } = await freshCtx(fix)
  330. try {
  331. let session!: Session
  332. const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
  333. session = inner.sessions.create(SessionId('delegated-child'), {
  334. meta: { cwd: WORK, parentSession: SessionId('root'), delegationDepth: 2 },
  335. })
  336. }, { inject: ['sessions'] }))
  337. send(session, oneTurnLog())
  338. await ctx.parallel('session/flush', session)
  339. await sessionFiber.dispose()
  340. const loaded = await ctx.sessionPersistence.load(SessionId('delegated-child'))
  341. expect(loaded.meta.delegationDepth).toBe(2)
  342. } finally {
  343. await fiber.dispose()
  344. await fix.cleanup()
  345. }
  346. })
  347. it('source-frozen events cannot be mutated after buffering and persist unchanged', async () => {
  348. const fix = await makeFixture()
  349. const { ctx, fiber } = await freshCtx(fix)
  350. try {
  351. const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } })
  352. session.append('turn/start', { turn: 1 })
  353. const ev = session.append('user/message', createUserMessage({
  354. content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
  355. }), { surfaceOp: 'append' })
  356. expect(() => {
  357. ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
  358. }).toThrow(TypeError)
  359. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  360. await ctx.sessions.flush(session)
  361. const loaded = await ctx.sessionPersistence.load(SessionId('mutate'))
  362. const message = loaded.events.find(event => event.type === 'user/message')
  363. expect(message?.type === 'user/message' && (message.data.content[0] as { text: string }).text).toBe('original')
  364. } finally {
  365. await fiber.dispose()
  366. await fix.cleanup()
  367. }
  368. })
  369. it('load and inspect return immutable identified-message snapshots', async () => {
  370. const fix = await makeFixture()
  371. const { ctx, fiber } = await freshCtx(fix)
  372. try {
  373. const id = SessionId('immutable-read')
  374. const session = ctx.sessions.create(id, { meta: { cwd: WORK } })
  375. send(session, oneTurnLog())
  376. await ctx.sessions.flush(session)
  377. for (const snapshot of [
  378. await ctx.sessionPersistence.load(id),
  379. await ctx.sessionPersistence.inspect(id),
  380. ]) {
  381. const event = snapshot.events.find(candidate => candidate.type === 'user/message')
  382. if (event?.type !== 'user/message') throw new Error('fixture lacks user/message')
  383. expect(Object.isFrozen(event.data)).toBe(true)
  384. expect(Object.isFrozen(event.data.content)).toBe(true)
  385. expect(() => {
  386. ;(event.data as { id: string }).id = 'rewritten'
  387. }).toThrow(TypeError)
  388. expect(() => {
  389. ;(event.data.content[0] as { type: 'text'; text: string }).text = 'rewritten'
  390. }).toThrow(TypeError)
  391. }
  392. } finally {
  393. await fiber.dispose()
  394. await fix.cleanup()
  395. }
  396. })
  397. it('loads pre-identity message logs into resumable current sessions', async () => {
  398. const fix = await makeFixture()
  399. const { ctx, fiber } = await freshCtx(fix)
  400. try {
  401. const id = SessionId('legacy-message-load')
  402. await ctx.sessionPersistence.create(meta(id, WORK))
  403. await ctx.sessionPersistence.append(id, legacyMessageLog())
  404. for (const snapshot of [
  405. await ctx.sessionPersistence.inspect(id),
  406. await ctx.sessionPersistence.load(id),
  407. ]) {
  408. const messages: { id: string }[] = []
  409. for (const event of snapshot.events) {
  410. if (event.type === 'user/message') messages.push(event.data)
  411. else if (event.type === 'assistant/message'
  412. || event.type === 'tool/result') messages.push(event.data.message)
  413. }
  414. expect(messages.map(message => message.id)).toEqual([
  415. `legacy-message:${id}:1`,
  416. `legacy-message:${id}:3`,
  417. `legacy-message:${id}:5`,
  418. `legacy-message:${id}:5`,
  419. ])
  420. expect(messages.every(message => Object.isFrozen(message))).toBe(true)
  421. const resumed = Session.create(id, snapshot.events, snapshot.meta)
  422. expect(resumed.deriveMessages().map(message => message.id)).toEqual([
  423. `legacy-message:${id}:1`,
  424. `legacy-message:${id}:3`,
  425. `legacy-message:${id}:5`,
  426. ])
  427. }
  428. const replacementSuffix = await ctx.sessionPersistence.readFrom(id, 6)
  429. expect(replacementSuffix.events[0]).toMatchObject({
  430. type: 'tool/result',
  431. seq: 6,
  432. data: { message: { id: `legacy-message:${id}:5` } },
  433. })
  434. } finally {
  435. await fiber.dispose()
  436. await fix.cleanup()
  437. }
  438. })
  439. it('loads pre-react-loop session logs into resumable current sessions', async () => {
  440. const fix = await makeFixture()
  441. const { ctx, fiber } = await freshCtx(fix)
  442. try {
  443. const id = SessionId('pre-react-loop-load')
  444. const log = preReactLoopLog()
  445. const legacySteering = log[3] as unknown as { data: { message: { id: string } } }
  446. await ctx.sessionPersistence.create(meta(id, WORK))
  447. await ctx.sessionPersistence.append(id, log)
  448. const snapshots = [
  449. await ctx.sessionPersistence.inspect(id),
  450. await ctx.sessionPersistence.readFrom(id, 0),
  451. await ctx.sessionPersistence.load(id),
  452. ]
  453. for (const snapshot of snapshots) {
  454. expect(snapshot.events.some(event => (event.type as string) === 'steering/message')).toBe(false)
  455. expect(snapshot.events.filter(event => event.type === 'turn/start').map(event => event.data))
  456. .toEqual([
  457. { turn: 1 }, { turn: 2 }, { turn: 3 }, { turn: 4 }, { turn: 5 }, { turn: 6 }, { turn: 7 },
  458. ])
  459. expect(snapshot.events.filter(event => event.type === 'turn/end').map(event => event.data)).toEqual([
  460. { turn: 1, reason: { kind: 'completed' } },
  461. {
  462. turn: 2,
  463. reason: { kind: 'error', error: { message: 'old provider failure', code: 'SERVER' } },
  464. },
  465. { turn: 3, reason: { kind: 'aborted', reason: { kind: 'legacy' } } },
  466. { turn: 4, reason: { kind: 'aborted', reason: { kind: 'disposed' } } },
  467. {
  468. turn: 5,
  469. reason: { kind: 'error', error: { message: 'old thrown value', code: 'UNKNOWN' } },
  470. },
  471. {
  472. turn: 6,
  473. reason: {
  474. kind: 'error',
  475. error: {
  476. message: 'old detailed provider failure',
  477. code: 'RATE_LIMIT',
  478. status: 429,
  479. providerRetryAfterMs: 1000,
  480. requestId: 'request-1',
  481. },
  482. },
  483. },
  484. {
  485. turn: 7,
  486. reason: { kind: 'error', error: { message: 'old coded error', code: 'CODED' } },
  487. },
  488. ])
  489. const resumed = Session.create(id, snapshot.events, snapshot.meta)
  490. expect(resumed.deriveMessages().map(message => message.content)).toEqual([
  491. [{ type: 'text', text: 'old prompt' }],
  492. [{ type: 'text', text: 'old steering' }],
  493. ])
  494. }
  495. const suffix = await ctx.sessionPersistence.readFrom(id, 3)
  496. expect(suffix.events[0]).toMatchObject({
  497. type: 'user/message',
  498. seq: 3,
  499. data: { id: legacySteering.data.message.id },
  500. })
  501. expect(suffix.events.filter(event => event.type === 'turn/end')
  502. .every(event => !Object.hasOwn(event.data, 'step'))).toBe(true)
  503. const flatId = SessionId('pre-react-loop-flat-steering')
  504. await ctx.sessionPersistence.create(meta(flatId, WORK))
  505. await ctx.sessionPersistence.append(flatId, [{
  506. type: 'steering/message',
  507. seq: 0,
  508. time: 1,
  509. data: {
  510. turn: 1,
  511. content: [{ type: 'text', text: 'flat steering' }],
  512. source: { kind: 'user' },
  513. },
  514. surfaceOp: 'append',
  515. } as unknown as SessionEvent])
  516. expect((await ctx.sessionPersistence.inspect(flatId)).events[0]).toMatchObject({
  517. type: 'user/message',
  518. data: {
  519. id: `legacy-message:${flatId}:0`,
  520. role: 'user',
  521. content: [{ type: 'text', text: 'flat steering' }],
  522. },
  523. })
  524. const extendedId = SessionId('current-extended-turn-end')
  525. await ctx.sessionPersistence.create(meta(extendedId, WORK))
  526. await ctx.sessionPersistence.append(extendedId, [
  527. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  528. {
  529. type: 'turn/end', seq: 1, time: 2,
  530. data: { turn: 1, reason: { kind: 'extension-reason' } },
  531. } as unknown as SessionEvent,
  532. ])
  533. expect((await ctx.sessionPersistence.inspect(extendedId)).events[1]).toMatchObject({
  534. type: 'turn/end',
  535. data: { reason: { kind: 'extension-reason' } },
  536. })
  537. } finally {
  538. await fiber.dispose()
  539. await fix.cleanup()
  540. }
  541. })
  542. it('rejects malformed persisted message events before returning them', async () => {
  543. const fix = await makeFixture()
  544. const { ctx, fiber } = await freshCtx(fix)
  545. try {
  546. const id = SessionId('invalid-message-read')
  547. await ctx.sessionPersistence.create(meta(id, WORK))
  548. await ctx.sessionPersistence.append(id, [{
  549. type: 'user/message',
  550. seq: 0,
  551. time: 1,
  552. surfaceOp: 'append',
  553. data: {
  554. id: 'wrong-role',
  555. role: 'assistant',
  556. content: [{ type: 'text', text: 'wrong' }],
  557. source: { kind: 'user' },
  558. },
  559. } as unknown as SessionEvent])
  560. await expect(ctx.sessionPersistence.inspect(id))
  561. .rejects.toThrow('message must have role "user"')
  562. await expect(ctx.sessionPersistence.load(id))
  563. .rejects.toThrow('message must have role "user"')
  564. const malformedLegacy: { id: string; event: SessionEvent; message: string }[] = [
  565. {
  566. id: 'invalid-old-turn-start',
  567. event: {
  568. type: 'turn/start', seq: 0, time: 1,
  569. data: { turn: 1, trigger: null },
  570. } as unknown as SessionEvent,
  571. message: 'malformed pre-react-loop turn/start',
  572. },
  573. {
  574. id: 'invalid-old-steering',
  575. event: {
  576. type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append',
  577. data: { turn: 1, content: [], source: { kind: 'user' }, extra: true },
  578. } as unknown as SessionEvent,
  579. message: 'malformed pre-react-loop steering/message',
  580. },
  581. {
  582. id: 'invalid-old-steering-data',
  583. event: {
  584. type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append', data: null,
  585. } as unknown as SessionEvent,
  586. message: 'malformed pre-react-loop steering/message',
  587. },
  588. {
  589. id: 'invalid-old-turn-end',
  590. event: {
  591. type: 'turn/end', seq: 0, time: 1,
  592. data: { turn: 1, reason: { kind: 'completed', extra: true } },
  593. } as unknown as SessionEvent,
  594. message: 'malformed pre-react-loop turn/end',
  595. },
  596. {
  597. id: 'invalid-old-turn-end-reason',
  598. event: {
  599. type: 'turn/end', seq: 0, time: 1,
  600. data: { turn: 1, reason: null },
  601. } as unknown as SessionEvent,
  602. message: 'malformed pre-react-loop turn/end',
  603. },
  604. {
  605. id: 'unsupported-intermediate-turn-end-step',
  606. event: {
  607. type: 'turn/end', seq: 0, time: 1,
  608. data: { turn: 1, step: 1, reason: { kind: 'completed' } },
  609. } as unknown as SessionEvent,
  610. message: 'malformed pre-react-loop turn/end',
  611. },
  612. {
  613. id: 'invalid-old-turn-end-aborted',
  614. event: {
  615. type: 'turn/end', seq: 0, time: 1,
  616. data: { turn: 1, reason: { kind: 'aborted', extra: true } },
  617. } as unknown as SessionEvent,
  618. message: 'malformed pre-react-loop turn/end',
  619. },
  620. {
  621. id: 'invalid-old-turn-end-disposed',
  622. event: {
  623. type: 'turn/end', seq: 0, time: 1,
  624. data: { turn: 1, reason: { kind: 'disposed', extra: true } },
  625. } as unknown as SessionEvent,
  626. message: 'malformed pre-react-loop turn/end',
  627. },
  628. {
  629. id: 'invalid-old-turn-end-error-step',
  630. event: {
  631. type: 'turn/end', seq: 0, time: 1,
  632. data: { turn: 1, reason: { kind: 'error', step: -1, message: 'bad step' } },
  633. } as unknown as SessionEvent,
  634. message: 'malformed pre-react-loop turn/end',
  635. },
  636. {
  637. id: 'invalid-old-turn-end-error-code',
  638. event: {
  639. type: 'turn/end', seq: 0, time: 1,
  640. data: { turn: 1, reason: { kind: 'error', step: 0, message: 'bad code', code: 1 } },
  641. } as unknown as SessionEvent,
  642. message: 'malformed pre-react-loop turn/end',
  643. },
  644. ]
  645. for (const malformed of malformedLegacy) {
  646. const malformedId = SessionId(malformed.id)
  647. await ctx.sessionPersistence.create(meta(malformedId, WORK))
  648. await ctx.sessionPersistence.append(malformedId, [malformed.event])
  649. await expect(ctx.sessionPersistence.inspect(malformedId)).rejects.toThrow(malformed.message)
  650. await expect(ctx.sessionPersistence.readFrom(malformedId, 0)).rejects.toThrow(malformed.message)
  651. }
  652. for (const type of ['tool/result'] as const) {
  653. const malformedId = SessionId(`invalid-${type}`)
  654. await ctx.sessionPersistence.create(meta(malformedId, WORK))
  655. await ctx.sessionPersistence.append(malformedId, [{
  656. type,
  657. seq: 0,
  658. time: 1,
  659. surfaceOp: 'append',
  660. data: { message: null },
  661. } as unknown as SessionEvent])
  662. await expect(ctx.sessionPersistence.inspect(malformedId))
  663. .rejects.toThrow('lacks an identified message')
  664. }
  665. // A known log-only event with non-object data is not a legacy message
  666. // candidate; both whole-log and seek reads preserve it unchanged.
  667. const primitiveId = SessionId('non-object-log-only-event')
  668. const primitive = {
  669. type: 'session/end-seed',
  670. seq: 0,
  671. time: 1,
  672. data: null,
  673. } as unknown as SessionEvent
  674. await ctx.sessionPersistence.create(meta(primitiveId, WORK))
  675. await ctx.sessionPersistence.append(primitiveId, [primitive])
  676. await expect(ctx.sessionPersistence.inspect(primitiveId))
  677. .resolves.toMatchObject({ events: [primitive] })
  678. await expect(ctx.sessionPersistence.readFrom(primitiveId, 0))
  679. .resolves.toMatchObject({ events: [primitive] })
  680. for (const type of ['user/message', 'assistant/message'] as const) {
  681. const missingContentId = SessionId(`invalid-${type}-without-content`)
  682. await ctx.sessionPersistence.create(meta(missingContentId, WORK))
  683. await ctx.sessionPersistence.append(missingContentId, [{
  684. type,
  685. seq: 0,
  686. time: 1,
  687. surfaceOp: 'append',
  688. data: {},
  689. } as unknown as SessionEvent])
  690. await expect(ctx.sessionPersistence.readFrom(missingContentId, 0))
  691. .rejects.toThrow('lacks an identified message')
  692. }
  693. } finally {
  694. await fiber.dispose()
  695. await fix.cleanup()
  696. }
  697. })
  698. it('append snapshots the batch: mutating the caller array/events after the call is ignored', async () => {
  699. const fix = await makeFixture()
  700. const { ctx, fiber } = await freshCtx(fix)
  701. try {
  702. const m = meta('snapshot', WORK)
  703. await ctx.sessionPersistence.create(m)
  704. const events = structuredClone(oneTurnLog()) // seqs 0..5
  705. const userMsg = events[1] // the user/message event
  706. const p = ctx.sessionPersistence.append(m.id, events)
  707. // Mutate the caller's array AND an event object after the call but before
  708. // the queued op runs: the snapshot taken at call time must shield the copy.
  709. events.push({ type: 'turn/start', seq: 6, time: 99, data: { turn: 2 } })
  710. if (userMsg?.type === 'user/message') {
  711. (userMsg.data as { content: unknown[] }).content = [{ type: 'text', text: 'MUTATED' }]
  712. }
  713. await p
  714. const loaded = await ctx.sessionPersistence.load(m.id)
  715. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) // not 0..6
  716. const persisted = JSON.stringify(loaded.events)
  717. expect(persisted).toContain('hi') // original content
  718. expect(persisted).not.toContain('MUTATED')
  719. } finally {
  720. await fiber.dispose()
  721. await fix.cleanup()
  722. }
  723. })
  724. // --- fork / resume ---
  725. it('fork: a seeded new session persists its seed once (no double-write on a no-op flush)', async () => {
  726. const fix = await makeFixture()
  727. const { ctx, fiber } = await freshCtx(fix)
  728. try {
  729. const seed = oneTurnLog()
  730. // A fork: a brand-new id whose seed came from elsewhere.
  731. const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } })
  732. await ctx.sessions.flush(forked) // onCreated persisted the seed
  733. const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
  734. // Fork is where the marker earns its keep: the inherited prefix may
  735. // carry a bracket the still-running parent owns.
  736. expect(loaded.events.slice(0, seed.length)).toEqual(seed)
  737. expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: seed.length })
  738. // A flush with no NEW events must not double-write.
  739. await ctx.sessions.flush(forked)
  740. const reloaded = await ctx.sessionPersistence.load(SessionId('forked'))
  741. expect(reloaded.events).toEqual(loaded.events)
  742. } finally {
  743. await fiber.dispose()
  744. await fix.cleanup()
  745. }
  746. })
  747. it('resume: a re-created session seeded with the loaded log does not re-append its seed and continues the seq', async () => {
  748. // Separate backend lifecycles distinguish persisted-seed adoption from an in-memory continuation.
  749. const fix = await makeFixture()
  750. const first = await freshCtx(fix)
  751. try {
  752. const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } })
  753. send(s1, oneTurnLog())
  754. await first.ctx.sessions.flush(s1)
  755. } finally {
  756. await first.fiber.dispose()
  757. }
  758. const second = await freshCtx(fix)
  759. try {
  760. const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
  761. const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } })
  762. await second.ctx.sessions.flush(s2) // let onCreated adopt
  763. s2.append('turn/start', { turn: 2 })
  764. s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  765. await second.ctx.sessions.flush(s2)
  766. const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
  767. // 0-5 the resumed seed, 6 end-seed, 7-8 the new turn.
  768. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
  769. expect(reloaded.events[6]).toMatchObject({ type: 'session/end-seed' })
  770. } finally {
  771. await second.fiber.dispose()
  772. await fix.cleanup()
  773. }
  774. })
  775. // --- HMR ---
  776. it('HMR: applying the plugin seeds existing live sessions', async () => {
  777. const fix = await makeFixture()
  778. const ctx = new Context()
  779. await ctx.plugin(SessionStore)
  780. // A session exists BEFORE the persistence plugin is applied.
  781. const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } })
  782. session.append('turn/start', { turn: 1 })
  783. session.append('user/message', createUserMessage({
  784. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  785. }), { surfaceOp: 'append' })
  786. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  787. const fiber = await fix.mount(ctx)
  788. try {
  789. // The plugin seeded it on apply; a subsequent flush persists its events.
  790. await ctx.sessions.flush(session)
  791. const loaded = await ctx.sessionPersistence.load(SessionId('pre-existing'))
  792. expect(loaded.events.length).toBeGreaterThanOrEqual(2)
  793. } finally {
  794. await fiber.dispose()
  795. await fix.cleanup()
  796. }
  797. })
  798. it('HMR: dispose drains remaining buffers', async () => {
  799. const fix = await makeFixture()
  800. const ctx = new Context()
  801. await ctx.plugin(SessionStore)
  802. const fiber = await fix.mount(ctx)
  803. const session = await liveSessionInFiber(ctx, 'drain', WORK)
  804. session.append('turn/start', { turn: 1 })
  805. session.append('user/message', createUserMessage({
  806. content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' },
  807. }), { surfaceOp: 'append' })
  808. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  809. // No explicit flush — dispose must drain.
  810. await fiber.dispose()
  811. // A fresh backend instance reads what the disposed one drained.
  812. const second = await freshCtx(fix)
  813. try {
  814. const loaded = await second.ctx.sessionPersistence.load(SessionId('drain'))
  815. expect(loaded.events.length).toBeGreaterThanOrEqual(2)
  816. } finally {
  817. await second.fiber.dispose()
  818. await fix.cleanup()
  819. }
  820. })
  821. it('HMR: reloading the backend adopts a still-live, already-materialized session', async () => {
  822. const fix = await makeFixture()
  823. const ctx = new Context()
  824. await ctx.plugin(SessionStore)
  825. // The session lives in its OWN fiber so it survives the backend reload.
  826. const session = await liveSessionInFiber(ctx, 'hmr-adopt', WORK)
  827. try {
  828. // Backend instance 1 materializes the session.
  829. const backend1 = await fix.mount(ctx)
  830. session.append('turn/start', { turn: 1 })
  831. session.append('user/message', createUserMessage({
  832. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  833. }), { surfaceOp: 'append' })
  834. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  835. await ctx.sessions.flush(session)
  836. // Hot-reload: dispose instance 1, mount instance 2 over the same storage while the
  837. // session stays live. The new instance has no coordinator state but must adopt the
  838. // materialized prefix, then persist another turn rather than rejecting it as a collision.
  839. await backend1.dispose()
  840. await fix.mount(ctx)
  841. session.append('turn/start', { turn: 2 })
  842. session.append('user/message', createUserMessage({
  843. content: [{ type: 'text', text: 'again' }], source: { kind: 'user' },
  844. }), { surfaceOp: 'append' })
  845. session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  846. await expect(ctx.sessions.flush(session)).resolves.not.toThrow()
  847. const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt'))
  848. expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
  849. } finally {
  850. await ctx.fiber.dispose()
  851. await fix.cleanup()
  852. }
  853. })
  854. it('HMR: adoption persists the live SUFFIX that was ahead of the stored prefix', async () => {
  855. const fix = await makeFixture()
  856. const ctx = new Context()
  857. await ctx.plugin(SessionStore)
  858. const session = await liveSessionInFiber(ctx, 'hmr-suffix', WORK)
  859. try {
  860. // Instance 1 flushes turn 1.
  861. const backend1 = await fix.mount(ctx)
  862. session.append('turn/start', { turn: 1 })
  863. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  864. await ctx.sessions.flush(session)
  865. // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
  866. // flushing turn 2: it is now ONLY in the live session's events; the new
  867. // backend never buffered it via session/event.
  868. await backend1.dispose()
  869. session.append('turn/start', { turn: 2 })
  870. session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  871. // Instance 2 adopts the stored prefix (turn 1) and MUST also persist the
  872. // live suffix (turn 2) carried in the session's events.
  873. await fix.mount(ctx)
  874. await ctx.sessions.flush(session)
  875. const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix'))
  876. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3])
  877. expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
  878. } finally {
  879. await ctx.fiber.dispose()
  880. await fix.cleanup()
  881. }
  882. })
  883. it('HMR adoption does NOT crash-repair an active open turn as interrupted (truncate without closers)', async () => {
  884. const fix = await makeFixture()
  885. const ctx = new Context()
  886. await ctx.plugin(SessionStore)
  887. const session = await liveSessionInFiber(ctx, 'hmr-open', WORK)
  888. try {
  889. const first = await fix.mount(ctx)
  890. session.append('turn/start', { turn: 1 })
  891. session.append('step/start', { turn: 1, step: 1 })
  892. await ctx.sessions.flush(session)
  893. // Crash-tail a torn fragment past the (open) committed turn, then reload.
  894. await first.dispose()
  895. if (fix.corruptTail) await fix.corruptTail(SessionId('hmr-open'), WORK)
  896. const second = await fix.mount(ctx)
  897. // The live session is still the authority: it appends the REAL step/turn
  898. // end. Adoption must truncate the torn tail but NOT synthesize closers.
  899. session.append('step/end', { turn: 1, step: 1 })
  900. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  901. await ctx.sessions.flush(session)
  902. const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
  903. expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
  904. expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
  905. await second.dispose()
  906. } finally {
  907. await ctx.fiber.dispose()
  908. await fix.cleanup()
  909. }
  910. })
  911. // --- collision / id reuse ---
  912. it('a NEW live session colliding on a persisted id is rejected, not silently adopted', async () => {
  913. const fix = await makeFixture()
  914. const first = await freshCtx(fix)
  915. try {
  916. const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
  917. send(s1, oneTurnLog())
  918. await first.ctx.sessions.flush(s1)
  919. } finally {
  920. await first.fiber.dispose()
  921. }
  922. // A fresh backend + a NEW live session with the same id but NO explicit resume. onCreated
  923. // treats it as new; create() rejects because a log already exists, and `flush()` surfaces
  924. // that initialization rejection.
  925. const second = await freshCtx(fix)
  926. try {
  927. const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
  928. s2.append('turn/start', { turn: 1 })
  929. await expect(second.ctx.sessions.flush(s2))
  930. .rejects.toThrow(/already has a persisted log|id collision/)
  931. } finally {
  932. await second.fiber.dispose()
  933. await fix.cleanup()
  934. }
  935. })
  936. it('an abandoned lazy session (never materialized) releases its id for reuse', async () => {
  937. const fix = await makeFixture()
  938. const { ctx, fiber } = await freshCtx(fix)
  939. try {
  940. // A live session created then disposed BEFORE its first append: cursor 0,
  941. // never materialized. A new live session reusing the id must reclaim it.
  942. let firstSession!: Session
  943. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
  944. firstSession = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
  945. }, { inject: ['sessions'] }))
  946. await ctx.sessions.flush(firstSession) // register the lazy state
  947. await firstFiber.dispose() // disposed before any append → never materialized
  948. let reuse!: Session
  949. await ctx.plugin(Object.assign((inner: Context) => {
  950. reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
  951. }, { inject: ['sessions'] }))
  952. await expect(ctx.sessions.flush(reuse)).resolves.toBe(true)
  953. reuse.append('turn/start', { turn: 1 })
  954. reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  955. await ctx.sessions.flush(reuse)
  956. const loaded = await ctx.sessionPersistence.load(SessionId('abandoned'))
  957. expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
  958. } finally {
  959. await fiber.dispose()
  960. await fix.cleanup()
  961. }
  962. })
  963. it('session disposal drains buffered events before retiring ownership', async () => {
  964. const fix = await makeFixture()
  965. const { ctx, fiber } = await freshCtx(fix)
  966. try {
  967. let first!: Session
  968. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
  969. first = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
  970. }, { inject: ['sessions'] }))
  971. await ctx.sessions.flush(first)
  972. // Append a turn but do NOT flush — events sit in the write-behind buffer.
  973. first.append('turn/start', { turn: 1 })
  974. first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  975. await firstFiber.dispose()
  976. // Disposal is an observe-only notification. Poll storage rather than
  977. // assuming the owning fiber awaits the coordinator's detached drain.
  978. await vi.waitFor(async () => {
  979. expect((await ctx.sessionPersistence.list()).map(meta => meta.id)).toContain(SessionId('buffered'))
  980. })
  981. expect((await ctx.sessionPersistence.load(SessionId('buffered'))).events.map(event => event.seq)).toEqual([0, 1])
  982. let reuse!: Session
  983. await ctx.plugin(Object.assign((inner: Context) => {
  984. reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
  985. }, { inject: ['sessions'] }))
  986. await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/persisted log|id collision/)
  987. } finally {
  988. await fiber.dispose()
  989. await fix.cleanup()
  990. }
  991. })
  992. it('initFor is idempotent: re-emitting session/created does not re-initialize', async () => {
  993. const fix = await makeFixture()
  994. const { ctx, fiber } = await freshCtx(fix)
  995. try {
  996. const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } })
  997. session.append('turn/start', { turn: 1 })
  998. session.append('user/message', createUserMessage({
  999. content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
  1000. }), { surfaceOp: 'append' })
  1001. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  1002. await ctx.sessions.flush(session)
  1003. // Re-emit session/created for the SAME live session (idempotent initFor).
  1004. ctx.emit(scopeTarget(session, undefined), 'session/created', session)
  1005. await ctx.sessions.flush(session)
  1006. const loaded = await ctx.sessionPersistence.load(SessionId('idem'))
  1007. expect(loaded.events).toHaveLength(3) // not doubled
  1008. } finally {
  1009. await fiber.dispose()
  1010. await fix.cleanup()
  1011. }
  1012. })
  1013. // --- ownerless-state claim (public create()/load() then a live session arrives) ---
  1014. it('a live session claims cursor-0 ownerless state created via the public API and persists its seed', async () => {
  1015. const fix = await makeFixture()
  1016. const { ctx, fiber } = await freshCtx(fix)
  1017. try {
  1018. // create() registers ownerless state with cursor 0 (lazy, nothing persisted).
  1019. await ctx.sessionPersistence.create(meta('lazy-claim', WORK))
  1020. // A live session with that id arrives and claims it (cursor 0 matches
  1021. // trivially), persisting its seed.
  1022. const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
  1023. await expect(ctx.sessions.flush(live)).resolves.toBe(true)
  1024. const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
  1025. // Seeded 0-5 plus the constructor's end-seed event at 6.
  1026. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6])
  1027. expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' })
  1028. } finally {
  1029. await fiber.dispose()
  1030. await fix.cleanup()
  1031. }
  1032. })
  1033. it('a fresh session reusing a previously-loaded id is rejected (ownerless guard)', async () => {
  1034. const fix = await makeFixture()
  1035. const { ctx, fiber } = await freshCtx(fix)
  1036. try {
  1037. // Materialize a log, then load() it WITHOUT a live session — ownerless
  1038. // state, cursor at the persisted length.
  1039. await ctx.sessionPersistence.create(meta('preview', WORK))
  1040. await ctx.sessionPersistence.append(SessionId('preview'), oneTurnLog())
  1041. await ctx.sessionPersistence.load(SessionId('preview'))
  1042. // A FRESH (empty-seed) live session reusing that id must be rejected: its
  1043. // seq 0..cursor-1 events would otherwise be filtered as already-persisted.
  1044. let fresh!: Session
  1045. await ctx.plugin(Object.assign((inner: Context) => {
  1046. fresh = inner.sessions.create(SessionId('preview'), { meta: { cwd: WORK } })
  1047. }, { inject: ['sessions'] }))
  1048. await expect(ctx.sessions.flush(fresh))
  1049. .rejects.toThrow(/do not match this live session|already has a persisted log|id collision/)
  1050. } finally {
  1051. await fiber.dispose()
  1052. await fix.cleanup()
  1053. }
  1054. })
  1055. it('a live session whose complete seed matches loaded ownerless state claims it without appending', async () => {
  1056. const fix = await makeFixture()
  1057. const { ctx, fiber } = await freshCtx(fix)
  1058. try {
  1059. const id = SessionId('claim-exact')
  1060. const completeSeed = [
  1061. ...oneTurnLog(),
  1062. { type: 'session/end-seed', seq: 6, time: 7, data: {} },
  1063. ] as SessionEvent[]
  1064. await ctx.sessionPersistence.create(meta(id, WORK))
  1065. await ctx.sessionPersistence.append(id, completeSeed)
  1066. const { events } = await ctx.sessionPersistence.load(id)
  1067. const live = ctx.sessions.create(id, { seed: events, meta: { cwd: WORK } })
  1068. await expect(ctx.sessions.flush(live)).resolves.toBe(true)
  1069. expect((await ctx.sessionPersistence.load(id)).events).toEqual(events)
  1070. } finally {
  1071. await fiber.dispose()
  1072. await fix.cleanup()
  1073. }
  1074. })
  1075. it('a live session whose seed matches the loaded prefix claims ownerless state and persists the suffix', async () => {
  1076. const fix = await makeFixture()
  1077. const { ctx, fiber } = await freshCtx(fix)
  1078. try {
  1079. // Materialize and load (ownerless, cursor = 6).
  1080. const storedMeta = meta('claim', WORK)
  1081. await ctx.sessionPersistence.create(storedMeta)
  1082. await ctx.sessionPersistence.append(SessionId('claim'), oneTurnLog())
  1083. const { events, meta: durableMeta } = await ctx.sessionPersistence.load(SessionId('claim'))
  1084. // A live session SEEDED with the loaded log PLUS a new turn claims the
  1085. // ownerless state and persists only the suffix.
  1086. let cont!: Session
  1087. const contFiber = await ctx.plugin(Object.assign((inner: Context) => {
  1088. cont = inner.sessions.create(SessionId('claim'), { seed: [
  1089. ...events,
  1090. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
  1091. { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
  1092. ], meta: { cwd: WORK, createdAt: 2000 } })
  1093. }, { inject: ['sessions'] }))
  1094. await ctx.sessions.flush(cont)
  1095. const loaded = await ctx.sessionPersistence.load(SessionId('claim'))
  1096. // 6-7 the claimed suffix; 8 end-seed after the whole seed.
  1097. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
  1098. expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' })
  1099. expect(loaded.meta).toEqual(durableMeta)
  1100. expect(loaded.meta.createdAt).toBe(1000)
  1101. await contFiber.dispose()
  1102. await vi.waitFor(async () => {
  1103. expect((await ctx.sessionPersistence.load(SessionId('claim'))).meta).toEqual(durableMeta)
  1104. })
  1105. } finally {
  1106. await fiber.dispose()
  1107. await fix.cleanup()
  1108. }
  1109. })
  1110. it('a live session at a DIFFERENT cwd cannot claim cursor-0 ownerless state (cwd scope)', async () => {
  1111. const fix = await makeFixture()
  1112. const { ctx, fiber } = await freshCtx(fix)
  1113. try {
  1114. // create() registers ownerless state at cwd /a (cursor 0 — claims would
  1115. // otherwise match trivially on the seed).
  1116. await ctx.sessionPersistence.create(meta('wrong-cwd-claim', OTHER))
  1117. // A live session reusing the id but at cwd WORK must NOT claim it — the
  1118. // cwd scope is the fence (without it, WORK events would append under the
  1119. // OTHER header). Rejected as a collision.
  1120. const live = ctx.sessions.create(SessionId('wrong-cwd-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
  1121. await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/)
  1122. } finally {
  1123. await fiber.dispose()
  1124. await fix.cleanup()
  1125. }
  1126. })
  1127. it('a live session at a DIFFERENT cwd cannot claim loaded-prefix ownerless state (cwd scope)', async () => {
  1128. const fix = await makeFixture()
  1129. const { ctx, fiber } = await freshCtx(fix)
  1130. try {
  1131. // Materialize + load at cwd OTHER (ownerless, cursor = 6).
  1132. await ctx.sessionPersistence.create(meta('wrong-cwd-load', OTHER))
  1133. await ctx.sessionPersistence.append(SessionId('wrong-cwd-load'), oneTurnLog())
  1134. const { events } = await ctx.sessionPersistence.load(SessionId('wrong-cwd-load'))
  1135. // A live session whose SEED matches the loaded prefix but whose cwd is
  1136. // WORK must still be rejected — the cwd guard runs before the seed check.
  1137. const live = ctx.sessions.create(SessionId('wrong-cwd-load'), { seed: events, meta: { cwd: WORK } })
  1138. await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/)
  1139. } finally {
  1140. await fiber.dispose()
  1141. await fix.cleanup()
  1142. }
  1143. })
  1144. it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => {
  1145. const fix = await makeFixture()
  1146. const { ctx, fiber } = await freshCtx(fix)
  1147. try {
  1148. // Ownerless state created WITHOUT a cwd (the `_no-cwd` project directory).
  1149. await ctx.sessionPersistence.create(meta('no-cwd-state'))
  1150. // A live session reusing the id but WITH cwd WORK is a cwd mismatch
  1151. // (undefined vs WORK) and must be rejected.
  1152. const live = ctx.sessions.create(SessionId('no-cwd-state'), { seed: oneTurnLog(), meta: { cwd: WORK } })
  1153. await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/)
  1154. } finally {
  1155. await fiber.dispose()
  1156. await fix.cleanup()
  1157. }
  1158. })
  1159. // --- append adopts a storage-only session (fresh instance, no prior create/load) ---
  1160. it('append adopts a storage-only session (fresh instance) and continues the seq', async () => {
  1161. const fix = await makeFixture()
  1162. const first = await freshCtx(fix)
  1163. try {
  1164. const m = meta('adopt-append', WORK)
  1165. await first.ctx.sessionPersistence.create(m)
  1166. await first.ctx.sessionPersistence.append(m.id, oneTurnLog())
  1167. } finally {
  1168. await first.fiber.dispose()
  1169. }
  1170. // A fresh instance appends a second turn WITHOUT a prior create/load: append
  1171. // must adopt the stored session (cursor = stored length) and continue.
  1172. const second = await freshCtx(fix)
  1173. try {
  1174. await second.ctx.sessionPersistence.append(SessionId('adopt-append'), [
  1175. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
  1176. { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
  1177. ])
  1178. const loaded = await second.ctx.sessionPersistence.load(SessionId('adopt-append'))
  1179. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  1180. } finally {
  1181. await second.fiber.dispose()
  1182. await fix.cleanup()
  1183. }
  1184. })
  1185. // --- small public-API edges that the coordinator owns uniformly ---
  1186. it('append of an empty batch is a no-op (stays lazy)', async () => {
  1187. const fix = await makeFixture()
  1188. const { ctx, fiber } = await freshCtx(fix)
  1189. try {
  1190. const m = meta('empty-batch', WORK)
  1191. await ctx.sessionPersistence.create(m)
  1192. await ctx.sessionPersistence.append(m.id, [])
  1193. expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
  1194. } finally {
  1195. await fiber.dispose()
  1196. await fix.cleanup()
  1197. }
  1198. })
  1199. it('load and inspect reject a missing session', async () => {
  1200. const fix = await makeFixture()
  1201. const { ctx, fiber } = await freshCtx(fix)
  1202. try {
  1203. await expect(ctx.sessionPersistence.load(SessionId('nope'))).rejects.toThrow(/not found/)
  1204. await expect(ctx.sessionPersistence.inspect(SessionId('nope'))).rejects.toThrow(/not found/)
  1205. } finally {
  1206. await fiber.dispose()
  1207. await fix.cleanup()
  1208. }
  1209. })
  1210. it('create rejects a duplicate id (in memory and on a persisted log)', async () => {
  1211. const fix = await makeFixture()
  1212. const first = await freshCtx(fix)
  1213. try {
  1214. const m = meta('dup', WORK)
  1215. await first.ctx.sessionPersistence.create(m)
  1216. // Same in-memory state.
  1217. await expect(first.ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists in this backend/)
  1218. await first.ctx.sessionPersistence.append(m.id, oneTurnLog())
  1219. } finally {
  1220. await first.fiber.dispose()
  1221. }
  1222. // A fresh instance over the same storage sees the persisted log.
  1223. const second = await freshCtx(fix)
  1224. try {
  1225. await expect(second.ctx.sessionPersistence.create(meta('dup', WORK)))
  1226. .rejects.toThrow(/already has a persisted log on disk/)
  1227. } finally {
  1228. await second.fiber.dispose()
  1229. await fix.cleanup()
  1230. }
  1231. })
  1232. it('rejects a newer format version on load, naming the upgrade direction', async () => {
  1233. const fix = await makeFixture()
  1234. const { ctx, fiber } = await freshCtx(fix)
  1235. try {
  1236. const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK }
  1237. await ctx.sessionPersistence.create(m)
  1238. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  1239. const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
  1240. expect(failure?.name).toBe('SessionFormatUnsupportedError')
  1241. expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/)
  1242. } finally {
  1243. await fiber.dispose()
  1244. await fix.cleanup()
  1245. }
  1246. })
  1247. it('rejects an older format version on load without claiming an upgrade path', async () => {
  1248. const fix = await makeFixture()
  1249. const { ctx, fiber } = await freshCtx(fix)
  1250. try {
  1251. const m = { version: -1, id: SessionId('v-older'), createdAt: 1, cwd: WORK }
  1252. await ctx.sessionPersistence.create(m)
  1253. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  1254. const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
  1255. expect(failure?.name).toBe('SessionFormatUnsupportedError')
  1256. expect(failure?.message).toMatch(/older than the supported v0.*no upgrade path/)
  1257. } finally {
  1258. await fiber.dispose()
  1259. await fix.cleanup()
  1260. }
  1261. })
  1262. it('rejects an unknown event type on load', async () => {
  1263. const fix = await makeFixture()
  1264. const { ctx, fiber } = await freshCtx(fix)
  1265. try {
  1266. const required = meta('unknown-required', WORK)
  1267. await ctx.sessionPersistence.create(required)
  1268. await ctx.sessionPersistence.append(required.id, [
  1269. ...oneTurnLog(),
  1270. { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 } } as unknown as SessionEvent,
  1271. ])
  1272. const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error)
  1273. expect(failure?.name).toBe('SessionFormatUnsupportedError')
  1274. expect(failure?.message).toMatch(/event type "future\/event".*unknown to this harness/)
  1275. } finally {
  1276. await fiber.dispose()
  1277. await fix.cleanup()
  1278. }
  1279. })
  1280. it('round-trips a header with parentSession (fork lineage)', async () => {
  1281. const fix = await makeFixture()
  1282. const { ctx, fiber } = await freshCtx(fix)
  1283. try {
  1284. const m = { version: SESSION_FORMAT_VERSION, id: SessionId('forked-child'), createdAt: 1, cwd: WORK, parentSession: SessionId('the-parent') }
  1285. await ctx.sessionPersistence.create(m)
  1286. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  1287. const loaded = await ctx.sessionPersistence.load(m.id)
  1288. expect(loaded.meta.parentSession).toBe('the-parent')
  1289. } finally {
  1290. await fiber.dispose()
  1291. await fix.cleanup()
  1292. }
  1293. })
  1294. it('flush before init resolves uses cursor 0', async () => {
  1295. const fix = await makeFixture()
  1296. const { ctx, fiber } = await freshCtx(fix)
  1297. try {
  1298. // Append directly to a live session and flush IMMEDIATELY, before the
  1299. // async onCreated init has necessarily set state (exercises the
  1300. // state-undefined cursor path).
  1301. const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } })
  1302. session.append('turn/start', { turn: 1 })
  1303. session.append('user/message', createUserMessage({
  1304. content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
  1305. }), { surfaceOp: 'append' })
  1306. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  1307. await ctx.sessions.flush(session)
  1308. const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate'))
  1309. expect(loaded.events).toHaveLength(3)
  1310. } finally {
  1311. await fiber.dispose()
  1312. await fix.cleanup()
  1313. }
  1314. })
  1315. // --- crash-tail repair THROUGH the coordinator (real storage torn tail) ---
  1316. it('torn-tail load: a never-committed tail is truncated and the open turn closed during load (commitRepair w/ tornMarker)', async () => {
  1317. const fix = await makeFixture()
  1318. if (!fix.corruptTail) {
  1319. // A memory-style store has no torn tails (every write is atomic in RAM),
  1320. // so there is no tornMarker path to exercise. Assert that explicitly
  1321. // instead of silently skipping, then bail.
  1322. expect(fix.corruptTail).toBeUndefined()
  1323. await fix.cleanup()
  1324. return
  1325. }
  1326. const first = await freshCtx(fix)
  1327. try {
  1328. const m = meta('torn', WORK)
  1329. await first.ctx.sessionPersistence.create(m)
  1330. await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed 0..5 (balanced)
  1331. // A second turn whose real events are durable but never closed (open turn).
  1332. await first.ctx.sessionPersistence.append(m.id, [
  1333. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
  1334. { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
  1335. ])
  1336. } finally {
  1337. await first.fiber.dispose()
  1338. }
  1339. // Inject a torn fragment past the committed region (never-committed tail).
  1340. await fix.corruptTail(SessionId('torn'), WORK)
  1341. // A FRESH instance loads: the torn tail is truncated (tornMarker !==
  1342. // undefined) AND the open turn 2 is closed with synthetic step/end +
  1343. // turn/end {interrupted} — commitRepair runs with BOTH a torn marker and
  1344. // closers. The preserved real events (0..7) are never truncated.
  1345. const second = await freshCtx(fix)
  1346. try {
  1347. const loaded = await second.ctx.sessionPersistence.load(SessionId('torn'))
  1348. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  1349. expect(loaded.events.map(e => e.type)).toEqual([
  1350. 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
  1351. 'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real + synthetic closers
  1352. ])
  1353. const last = loaded.events.at(-1)!
  1354. expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
  1355. // The repair is durable: the next append continues at the balanced length
  1356. // (seq 10) and a reload round-trips identically.
  1357. await second.ctx.sessionPersistence.append(SessionId('torn'), [
  1358. { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
  1359. { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
  1360. ])
  1361. const reloaded = await second.ctx.sessionPersistence.load(SessionId('torn'))
  1362. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
  1363. } finally {
  1364. await second.fiber.dispose()
  1365. await fix.cleanup()
  1366. }
  1367. })
  1368. })
  1369. }