session.spec.ts 51 KB

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