session.spec.ts 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { CallId } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  5. import type { CreateSessionOptions, SessionEventType, SessionHeader, TodoItem } from '@deepseek-ai/dsh-session'
  6. describe('Session', () => {
  7. it('derives message history from the event log', () => {
  8. const session = new Session(SessionId('s1'))
  9. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  10. session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  11. session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
  12. session.append('assistant/message', {
  13. turn: 1, step: 1,
  14. content: [
  15. { type: 'text', text: 'let me check' },
  16. { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
  17. ],
  18. }, { surfaceOp: 'append' })
  19. session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
  20. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  21. const messages = session.deriveMessages()
  22. expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
  23. // raw chunks must NOT appear in derived history
  24. expect(messages[1]!.content).toHaveLength(2)
  25. expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: CallId('c1') })
  26. })
  27. it('accepts and round-trips a max-tokens turn/end reason', () => {
  28. // The max-tokens TurnEndReason variant carries no extra data, so it must
  29. // append and persist like any other reason (JSON-serializable, no fields).
  30. const session = new Session(SessionId('s1'))
  31. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  32. session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
  33. const turnEnd = session.events.findLast(e => e.type === 'turn/end')!
  34. expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' })
  35. // survives a structuredClone (the persistence-serialization boundary)
  36. expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
  37. })
  38. it('renders context and steering messages as tagged synthetic user content', () => {
  39. const session = new Session(SessionId('s2'))
  40. session.append('context/message', {
  41. content: [{ type: 'text', text: 'file changed: a.ts' }],
  42. source: { kind: 'plugin', plugin: 'watcher' },
  43. }, { surfaceOp: 'append' })
  44. session.append('steering/message', {
  45. turn: 1,
  46. content: [{ type: 'text', text: 'focus on tests' }],
  47. source: { kind: 'user' },
  48. }, { surfaceOp: 'append' })
  49. const [contextMessage, steeringMessage] = session.deriveMessages()
  50. expect(contextMessage!.role).toBe('user')
  51. expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
  52. expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '</context>' })
  53. expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
  54. })
  55. it('replays identically from a seeded event log', () => {
  56. const original = new Session(SessionId('s3'))
  57. original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  58. original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  59. original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
  60. original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  61. const replayed = new Session(SessionId('s3-replay'), [...original.events])
  62. expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
  63. expect(replayed.seq).toBe(original.seq)
  64. })
  65. it('isolates the log from mutation through a derived message (append-only contract)', () => {
  66. const session = new Session(SessionId('s4'))
  67. session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  68. session.append('tool/result', {
  69. turn: 1, step: 1, callId: CallId('c1'),
  70. content: [{ type: 'text', text: 'tool out' }], isError: false,
  71. }, { surfaceOp: 'append' })
  72. const before = structuredClone(session.events)
  73. // A misbehaving consumer tries to mutate the messages it was handed.
  74. // Derived messages are frozen shared projections (cloned once off the
  75. // log, then deep-frozen): every mutation attempt THROWS in strict mode —
  76. // isolation by unrepresentability, not by per-call cloning.
  77. const messages = session.deriveMessages()
  78. const userBlock = messages[0]!.content[0]!
  79. expect(() => { if (userBlock.type === 'text') userBlock.text = 'HACKED' }).toThrow(TypeError)
  80. const toolBlock = messages[1]!.content[0]!
  81. expect(() => {
  82. if (toolBlock.type === 'tool-result') toolBlock.content.push({ type: 'text', text: 'injected' })
  83. }).toThrow(TypeError)
  84. expect(() => { messages[0]!.content.push({ type: 'text', text: 'extra' }) }).toThrow(TypeError)
  85. // The returned ARRAY is the caller's own snapshot, though — reordering it
  86. // is the caller's business and never reaches the cache or the log.
  87. messages.reverse()
  88. // The log is unchanged: deep-equal to the snapshot taken before mutation.
  89. expect(session.events).toEqual(before)
  90. // And a fresh derivation still reflects the original content and order.
  91. expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }])
  92. })
  93. it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
  94. const session = new Session(SessionId('s5'))
  95. const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' })
  96. expect(bad(1n)).toThrow(/non-JSON-serializable/)
  97. expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
  98. expect(bad(Symbol('s'))).toThrow(/non-JSON-serializable/)
  99. expect(bad(new Map())).toThrow(/non-JSON-serializable/)
  100. expect(bad(undefined)).toThrow(/non-JSON-serializable/)
  101. expect(bad(Infinity)).toThrow(/non-JSON-serializable/)
  102. // A sparse array: `every` skips the hole but JSON.stringify writes it null.
  103. // Build the hole without a sparse literal or `delete` (both linted).
  104. const sparse: unknown[] = Array(3)
  105. sparse[0] = 1
  106. sparse[2] = 3 // index 1 stays a hole
  107. expect(bad(sparse)).toThrow(/non-JSON-serializable/)
  108. // A DENSE array carrying a non-serializable element is rejected too.
  109. expect(bad([1, 2n, 3])).toThrow(/non-JSON-serializable/)
  110. // A nested non-serializable value (inside a plain object) is rejected.
  111. expect(bad({ nested: { deep: () => 0 } })).toThrow(/non-JSON-serializable/)
  112. // A circular reference is rejected (the seen-set guard, not a stack blow-up).
  113. const cyclic: Record<string, unknown> = { a: 1 }
  114. cyclic['self'] = cyclic
  115. expect(bad(cyclic)).toThrow(/non-JSON-serializable/)
  116. // The rejected appends never entered the log.
  117. expect(session.events).toHaveLength(0)
  118. })
  119. it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
  120. const session = new Session(SessionId('s5b'))
  121. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  122. // The typed overload makes surfaceOp mandatory only when the type argument is
  123. // a SPECIFIC SurfaceEventType literal. A caller iterating raw events widens it
  124. // to the SessionEventType union, where the conditional rest collapses to
  125. // optional — the exact shape `for (const e of log) append(e.type, e.data)`
  126. // produces. Reproduce that here and assert the runtime guard rejects it.
  127. const widenedType = 'user/message' as SessionEventType
  128. expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
  129. .toThrow(/surface-eligible and requires a surfaceOp marker/)
  130. // The rejected append never entered the log (only turn/start is present).
  131. expect(session.events).toHaveLength(1)
  132. })
  133. it('accepts dense arrays and nested plain objects', () => {
  134. const session = new Session(SessionId('s6'))
  135. expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow()
  136. expect(session.events).toHaveLength(1)
  137. })
  138. it('validates seed events: rejects a non-JSON-serializable seed', () => {
  139. // A replay/fork seed must satisfy the SAME invariant as Session.append, or
  140. // it builds a live log no backend can persist.
  141. const badSeed = [
  142. { type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
  143. ] as unknown as SessionEvent[]
  144. expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
  145. })
  146. it('validates seed events: rejects a non-contiguous seq', () => {
  147. const gapSeed = [
  148. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
  149. { type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
  150. ] as SessionEvent[]
  151. expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
  152. })
  153. it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
  154. // A surface-eligible event (user/message) with no surfaceOp would load fine
  155. // but vanish from deriveMessages() (the surface is the sole derivation path),
  156. // so a resume/fork would silently lose history. append() forbids this at
  157. // compile time; a raw seed must be rejected at runtime to match.
  158. const markerlessSeed = [
  159. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
  160. { type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
  161. { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
  162. ] as SessionEvent[]
  163. expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
  164. })
  165. it('accepts a well-formed contiguous serializable seed', () => {
  166. const goodSeed = [
  167. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
  168. { type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
  169. { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
  170. ] as SessionEvent[]
  171. const session = new Session(SessionId('seed-ok'), goodSeed)
  172. expect(session.events).toHaveLength(3)
  173. })
  174. it('reads each seed array entry once so validation and storage use the same event', () => {
  175. const accepted = {
  176. type: 'turn/start' as const,
  177. seq: 0,
  178. time: 1,
  179. data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
  180. }
  181. const drifted = { ...accepted, seq: 99, data: { invalid: 1n } }
  182. let reads = 0
  183. const seed = new Array<SessionEvent>(1)
  184. Object.defineProperty(seed, 0, {
  185. enumerable: true,
  186. get: () => {
  187. reads += 1
  188. return reads === 1 ? accepted : drifted
  189. },
  190. })
  191. const session = new Session(SessionId('seed-entry-snapshot'), seed)
  192. expect(reads).toBe(1)
  193. expect(session.events).toEqual([accepted])
  194. })
  195. it('reads a nested seed-data getter once and stores its first JSON value', () => {
  196. let reads = 0
  197. const data = Object.defineProperty({}, 'value', {
  198. enumerable: true,
  199. get: () => {
  200. reads += 1
  201. return reads === 1 ? 'accepted' : 1n
  202. },
  203. })
  204. const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
  205. const session = new Session(SessionId('seed-nested-drift'), seed)
  206. expect(reads).toBe(1)
  207. expect(session.events[0]!.data).toEqual({ value: 'accepted' })
  208. })
  209. it('rejects non-JSON surface metadata in a seed event', () => {
  210. const seed = [{
  211. type: 'user/message',
  212. seq: 0,
  213. time: 1,
  214. data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
  215. surfaceOp: { op: 'replace', start: 1n, end: 2 },
  216. }] as unknown as SessionEvent[]
  217. expect(() => new Session(SessionId('seed-bad-metadata'), seed))
  218. .toThrow(/losslessly JSON-serializable/)
  219. })
  220. it('rejects exotic seed metadata before cloning can erase its prototype', () => {
  221. class ReplaceOp {
  222. readonly op = 'replace' as const
  223. readonly start = 0
  224. readonly end = 0
  225. }
  226. const seed = [{
  227. type: 'user/message',
  228. seq: 0,
  229. time: 1,
  230. data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
  231. surfaceOp: new ReplaceOp(),
  232. }] as unknown as SessionEvent[]
  233. expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
  234. .toThrow(/losslessly JSON-serializable/)
  235. })
  236. it('rejects an exotic seed event shell before spreading erases its prototype', () => {
  237. class SeedEvent {
  238. readonly type = 'turn/start' as const
  239. readonly seq = 0
  240. readonly time = 1
  241. readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }
  242. }
  243. const seed: SessionEvent[] = [new SeedEvent()]
  244. expect(() => new Session(SessionId('seed-exotic-shell'), seed))
  245. .toThrow(/not losslessly JSON-serializable/)
  246. })
  247. it('accepts a null-prototype seed event shell as a plain JSON record', () => {
  248. const event = Object.assign(Object.create(null) as Record<string, unknown>, {
  249. type: 'turn/start' as const,
  250. seq: 0,
  251. time: 1,
  252. data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
  253. }) as unknown as SessionEvent
  254. const session = new Session(SessionId('seed-null-prototype'), [event])
  255. expect(session.events).toEqual([{ ...event }])
  256. })
  257. it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
  258. let reads = 0
  259. const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
  260. enumerable: true,
  261. get: () => {
  262. reads += 1
  263. return reads === 1 ? 0 : 1n
  264. },
  265. })
  266. const seed = [{
  267. type: 'user/message',
  268. seq: 0,
  269. time: 1,
  270. data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
  271. surfaceOp,
  272. }] as unknown as SessionEvent[]
  273. const session = new Session(SessionId('seed-unstable-metadata'), seed)
  274. const event = session.events[0]!
  275. if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
  276. expect(reads).toBe(1)
  277. expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
  278. })
  279. it('adds seed context when surface validation throws a non-Error value', () => {
  280. const originalHasOwn = Object.hasOwn
  281. const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => {
  282. if ((object as Record<string, unknown>)['op'] === 'replace') throw 'validator failed'
  283. return originalHasOwn(object, property)
  284. })
  285. const seed = [{
  286. type: 'user/message',
  287. seq: 0,
  288. time: 1,
  289. data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
  290. surfaceOp: { op: 'replace', start: 0, end: 0 },
  291. }] as unknown as SessionEvent[]
  292. try {
  293. expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
  294. .toThrow('invalid seed event at index 0: invalid surface metadata')
  295. } finally {
  296. hasOwn.mockRestore()
  297. }
  298. })
  299. it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
  300. const seed = [
  301. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
  302. { type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
  303. { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
  304. ] as SessionEvent[]
  305. const session = new Session(SessionId('seed-snapshot'), seed)
  306. // Mutate the ORIGINAL seed objects after construction: a shared reference
  307. // would let this rewrite the forked log (or reintroduce non-serializable
  308. // data past validation). The snapshot must shield session.events.
  309. const um = seed[1]!
  310. ;(um.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
  311. ;(um.data as Record<string, unknown>)['injected'] = 1n // would have failed validation
  312. const logged = session.events[1]!
  313. expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
  314. expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
  315. })
  316. it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
  317. const session = new Session(SessionId('append-snapshot'))
  318. const data = { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }
  319. const event = session.append('user/message', data, { surfaceOp: 'append' })
  320. // Mutate the caller's object after append returns. A shared reference would
  321. // make session.events diverge from the value that passed validation.
  322. data.content[0]!.text = 'HACKED'
  323. ;(data as Record<string, unknown>)['injected'] = 1n
  324. const logged = session.events[0]!
  325. expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
  326. expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
  327. // The returned event carries the same snapshot, not the caller's input.
  328. expect((event.data.content[0] as { text: string }).text).toBe('original')
  329. })
  330. it('reads a nested append-data getter once and stores its first JSON value', () => {
  331. const session = new Session(SessionId('append-nested-drift'))
  332. let reads = 0
  333. const data = Object.defineProperty({}, 'value', {
  334. enumerable: true,
  335. get: () => {
  336. reads += 1
  337. return reads === 1 ? 'accepted' : 1n
  338. },
  339. })
  340. const event = session.append('todo/write', data as never)
  341. expect(reads).toBe(1)
  342. expect(event.data).toEqual({ value: 'accepted' })
  343. expect(session.events).toEqual([event])
  344. })
  345. it('rejects non-JSON surface metadata before appending the event', () => {
  346. const session = new Session(SessionId('append-bad-metadata'))
  347. expect(() => session.append(
  348. 'user/message',
  349. { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
  350. { surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never,
  351. )).toThrow(/non-JSON-serializable surface metadata/)
  352. expect(session.events).toEqual([])
  353. })
  354. it('rejects exotic surface metadata before cloning can erase its prototype', () => {
  355. class ReplaceOp {
  356. readonly op = 'replace' as const
  357. readonly start = 0
  358. readonly end = 0
  359. }
  360. const session = new Session(SessionId('append-exotic-metadata'))
  361. expect(() => session.append(
  362. 'user/message',
  363. { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
  364. { surfaceOp: new ReplaceOp() },
  365. )).toThrow(/non-JSON-serializable surface metadata/)
  366. expect(session.events).toEqual([])
  367. })
  368. it('reads a nested append-metadata getter once and stores its first JSON value', () => {
  369. const session = new Session(SessionId('append-unstable-metadata'))
  370. let reads = 0
  371. const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
  372. enumerable: true,
  373. get: () => {
  374. reads += 1
  375. return reads === 1 ? 0 : 1n
  376. },
  377. })
  378. const event = session.append(
  379. 'user/message',
  380. { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
  381. { surfaceOp } as never,
  382. )
  383. expect(reads).toBe(1)
  384. expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
  385. expect(session.events).toEqual([event])
  386. })
  387. it('rejects invalid plain surface metadata shapes at append', () => {
  388. const session = new Session(SessionId('append-invalid-surface-shape'))
  389. const appendRaw = session.append.bind(session) as unknown as (
  390. type: SessionEventType,
  391. data: unknown,
  392. opts?: unknown,
  393. ) => SessionEvent
  394. const data = { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }
  395. expect(() => appendRaw('user/message', data, { surfaceOp: 'invalid' }))
  396. .toThrow(/invalid surfaceOp/)
  397. expect(() => appendRaw('user/message', data, {
  398. surfaceOp: { op: 'replace', start: -1, end: 0 },
  399. })).toThrow(/invalid replace surfaceOp/)
  400. expect(() => appendRaw('user/message', data, {
  401. surfaceOp: 'append',
  402. sourceEventSeqs: [0, -1],
  403. })).toThrow(/non-negative safe integers/)
  404. expect(session.events).toEqual([])
  405. })
  406. it('rejects surface metadata on non-surface append and seed events', () => {
  407. const session = new Session(SessionId('non-surface-metadata'))
  408. const appendRaw = session.append.bind(session) as unknown as (
  409. type: SessionEventType,
  410. data: unknown,
  411. opts?: unknown,
  412. ) => SessionEvent
  413. expect(() => appendRaw(
  414. 'turn/start',
  415. { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  416. { surfaceOp: 'append' },
  417. )).toThrow(/not surface-eligible and cannot carry surface metadata/)
  418. expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
  419. type: 'turn/start',
  420. seq: 0,
  421. time: 1,
  422. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  423. surfaceOp: 'append',
  424. } as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/)
  425. expect(session.events).toEqual([])
  426. })
  427. it('deep-freezes seeded and appended event snapshots', () => {
  428. const seeded = new Session(SessionId('seed-frozen'), [{
  429. type: 'turn/start',
  430. seq: 0,
  431. time: 1,
  432. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  433. }])
  434. const seededEvent = seeded.events[0]!
  435. if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
  436. expect(Object.isFrozen(seededEvent)).toBe(true)
  437. expect(Object.isFrozen(seededEvent.data)).toBe(true)
  438. expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
  439. expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
  440. const appended = new Session(SessionId('append-frozen'))
  441. const appendedEvent = appended.append('todo/write', {
  442. todos: [{ content: 'first', status: 'pending' }],
  443. })
  444. expect(Object.isFrozen(appendedEvent)).toBe(true)
  445. expect(Object.isFrozen(appendedEvent.data)).toBe(true)
  446. expect(Object.isFrozen(appendedEvent.data.todos)).toBe(true)
  447. expect(Object.isFrozen(appendedEvent.data.todos[0])).toBe(true)
  448. expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
  449. })
  450. it('returns cached frozen event-array snapshots that do not grow after append', () => {
  451. const session = new Session(SessionId('events-snapshot'))
  452. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  453. const before = session.events
  454. const beforeEvent = before[0]!
  455. if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
  456. expect(session.events).toBe(before)
  457. expect(Object.isFrozen(before)).toBe(true)
  458. expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError)
  459. expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError)
  460. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  461. const after = session.events
  462. expect(before).toHaveLength(1)
  463. expect(after).toHaveLength(2)
  464. expect(after).not.toBe(before)
  465. expect(session.events).toBe(after)
  466. })
  467. it('detaches and freezes an explicitly supplied session header', () => {
  468. const input = {
  469. version: SESSION_FORMAT_VERSION,
  470. id: SessionId('header-owned'),
  471. createdAt: 123,
  472. cwd: '/accepted',
  473. parentSession: SessionId('parent'),
  474. seedLength: 2,
  475. }
  476. const session = new Session(SessionId('header-owned'), undefined, input)
  477. input.cwd = '/caller-mutated'
  478. expect(session.header).toEqual({
  479. version: SESSION_FORMAT_VERSION,
  480. id: 'header-owned',
  481. createdAt: 123,
  482. cwd: '/accepted',
  483. parentSession: 'parent',
  484. seedLength: 2,
  485. })
  486. expect(session.header).not.toBe(input)
  487. expect(Object.isFrozen(session.header)).toBe(true)
  488. expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false)
  489. expect(session.id).toBe('header-owned')
  490. expect(session.header.cwd).toBe('/accepted')
  491. })
  492. it('rejects an exotic, non-JSON, or mismatched supplied header', () => {
  493. class ExoticHeader implements SessionHeader {
  494. readonly version = SESSION_FORMAT_VERSION
  495. readonly id = SessionId('header-invalid')
  496. readonly createdAt = 123
  497. }
  498. expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
  499. .toThrow(/not losslessly JSON-serializable/)
  500. expect(() => new Session(SessionId('header-invalid'), undefined, {
  501. version: SESSION_FORMAT_VERSION,
  502. id: SessionId('header-invalid'),
  503. createdAt: 123,
  504. parentSession: 1n,
  505. } as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
  506. expect(() => new Session(SessionId('header-invalid'), undefined, {
  507. version: SESSION_FORMAT_VERSION,
  508. id: SessionId('other'),
  509. createdAt: 123,
  510. })).toThrow(/does not match session id/)
  511. })
  512. it('rejects invalid scalar fields in an explicitly supplied header', () => {
  513. const base = {
  514. version: SESSION_FORMAT_VERSION,
  515. id: SessionId('header-shape'),
  516. createdAt: 123,
  517. }
  518. const cases: Array<{ header: unknown; error: RegExp }> = [
  519. { header: 1, error: /not a plain JSON record/ },
  520. { header: null, error: /not a plain JSON record/ },
  521. { header: { ...base, version: 1 }, error: /header version/ },
  522. { header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ },
  523. { header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
  524. { header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
  525. { header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
  526. { header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
  527. { header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
  528. { header: { ...base, seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
  529. ]
  530. for (const { header, error } of cases) {
  531. expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
  532. }
  533. })
  534. it('rejects seed records with invalid fixed-envelope fields', () => {
  535. const base = {
  536. type: 'turn/start',
  537. seq: 0,
  538. time: 1,
  539. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  540. }
  541. const cases: unknown[] = [
  542. { ...base, extra: true },
  543. { ...base, type: 1 },
  544. { ...base, seq: '0' },
  545. { ...base, seq: 0.5 },
  546. { ...base, seq: -1 },
  547. { ...base, time: '1' },
  548. { ...base, time: 0.5 },
  549. { ...base, time: -1 },
  550. { type: base.type, seq: base.seq, time: base.time },
  551. ]
  552. for (const [index, event] of cases.entries()) {
  553. expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
  554. .toThrow(/invalid event envelope/)
  555. }
  556. })
  557. })
  558. describe('SessionStore', () => {
  559. it('creates sessions, emits session/created and session/event', async () => {
  560. const ctx = new Context()
  561. await ctx.plugin(SessionStore)
  562. const created: Session[] = []
  563. const events: [Session, SessionEvent][] = []
  564. ctx.on('session/created', session => void created.push(session))
  565. ctx.on('session/event', (session, event) => void events.push([session, event]))
  566. const session = ctx.sessions.create()
  567. expect(created).toEqual([session])
  568. // The store-owned append publication hooks are module-private. A JavaScript caller
  569. // may create an unrelated property with the old implementation's name,
  570. // but cannot suppress the durable event feed.
  571. expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
  572. session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  573. expect(events).toHaveLength(1)
  574. expect(events[0]![0]).toBe(session)
  575. expect(events[0]![1].type).toBe('user/message')
  576. expect(ctx.sessions.get(session.id)).toBe(session)
  577. expect(ctx.sessions.list()).toEqual([session])
  578. })
  579. it('rejects duplicate ids and supports seeding', async () => {
  580. const ctx = new Context()
  581. await ctx.plugin(SessionStore)
  582. const a = ctx.sessions.create(SessionId('fixed'))
  583. expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
  584. a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  585. const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
  586. expect(forked.deriveMessages()).toEqual(a.deriveMessages())
  587. })
  588. it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => {
  589. // prepare()/enter() are public cross-package primitives that a caller may
  590. // separate with arbitrary work. A stale prepared session must NOT overwrite
  591. // a live store entry of the same id — its detach disposer would later delete
  592. // the REAL session, breaking the store-uniqueness invariant.
  593. const ctx = new Context()
  594. await ctx.plugin(SessionStore)
  595. const stale = ctx.sessions.prepare(SessionId('racy'))
  596. const live = ctx.sessions.create(SessionId('racy'))
  597. expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/)
  598. // The live session is intact and still the store entry.
  599. expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
  600. })
  601. it('prepare() + enter() + announce() register a session and emit session/created', async () => {
  602. const ctx = new Context()
  603. await ctx.plugin(SessionStore)
  604. const created: Session[] = []
  605. ctx.on('session/created', session => void created.push(session))
  606. const session = ctx.sessions.prepare(SessionId('lifecycle'))
  607. // prepare alone does NOT enter the store.
  608. expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
  609. const detach = ctx.sessions.enter(session)
  610. expect(ctx.sessions.get(SessionId('lifecycle'))).toBe(session)
  611. // enter does NOT announce.
  612. expect(created).toEqual([])
  613. ctx.sessions.announce(session)
  614. expect(created).toEqual([session])
  615. // The detach disposer removes the entry + stops notification.
  616. detach()
  617. detach() // idempotent: cannot disturb a later same-id lifecycle
  618. expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
  619. })
  620. it('prevents simultaneous attachment of one session object to two stores', async () => {
  621. const firstCtx = new Context()
  622. const secondCtx = new Context()
  623. await firstCtx.plugin(SessionStore)
  624. await secondCtx.plugin(SessionStore)
  625. const session = new Session(SessionId('owned-key'))
  626. const detachFirst = firstCtx.sessions.enter(session)
  627. expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
  628. expect(firstCtx.sessions.get(SessionId('owned-key'))).toBe(session)
  629. detachFirst()
  630. expect(firstCtx.sessions.get(SessionId('owned-key'))).toBeUndefined()
  631. const detachSecond = secondCtx.sessions.enter(session)
  632. expect(secondCtx.sessions.get(SessionId('owned-key'))).toBe(session)
  633. detachSecond()
  634. })
  635. it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => {
  636. const ctx = new Context()
  637. await ctx.plugin(SessionStore)
  638. let created = 0
  639. let disposed = 0
  640. let reentrantError = ''
  641. ctx.on('session/created', (session) => {
  642. created += 1
  643. try {
  644. ctx.sessions.announce(session)
  645. } catch (error: unknown) {
  646. reentrantError = String(error)
  647. }
  648. })
  649. ctx.on('session/disposed', () => { disposed += 1 })
  650. const session = ctx.sessions.prepare(SessionId('once'))
  651. const detach = ctx.sessions.enter(session)
  652. ctx.sessions.announce(session)
  653. expect(reentrantError).toMatch(/already announced/)
  654. expect(() => { ctx.sessions.announce(session) }).toThrow(/already announced/)
  655. detach()
  656. expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
  657. })
  658. it('defers a reentrant detach until the creation dispatch unwinds', async () => {
  659. const ctx = new Context()
  660. await ctx.plugin(SessionStore)
  661. const order: string[] = []
  662. const session = ctx.sessions.prepare(SessionId('reentrant-detach'))
  663. const detach = ctx.sessions.enter(session)
  664. ctx.on('session/created', (created) => {
  665. order.push('created:first')
  666. detach()
  667. expect(ctx.sessions.get(created.id)).toBe(created)
  668. })
  669. ctx.on('session/created', (created) => {
  670. order.push('created:second')
  671. expect(ctx.sessions.get(created.id)).toBe(created)
  672. })
  673. ctx.on('session/disposed', (disposed) => {
  674. order.push('disposed')
  675. expect(ctx.sessions.get(disposed.id)).toBeUndefined()
  676. })
  677. ctx.sessions.announce(session)
  678. expect(order).toEqual(['created:first', 'created:second', 'disposed'])
  679. expect(ctx.sessions.get(session.id)).toBeUndefined()
  680. detach()
  681. })
  682. it('rolls back create when its owner unloads from session/created', async () => {
  683. const ctx = new Context()
  684. await ctx.plugin(SessionStore)
  685. let ownerCtx!: Context
  686. const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['sessions'] }))
  687. const id = SessionId('create-unload-race')
  688. ctx.on('session/created', (session) => {
  689. if (session.id === id) void owner.dispose()
  690. })
  691. ownerCtx.sessions.create(id)
  692. await owner.dispose()
  693. expect(ctx.sessions.get(id)).toBeUndefined()
  694. })
  695. it('synthesizes a minimal current-version header for a bare-created session', async () => {
  696. const ctx = new Context()
  697. await ctx.plugin(SessionStore)
  698. const session = ctx.sessions.create(SessionId('plain'))
  699. expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
  700. expect(typeof session.header.createdAt).toBe('number')
  701. expect(session.header.cwd).toBeUndefined()
  702. expect(session.header.parentSession).toBeUndefined()
  703. })
  704. it('attaches cwd and parentSession from meta to the header', async () => {
  705. const ctx = new Context()
  706. await ctx.plugin(SessionStore)
  707. const session = ctx.sessions.create(SessionId('child'), {
  708. meta: { cwd: '/work/project', parentSession: SessionId('parent') },
  709. })
  710. expect(session.header).toMatchObject({
  711. version: SESSION_FORMAT_VERSION,
  712. id: 'child',
  713. cwd: '/work/project',
  714. parentSession: 'parent',
  715. })
  716. })
  717. it('rejects non-JSON and invalid scalar session metadata', async () => {
  718. const ctx = new Context()
  719. await ctx.plugin(SessionStore)
  720. const cases: Array<{ meta: unknown; error: RegExp }> = [
  721. { meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
  722. { meta: { cwd: 1 }, error: /header cwd must be a string/ },
  723. { meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
  724. { meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
  725. { meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
  726. { meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
  727. { meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
  728. ]
  729. for (const [index, { meta, error }] of cases.entries()) {
  730. expect(() => ctx.sessions.prepare(SessionId(`bad-meta-${index}`), {
  731. meta: meta as NonNullable<CreateSessionOptions['meta']>,
  732. })).toThrow(error)
  733. }
  734. })
  735. it('rejects a non-absolute meta.cwd', async () => {
  736. const ctx = new Context()
  737. await ctx.plugin(SessionStore)
  738. expect(() => ctx.sessions.create(SessionId('rel'), { meta: { cwd: 'relative/path' } }))
  739. .toThrow(/cwd must be an absolute path/)
  740. // the rejected session was not registered
  741. expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined()
  742. })
  743. it('a bare Session() constructed without the store still exposes a current-version header', () => {
  744. const session = new Session(SessionId('bare'))
  745. expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
  746. expect(typeof session.header.createdAt).toBe('number')
  747. })
  748. it('detaches sessions when the creating fiber is disposed (HMR safety)', async () => {
  749. const ctx = new Context()
  750. await ctx.plugin(SessionStore)
  751. let session!: Session
  752. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  753. session = inner.sessions.create(SessionId('scoped'))
  754. }, { inject: ['sessions'] }))
  755. expect(ctx.sessions.get(SessionId('scoped'))).toBe(session)
  756. let observed = 0
  757. ctx.on('session/event', () => void observed++)
  758. await fiber.dispose()
  759. expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
  760. session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  761. expect(observed).toBe(0)
  762. })
  763. it('pairs a partial session/created announcement with disposal during rollback', async () => {
  764. const ctx = new Context()
  765. await ctx.plugin(SessionStore)
  766. let threw = false
  767. const disposed: Session[] = []
  768. ctx.on('session/disposed', (session) => { disposed.push(session) })
  769. ctx.on('session/created', () => {
  770. if (!threw) { threw = true; throw new Error('boom created listener') }
  771. })
  772. // The throwing emit must roll the store entry back, not leak it.
  773. expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener')
  774. expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked
  775. expect(disposed.map(session => session.id)).toEqual(['fixed'])
  776. // A subsequent create of the SAME id succeeds (the already-exists check is
  777. // not wedged) and its store-owned publication hooks are correctly wired.
  778. const events: SessionEvent[] = []
  779. ctx.on('session/event', (_session, event) => void events.push(event))
  780. const session = ctx.sessions.create(SessionId('fixed'))
  781. expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
  782. session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  783. expect(events).toHaveLength(1)
  784. })
  785. it('contains session/event observer failures after the append commit point', async () => {
  786. const ctx = new Context()
  787. await ctx.plugin(SessionStore)
  788. const warnings: string[] = []
  789. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  790. const session = ctx.sessions.create(SessionId('contained-event'))
  791. const heard: SessionEvent[] = []
  792. let committedBeforeNotify = false
  793. ctx.on('session/event', (observedSession, event) => {
  794. committedBeforeNotify = observedSession.events.at(-1) === event
  795. throw new Error('sync event observer')
  796. })
  797. ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never)
  798. ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
  799. let appended!: SessionEvent
  800. expect(() => {
  801. appended = session.append('turn/start', {
  802. turn: 1,
  803. trigger: { kind: 'message', source: { kind: 'user' } },
  804. })
  805. }).not.toThrow()
  806. expect(committedBeforeNotify).toBe(true)
  807. expect(session.events).toEqual([appended])
  808. expect(heard).toEqual([appended])
  809. await Promise.resolve()
  810. await Promise.resolve()
  811. expect(warnings).toEqual([
  812. 'session "contained-event": session/event listener threw: Error: sync event observer',
  813. 'session "contained-event": session/event listener rejected: Error: async event observer',
  814. ])
  815. })
  816. it('runs internal dispatch validation on one frozen candidate before commit and resets after a veto', async () => {
  817. const ctx = new Context()
  818. await ctx.plugin(SessionStore)
  819. const session = ctx.sessions.create(SessionId('dispatch-veto'))
  820. const validations: Array<{ event: SessionEvent; logLength: number; frozen: boolean }> = []
  821. const observed: SessionEvent[] = []
  822. let reject = true
  823. ctx.on('internal/dispatch', (_mode, name, args) => {
  824. if (name !== 'session/event') return
  825. const [observedSession, event] = args as [Session, SessionEvent]
  826. validations.push({
  827. event,
  828. logLength: observedSession.events.length,
  829. frozen: Object.isFrozen(event) && Object.isFrozen(event.data),
  830. })
  831. if (reject) {
  832. reject = false
  833. throw new Error('reject first candidate')
  834. }
  835. })
  836. ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
  837. expect(() => session.append('turn/start', {
  838. turn: 1,
  839. trigger: { kind: 'message', source: { kind: 'user' } },
  840. })).toThrow('reject first candidate')
  841. expect(session.events).toEqual([])
  842. expect(observed).toEqual([])
  843. const appended = session.append('turn/start', {
  844. turn: 1,
  845. trigger: { kind: 'message', source: { kind: 'user' } },
  846. })
  847. expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([
  848. { logLength: 0, frozen: true },
  849. { logLength: 0, frozen: true },
  850. ])
  851. expect(validations.map(({ event }) => event.seq)).toEqual([0, 0])
  852. expect(validations[1]!.event).toBe(appended)
  853. expect(session.events).toEqual([appended])
  854. expect(observed).toEqual([appended])
  855. })
  856. it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => {
  857. const ctx = new Context()
  858. await ctx.plugin(SessionStore)
  859. const session = ctx.sessions.create(SessionId('dispatch-check'))
  860. const observed: SessionEvent[] = []
  861. ctx.on('internal/dispatch', (_mode, name) => {
  862. if (name === 'session/event') throw new Error('dispatch instrumentation rejected the carrier')
  863. })
  864. ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
  865. expect(() => session.append('turn/start', {
  866. turn: 1,
  867. trigger: { kind: 'message', source: { kind: 'user' } },
  868. })).toThrow('dispatch instrumentation rejected the carrier')
  869. expect(session.events).toEqual([])
  870. expect(observed).toEqual([])
  871. })
  872. it('contains a reentrant observer append without reordering later observers', async () => {
  873. const ctx = new Context()
  874. await ctx.plugin(SessionStore)
  875. const warnings: string[] = []
  876. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  877. const session = ctx.sessions.create(SessionId('reentrant-observer'))
  878. const heard: SessionEvent[] = []
  879. ctx.on('session/event', (observedSession) => {
  880. observedSession.append('todo/write', { todos: [] })
  881. })
  882. ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
  883. const appended = session.append('turn/start', {
  884. turn: 1,
  885. trigger: { kind: 'message', source: { kind: 'user' } },
  886. })
  887. expect(session.events).toEqual([appended])
  888. expect(heard).toEqual([appended])
  889. expect(warnings).toEqual([
  890. 'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being published',
  891. ])
  892. })
  893. it('defers detach through dispatch resolution, commit, and observer publication', async () => {
  894. const ctx = new Context()
  895. await ctx.plugin(SessionStore)
  896. const order: string[] = []
  897. const session = ctx.sessions.prepare(SessionId('detach-during-append'))
  898. const detach = ctx.sessions.enter(session)
  899. ctx.on('internal/dispatch', (_mode, name, args) => {
  900. if (name !== 'session/event') return
  901. const session = args[0] as Session
  902. order.push(`resolve:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
  903. detach()
  904. })
  905. ctx.on('session/event', (session) => {
  906. order.push(`observe:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
  907. })
  908. ctx.on('session/disposed', (session) => {
  909. order.push(`dispose:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
  910. })
  911. ctx.sessions.announce(session)
  912. const appended = session.append('turn/start', {
  913. turn: 1,
  914. trigger: { kind: 'message', source: { kind: 'user' } },
  915. })
  916. expect(session.events).toEqual([appended])
  917. expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached'])
  918. expect(ctx.sessions.get(session.id)).toBeUndefined()
  919. })
  920. it('observes async session/created rejection without rolling back or starving peers', async () => {
  921. const ctx = new Context()
  922. await ctx.plugin(SessionStore)
  923. const warnings: string[] = []
  924. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  925. const heard: string[] = []
  926. ctx.on('session/created', () => Promise.reject(new Error('late creation failure')) as never)
  927. ctx.on('session/created', (session) => { heard.push(session.id) })
  928. const session = ctx.sessions.create(SessionId('async-created'))
  929. await Promise.resolve()
  930. await Promise.resolve()
  931. expect(ctx.sessions.get(session.id)).toBe(session)
  932. expect(heard).toEqual(['async-created'])
  933. expect(warnings).toEqual([
  934. 'session "async-created": session/created listener rejected: Error: late creation failure',
  935. ])
  936. })
  937. it('contains synchronous and async session/disposed listener failures per observer', async () => {
  938. const ctx = new Context()
  939. await ctx.plugin(SessionStore)
  940. const warnings: string[] = []
  941. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  942. const heard: string[] = []
  943. ctx.on('session/disposed', () => { throw new Error('sync disposed') })
  944. ctx.on('session/disposed', () => Promise.reject(new Error('async disposed')) as never)
  945. ctx.on('session/disposed', (session) => { heard.push(session.id) })
  946. const unannounced = ctx.sessions.prepare(SessionId('never-announced'))
  947. const detachUnannounced = ctx.sessions.enter(unannounced)
  948. detachUnannounced()
  949. expect(heard).toEqual([])
  950. const announced = ctx.sessions.prepare(SessionId('contained-disposal'))
  951. const detach = ctx.sessions.enter(announced)
  952. ctx.sessions.announce(announced)
  953. expect(() => { detach() }).not.toThrow()
  954. await Promise.resolve()
  955. await Promise.resolve()
  956. expect(heard).toEqual(['contained-disposal'])
  957. expect(warnings).toEqual([
  958. 'session "contained-disposal": session/disposed listener threw: Error: sync disposed',
  959. 'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
  960. ])
  961. })
  962. it('contains internal dispatch failure after session detachment', async () => {
  963. const ctx = new Context()
  964. await ctx.plugin(SessionStore)
  965. const warnings: string[] = []
  966. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  967. const heard: Session[] = []
  968. ctx.on('internal/dispatch', (_mode, name) => {
  969. if (name === 'session/disposed') throw new Error('disposed dispatch instrumentation')
  970. })
  971. ctx.on('session/disposed', (session) => { heard.push(session) })
  972. const session = ctx.sessions.prepare(SessionId('disposed-dispatch'))
  973. const detach = ctx.sessions.enter(session)
  974. ctx.sessions.announce(session)
  975. expect(() => { detach() }).not.toThrow()
  976. expect(ctx.sessions.get(session.id)).toBeUndefined()
  977. expect(heard).toEqual([])
  978. expect(warnings).toEqual([
  979. 'session "disposed-dispatch": session/disposed dispatch threw: Error: disposed dispatch instrumentation',
  980. ])
  981. })
  982. it('does not let internal dispatch replace the disposed callback tuple', async () => {
  983. const ctx = new Context()
  984. await ctx.plugin(SessionStore)
  985. const replacement = new Session(SessionId('replacement-disposed'))
  986. const heard: Session[] = []
  987. ctx.on('internal/dispatch', (_mode, name, args) => {
  988. if (name === 'session/disposed') args[0] = replacement
  989. })
  990. ctx.on('session/disposed', (session) => { heard.push(session) })
  991. const session = ctx.sessions.prepare(SessionId('fixed-disposed-tuple'))
  992. const detach = ctx.sessions.enter(session)
  993. ctx.sessions.announce(session)
  994. detach()
  995. expect(heard).toEqual([session])
  996. })
  997. })
  998. describe('todo/write event', () => {
  999. it('appends the whole-list snapshot and isolates the log from later mutation', () => {
  1000. const session = new Session(SessionId('t1'))
  1001. const todos: TodoItem[] = [
  1002. { content: 'plan the work', status: 'in_progress' },
  1003. { content: 'write the code', status: 'pending' },
  1004. ]
  1005. session.append('todo/write', { todos })
  1006. const event = session.events.findLast(e => e.type === 'todo/write')!
  1007. expect(event.type).toBe('todo/write')
  1008. expect(event.data.todos).toEqual(todos)
  1009. // The append snapshots its input: mutating the caller's array afterward must
  1010. // not change what the log holds (the durable-source-of-truth contract).
  1011. todos.push({ content: 'sneak in', status: 'pending' })
  1012. todos[0]!.status = 'completed'
  1013. expect(event.data.todos).toEqual([
  1014. { content: 'plan the work', status: 'in_progress' },
  1015. { content: 'write the code', status: 'pending' },
  1016. ])
  1017. })
  1018. it('is last-write-wins: the current list is the most recent todo/write', () => {
  1019. const session = new Session(SessionId('t2'))
  1020. session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
  1021. session.append('todo/write', { todos: [
  1022. { content: 'first', status: 'completed' },
  1023. { content: 'second', status: 'in_progress' },
  1024. ] })
  1025. const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos
  1026. expect(current).toEqual([
  1027. { content: 'first', status: 'completed' },
  1028. { content: 'second', status: 'in_progress' },
  1029. ])
  1030. })
  1031. it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
  1032. const session = new Session(SessionId('t3'))
  1033. session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  1034. const before = session.deriveMessages().length
  1035. session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
  1036. // The todo event must not add a message to the derived history…
  1037. expect(session.deriveMessages()).toHaveLength(before)
  1038. // …and must not appear on the surface linked list.
  1039. expect(session.surface.nodes.some(node => node.seq === session.seq - 1)).toBe(false)
  1040. })
  1041. it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
  1042. const original = new Session(SessionId('t4'))
  1043. original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  1044. original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
  1045. original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  1046. // Seeding a non-surface event with no surfaceOp must not throw.
  1047. const replayed = new Session(SessionId('t4-replay'), [...original.events])
  1048. expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
  1049. .toEqual([{ content: 'only', status: 'completed' }])
  1050. expect(replayed.seq).toBe(original.seq)
  1051. })
  1052. })