session.spec.ts 62 KB

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