session.spec.ts 70 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800
  1. import { describe, expect, expectTypeOf, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { createSystemMessage, createUserMessage, ToolCallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, {
  5. adoptSessionEvent,
  6. SESSION_FORMAT_VERSION,
  7. Session,
  8. SessionEvent,
  9. SessionId,
  10. SessionLogOffset,
  11. SessionSeq,
  12. snapshotSessionEvent,
  13. } from '@deepseek-ai/dsh-session'
  14. import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface } from '@deepseek-ai/dsh-session'
  15. describe('Session', () => {
  16. it('exposes one stable readonly surface view', () => {
  17. const session = Session.create(SessionId('surface-view'))
  18. const surface = session.surface
  19. expectTypeOf(surface).toEqualTypeOf<SessionSurface>()
  20. expect(surface).toBe(session.surface)
  21. })
  22. it('derives message history from the event log', () => {
  23. const session = Session.create(SessionId('s1'))
  24. session.append('turn/start', { turn: 1 })
  25. session.append('user/message', createUserMessage({
  26. content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
  27. }), { surfaceOp: 'append' })
  28. session.append('assistant/attempt', {
  29. turn: 1, step: 1,
  30. stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['hi'] }],
  31. })
  32. session.append('assistant/message', {
  33. stream: [],
  34. turn: 1, step: 1,
  35. message: createMessage({
  36. role: 'assistant',
  37. content: [
  38. { type: 'text', text: 'let me check' },
  39. { type: 'tool-call', id: ToolCallId('c1'), name: 'echo', arguments: '{}' },
  40. ],
  41. source: {
  42. kind: 'model',
  43. ...{ provider: 'mock', model: 'mock' },
  44. },
  45. }),
  46. }, { surfaceOp: 'append' })
  47. session.append('tool/result', {
  48. turn: 1, step: 1,
  49. message: createToolResultMessage({
  50. callId: ToolCallId('c1'),
  51. content: [{ type: 'text', text: 'ok' }],
  52. isError: false,
  53. }),
  54. }, { surfaceOp: 'append' })
  55. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  56. const messages = session.deriveMessages()
  57. expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
  58. // raw chunks must NOT appear in derived history
  59. expect(messages[1]!.content).toHaveLength(2)
  60. expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: ToolCallId('c1') })
  61. })
  62. it('accepts and round-trips a max-tokens turn/end reason', () => {
  63. // The max-tokens TurnEndReason variant carries no extra data, so it must
  64. // append and persist like any other reason (JSON-serializable, no fields).
  65. const session = Session.create(SessionId('s1'))
  66. session.append('turn/start', { turn: 1 })
  67. session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
  68. const turnEnd = session.snapshotEvents().findLast(e => e.type === 'turn/end')!
  69. expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' })
  70. // survives a structuredClone (the persistence-serialization boundary)
  71. expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
  72. })
  73. it('round-trips an aborted turn with its cancellation cause', () => {
  74. const session = Session.create(SessionId('aborted'))
  75. session.append('turn/start', { turn: 1 })
  76. session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
  77. const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.snapshotEvents()))
  78. expect(replayed.snapshotEvents().slice(0, -1)).toEqual(session.snapshotEvents())
  79. const turnEnd = replayed.snapshotEvents().findLast(event => event.type === 'turn/end')
  80. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason)
  81. .toEqual({ kind: 'aborted', reason: { kind: 'user' } })
  82. })
  83. it('renders injected-context and user messages as plain user content', () => {
  84. const session = Session.create(SessionId('s2'))
  85. session.append('user/message', createUserMessage({
  86. content: [{ type: 'text', text: 'file changed: a.ts' }],
  87. source: { kind: 'plugin', plugin: 'watcher' },
  88. }), { surfaceOp: 'append' })
  89. session.append('user/message', createUserMessage({
  90. content: [{ type: 'text', text: 'focus on tests' }],
  91. source: { kind: 'user' },
  92. }), { surfaceOp: 'append' })
  93. const [contextMessage, steeringMessage] = session.deriveMessages()
  94. expect(contextMessage!.role).toBe('user')
  95. expect(contextMessage!.content).toEqual([{ type: 'text', text: 'file changed: a.ts' }])
  96. expect(steeringMessage!.role).toBe('user')
  97. expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
  98. })
  99. it('keeps the exact identified context message in durable history and projection', () => {
  100. const session = Session.create(SessionId('s2-raw'))
  101. const message = createUserMessage({
  102. content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
  103. source: { kind: 'plugin', plugin: 'agent-instructions' },
  104. })
  105. session.append('user/message', message, { surfaceOp: 'append' })
  106. expect(session.deriveMessages()).toEqual([message])
  107. const event = session.snapshotEvents()[0]
  108. expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'agent-instructions' })
  109. })
  110. it('replays identically from a seeded event log', () => {
  111. const original = Session.create(SessionId('s3'))
  112. original.append('turn/start', { turn: 1 })
  113. original.append('user/message', createUserMessage({
  114. content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
  115. }), { surfaceOp: 'append' })
  116. original.append('assistant/message', {
  117. stream: [],
  118. turn: 1, step: 1,
  119. message: createMessage({
  120. role: 'assistant',
  121. content: [{ type: 'text', text: 'a' }],
  122. source: {
  123. kind: 'model',
  124. ...{ provider: 'mock', model: 'mock' },
  125. },
  126. }),
  127. }, { surfaceOp: 'append' })
  128. original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  129. const replayed = Session.create(SessionId('s3-replay'), original.snapshotEvents())
  130. expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
  131. // The seed verbatim, plus the end-seed event the constructor appends.
  132. expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents())
  133. expect(replayed.seq).toBe(original.seq + 1)
  134. expect(replayed.firstLiveSeq).toBe(original.seq)
  135. })
  136. it('marks an explicitly empty seed without marking a fresh session', () => {
  137. const fresh = Session.create(SessionId('fresh-empty'))
  138. expect(fresh.snapshotEvents()).toEqual([])
  139. const resumed = Session.create(SessionId('resumed-empty'), [])
  140. expect(resumed.firstLiveSeq).toBe(0)
  141. expect(resumed.snapshotEvents()).toMatchObject([
  142. { type: 'session/end-seed', seq: 0, data: {} },
  143. ])
  144. const reopened = Session.create(SessionId('reopened-empty'), resumed.snapshotEvents())
  145. expect(reopened.firstLiveSeq).toBe(1)
  146. expect(reopened.snapshotEvents()).toEqual(resumed.snapshotEvents())
  147. })
  148. it('rejects pre-provider request headers and assistant messages on seed/load', () => {
  149. const requestHeader = {
  150. type: 'request/header', seq: 0, time: 1,
  151. data: { header: { config: { model: 'old-model' } }, reason: 'initial' },
  152. } as unknown as SessionEvent
  153. expect(() => Session.create(SessionId('old-header'), [requestHeader]))
  154. .toThrow('seed request/header at index 0 lacks provider/model')
  155. const assistantMessage = {
  156. type: 'assistant/message', seq: 0, time: 1,
  157. data: { turn: 1, step: 1, content: [{ type: 'text', text: 'old' }] },
  158. surfaceOp: 'append',
  159. } as unknown as SessionEvent
  160. expect(() => Session.create(SessionId('old-assistant'), [assistantMessage]))
  161. .toThrow('seed assistant/message at index 0 lacks an identified message')
  162. const malformedHeader = {
  163. type: 'request/header', seq: 0, time: 1,
  164. data: { header: 'old-header' },
  165. } as unknown as SessionEvent
  166. expect(() => Session.create(SessionId('malformed-header'), [malformedHeader]))
  167. .toThrow('seed request/header at index 0 header must be an object')
  168. const unrelatedPrimitiveData = {
  169. type: 'plugin/event', seq: 0, time: 1, data: null,
  170. } as unknown as SessionEvent
  171. expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).snapshotEvents().slice(0, 1))
  172. .toEqual([unrelatedPrimitiveData])
  173. })
  174. it('validates Assistant settlement fields without replaying embedded streams', () => {
  175. const id = SessionId('invalid-restored-assistant-stream')
  176. const header = {
  177. version: SESSION_FORMAT_VERSION,
  178. id,
  179. createdAt: 1,
  180. isSeeded: false,
  181. delegationDepth: 0,
  182. } as const
  183. for (const data of [
  184. null,
  185. { turn: '1', step: 1, stream: [] },
  186. { turn: -1, step: 1, stream: [] },
  187. { turn: -0, step: 1, stream: [] },
  188. { turn: 1.5, step: 1, stream: [] },
  189. { turn: 1, step: '1', stream: [] },
  190. { turn: 1, step: -1, stream: [] },
  191. { turn: 1, step: -0, stream: [] },
  192. { turn: 1, step: 1.5, stream: [] },
  193. { turn: 1, step: 1, stream: null },
  194. ]) {
  195. const invalidAttempt = {
  196. type: 'assistant/attempt', seq: 0, time: 1, data,
  197. } as unknown as SessionEvent
  198. expect(() => Session.fromRestore(
  199. id,
  200. [invalidAttempt],
  201. header,
  202. SessionLogOffset(0),
  203. 'detached',
  204. ))
  205. .toThrow(/invalid settlement fields/)
  206. }
  207. const mismatchedMessage = {
  208. type: 'assistant/message',
  209. seq: 0,
  210. time: 1,
  211. data: {
  212. turn: 1,
  213. step: 1,
  214. message: {
  215. id: 'message',
  216. role: 'assistant',
  217. content: [{ type: 'text', text: 'different' }],
  218. source: { kind: 'model', provider: 'mock', model: 'mock' },
  219. },
  220. stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['streamed'] }],
  221. },
  222. surfaceOp: 'append',
  223. } as unknown as SessionEvent
  224. const restored = Session.fromRestore(
  225. id,
  226. [mismatchedMessage],
  227. header,
  228. SessionLogOffset(0),
  229. 'detached',
  230. )
  231. expect(restored.eventAt(SessionSeq(0))).toBe(mismatchedMessage)
  232. expect(Object.isFrozen(mismatchedMessage)).toBe(false)
  233. })
  234. it('rejects historical or malformed request-header lifecycle markers on seed/load', () => {
  235. const base = {
  236. type: 'request/header', seq: SessionSeq(0), time: 1,
  237. data: { header: { config: { provider: 'mock', model: 'model' } }, reason: 'initial' },
  238. } as const
  239. for (const reason of ['fallback', 'unknown', null]) {
  240. const invalid = structuredClone(base) as unknown as SessionEvent
  241. if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
  242. invalid.data.reason = reason as never
  243. expect(() => Session.create(SessionId('invalid-header-reason'), [invalid]))
  244. .toThrow('seed request/header at index 0 has an invalid reason')
  245. }
  246. for (const startsSeries of [false, 1, 'true']) {
  247. const invalid = structuredClone(base) as unknown as SessionEvent
  248. if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
  249. invalid.data.startsSeries = startsSeries as never
  250. expect(() => Session.create(SessionId('invalid-series-marker'), [invalid]))
  251. .toThrow('seed request/header at index 0 has an invalid startsSeries marker')
  252. }
  253. })
  254. it('rejects event-specific malformed message shapes on seed/load', () => {
  255. const user = {
  256. id: 'user',
  257. role: 'user',
  258. content: [{ type: 'text', text: 'content' }],
  259. source: { kind: 'user' },
  260. }
  261. const assistant = {
  262. id: 'assistant',
  263. role: 'assistant',
  264. content: [{ type: 'text', text: 'content' }],
  265. source: { kind: 'model', provider: 'mock', model: 'mock' },
  266. }
  267. const tool = {
  268. id: 'tool',
  269. role: 'user',
  270. content: [{
  271. type: 'tool-result',
  272. toolCallId: 'call',
  273. content: [{ type: 'text', text: 'result' }],
  274. }],
  275. source: { kind: 'tool', callId: 'call' },
  276. }
  277. const invalid = [
  278. {
  279. name: 'message record',
  280. event: {
  281. type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
  282. data: null,
  283. },
  284. message: 'lacks an identified message',
  285. },
  286. {
  287. name: 'user role',
  288. event: {
  289. type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
  290. data: { ...user, role: 'assistant' },
  291. },
  292. message: 'message must have role "user"',
  293. },
  294. {
  295. name: 'source',
  296. event: {
  297. type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
  298. data: { ...user, source: null },
  299. },
  300. message: 'message has invalid source',
  301. },
  302. {
  303. name: 'content shape',
  304. event: {
  305. type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
  306. data: { ...user, content: 'not-an-array' },
  307. },
  308. message: 'message has invalid content',
  309. },
  310. {
  311. name: 'assistant source',
  312. event: {
  313. type: 'assistant/message', seq: 0, time: 1, surfaceOp: 'append',
  314. data: {
  315. turn: 1,
  316. step: 1,
  317. message: { ...assistant, source: { kind: 'user' } },
  318. },
  319. },
  320. message: 'message must have model source',
  321. },
  322. {
  323. name: 'tool source',
  324. event: {
  325. type: 'tool/result', seq: 0, time: 1, surfaceOp: 'append',
  326. data: {
  327. turn: 1,
  328. step: 1,
  329. message: { ...tool, source: { kind: 'user' } },
  330. },
  331. },
  332. message: 'message must have tool source',
  333. },
  334. {
  335. name: 'system role',
  336. event: {
  337. type: 'system/message', seq: 0, time: 1, surfaceOp: 'append',
  338. data: {
  339. turn: 1,
  340. step: 1,
  341. message: { ...user, id: 'system', source: { kind: 'plugin', plugin: 'prompt' } },
  342. },
  343. },
  344. message: 'message must have role "system"',
  345. },
  346. {
  347. name: 'system source',
  348. event: {
  349. type: 'system/message', seq: 0, time: 1, surfaceOp: 'append',
  350. data: {
  351. turn: 1,
  352. step: 1,
  353. message: { ...user, id: 'system', role: 'system', source: { kind: 'plugin', plugin: '' } },
  354. },
  355. },
  356. message: 'message must have plugin source',
  357. },
  358. {
  359. name: 'tool tuple',
  360. event: {
  361. type: 'tool/result', seq: 0, time: 1, surfaceOp: 'append',
  362. data: {
  363. turn: 1,
  364. step: 1,
  365. message: { ...tool, content: [{ type: 'text', text: 'not a result' }] },
  366. },
  367. },
  368. message: 'message must contain one tool-result block',
  369. },
  370. {
  371. name: 'tool correlation',
  372. event: {
  373. type: 'tool/result', seq: 0, time: 1, surfaceOp: 'append',
  374. data: {
  375. turn: 1,
  376. step: 1,
  377. message: {
  378. ...tool,
  379. source: { kind: 'tool', callId: 'other-call' },
  380. },
  381. },
  382. },
  383. message: 'message has mismatched tool call ids',
  384. },
  385. ] as const
  386. for (const { name, event, message } of invalid) {
  387. expect(
  388. () => Session.create(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
  389. name,
  390. ).toThrow(message)
  391. }
  392. })
  393. it('snapshots message events without validating plugin-owned block details', () => {
  394. const boundary = snapshotSessionEvent({
  395. type: 'turn/start',
  396. seq: SessionSeq(0),
  397. time: 1,
  398. data: { turn: 1 },
  399. })
  400. expect(boundary).toEqual({
  401. type: 'turn/start',
  402. seq: SessionSeq(0),
  403. time: 1,
  404. data: { turn: 1 },
  405. })
  406. const extended = snapshotSessionEvent({
  407. type: 'user/message',
  408. seq: SessionSeq(0),
  409. time: 1,
  410. surfaceOp: 'append',
  411. data: {
  412. id: 'extended-message',
  413. role: 'user',
  414. content: [{ type: 'plugin-block', value: 1 }],
  415. source: { kind: 'plugin-source', value: 1 },
  416. },
  417. } as unknown as SessionEvent)
  418. expect(extended.type === 'user/message' && extended.data.content)
  419. .toEqual([{ type: 'plugin-block', value: 1 }])
  420. })
  421. it('adopts exclusively owned messages in place and keeps snapshots detached', () => {
  422. const owned = {
  423. type: 'user/message',
  424. seq: 0,
  425. time: 1,
  426. surfaceOp: 'append',
  427. data: {
  428. id: 'owned-message',
  429. role: 'user',
  430. content: [{ type: 'text', text: 'owned' }],
  431. source: { kind: 'user' },
  432. },
  433. } as SessionEvent<'user/message'>
  434. expect(adoptSessionEvent(owned)).toBe(owned)
  435. expect(Object.isFrozen(owned.data)).toBe(true)
  436. expect(Object.isFrozen(owned.data.content)).toBe(true)
  437. const source = structuredClone(owned)
  438. const snapshot = snapshotSessionEvent(source)
  439. expect(snapshot).not.toBe(source)
  440. expect(snapshot.data).not.toBe(source.data)
  441. expect(snapshot.data.content).not.toBe(source.data.content)
  442. })
  443. it('adopts a system/message by freezing its message', () => {
  444. const event = {
  445. type: 'system/message',
  446. seq: SessionSeq(0),
  447. time: 1,
  448. surfaceOp: 'append',
  449. data: {
  450. turn: 1,
  451. step: 1,
  452. message: createSystemMessage('You are terse.', '@deepseek-ai/dsh-system-prompt'),
  453. },
  454. } as unknown as SessionEvent
  455. const adopted = adoptSessionEvent(event)
  456. expect(adopted.type === 'system/message' && Object.isFrozen(adopted.data.message)).toBe(true)
  457. expect(adopted.type === 'system/message' && Object.isFrozen(adopted.data.message.content[0])).toBe(true)
  458. })
  459. it('validates message shape before adopting ownership', () => {
  460. const malformed = {
  461. type: 'user/message',
  462. surfaceOp: 'append',
  463. seq: 0,
  464. time: 1,
  465. data: {
  466. id: 'wrong-role',
  467. role: 'assistant',
  468. content: [],
  469. source: { kind: 'user' },
  470. },
  471. } as unknown as SessionEvent
  472. expect(() => adoptSessionEvent(malformed)).toThrow('message must have role "user"')
  473. })
  474. it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
  475. const valid = {
  476. type: 'request/header',
  477. seq: SessionSeq(0),
  478. time: 1,
  479. data: {
  480. header: {
  481. config: {
  482. provider: 'mock',
  483. model: 'model',
  484. reasoningEffort: ReasoningEffortId('adapter-owned'),
  485. },
  486. },
  487. reason: 'initial',
  488. },
  489. } as const
  490. expect(Session.create(SessionId('reasoning-effort'), [valid]).snapshotEvents()[0])
  491. .toEqual(valid)
  492. for (const reasoningEffort of ['', 1]) {
  493. const invalid = structuredClone(valid) as unknown as SessionEvent
  494. if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
  495. const config = invalid.data.header.config as unknown as Record<string, unknown>
  496. config.reasoningEffort = reasoningEffort
  497. expect(() => Session.create(SessionId('invalid-reasoning-effort'), [invalid]))
  498. .toThrow('seed request/header at index 0 has an invalid reasoningEffort')
  499. }
  500. })
  501. it('round-trips adapter-default markers and rejects invalid durable values', () => {
  502. const valid = {
  503. type: 'request/header',
  504. seq: SessionSeq(0),
  505. time: 1,
  506. data: {
  507. header: {
  508. config: {
  509. provider: 'mock',
  510. model: 'model',
  511. maxTokens: 256_000,
  512. },
  513. adapterDefaults: { maxTokens: true },
  514. },
  515. reason: 'initial',
  516. },
  517. } as const
  518. expect(Session.create(SessionId('adapter-defaults'), [valid]).snapshotEvents()[0]).toEqual(valid)
  519. for (const adapterDefaults of [
  520. null,
  521. [],
  522. { unknown: true },
  523. { maxTokens: false },
  524. { reasoningEffort: true },
  525. ]) {
  526. const invalid = structuredClone(valid) as unknown as SessionEvent
  527. if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
  528. invalid.data.header.adapterDefaults = adapterDefaults as never
  529. expect(() => Session.create(SessionId('invalid-adapter-defaults'), [invalid]))
  530. .toThrow('seed request/header at index 0 has invalid adapterDefaults')
  531. }
  532. })
  533. it('isolates the log from mutation through a derived message (append-only contract)', () => {
  534. const session = Session.create(SessionId('s4'))
  535. session.append('user/message', createUserMessage({
  536. content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
  537. }), { surfaceOp: 'append' })
  538. session.append('tool/result', {
  539. turn: 1, step: 1,
  540. message: createToolResultMessage({
  541. callId: ToolCallId('c1'),
  542. content: [{ type: 'text', text: 'tool out' }],
  543. isError: false,
  544. }),
  545. }, { surfaceOp: 'append' })
  546. const before = structuredClone(session.snapshotEvents())
  547. // A misbehaving consumer tries to mutate the messages it was handed.
  548. const messages = session.deriveMessages()
  549. const userBlock = messages[0]!.content[0]!
  550. expect(() => { if (userBlock.type === 'text') userBlock.text = 'HACKED' }).toThrow(TypeError)
  551. const toolBlock = messages[1]!.content[0]!
  552. expect(() => {
  553. if (toolBlock.type === 'tool-result') toolBlock.content.push({ type: 'text', text: 'injected' })
  554. }).toThrow(TypeError)
  555. expect(() => { messages[0]!.content.push({ type: 'text', text: 'extra' }) }).toThrow(TypeError)
  556. // The returned ARRAY is the caller's own snapshot, though — reordering it
  557. // is the caller's business and never reaches the cache or the log.
  558. messages.reverse()
  559. // The log is unchanged: deep-equal to the snapshot taken before mutation.
  560. expect(session.snapshotEvents()).toEqual(before)
  561. // And a fresh derivation still reflects the original content and order.
  562. expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }])
  563. })
  564. it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
  565. const session = Session.create(SessionId('s5'))
  566. const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' })
  567. expect(bad(1n)).toThrow(/non-JSON-serializable/)
  568. expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
  569. expect(bad(Symbol('s'))).toThrow(/non-JSON-serializable/)
  570. expect(bad(new Map())).toThrow(/non-JSON-serializable/)
  571. expect(bad(undefined)).toThrow(/non-JSON-serializable/)
  572. expect(bad(Infinity)).toThrow(/non-JSON-serializable/)
  573. // A sparse array: `every` skips the hole but JSON.stringify writes it null.
  574. // Build the hole without a sparse literal or `delete` (both linted).
  575. const sparse: unknown[] = Array(3)
  576. sparse[0] = 1
  577. sparse[2] = 3 // index 1 stays a hole
  578. expect(bad(sparse)).toThrow(/non-JSON-serializable/)
  579. // A DENSE array carrying a non-serializable element is rejected too.
  580. expect(bad([1, 2n, 3])).toThrow(/non-JSON-serializable/)
  581. // A nested non-serializable value (inside a plain object) is rejected.
  582. expect(bad({ nested: { deep: () => 0 } })).toThrow(/non-JSON-serializable/)
  583. // A circular reference is rejected (the seen-set guard, not a stack blow-up).
  584. const cyclic: Record<string, unknown> = { a: 1 }
  585. cyclic['self'] = cyclic
  586. expect(bad(cyclic)).toThrow(/non-JSON-serializable/)
  587. // The rejected appends never entered the log.
  588. expect(session.snapshotEvents()).toHaveLength(0)
  589. })
  590. it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
  591. const session = Session.create(SessionId('s5b'))
  592. session.append('turn/start', { turn: 1 })
  593. // A widened SessionEventType bypasses the overload's conditional requirement,
  594. // so the runtime guard must still reject the missing surface marker.
  595. const widenedType = 'user/message' as SessionEventType
  596. expect(() => session.append(widenedType, createUserMessage({
  597. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  598. })))
  599. .toThrow(/surface-eligible and requires a surfaceOp marker/)
  600. // The rejected append never entered the log (only turn/start is present).
  601. expect(session.snapshotEvents()).toHaveLength(1)
  602. })
  603. it('accepts dense arrays and nested plain objects', () => {
  604. const session = Session.create(SessionId('s6'))
  605. 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()
  606. expect(session.snapshotEvents()).toHaveLength(1)
  607. })
  608. it('validates seed events: rejects a non-JSON-serializable seed', () => {
  609. // A replay/fork seed must satisfy the SAME invariant as Session.append, or
  610. // it builds a live log no backend can persist.
  611. const badSeed = [
  612. { type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
  613. ] as unknown as SessionEvent[]
  614. expect(() => Session.create(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
  615. })
  616. it('validates seed events: rejects a non-contiguous seq', () => {
  617. const gapSeed = [
  618. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
  619. { type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
  620. ] as SessionEvent[]
  621. expect(() => Session.create(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
  622. })
  623. it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
  624. // A surface-eligible event (user/message) with no surfaceOp would load fine
  625. // but vanish from deriveMessages() (the surface is the sole derivation path),
  626. // so a resume/fork would silently lose history. append() forbids this at
  627. // compile time; a raw seed must be rejected at runtime to match.
  628. const markerlessSeed = [
  629. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
  630. { type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
  631. content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
  632. }) },
  633. { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
  634. ] as SessionEvent[]
  635. expect(() => Session.create(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
  636. })
  637. it('accepts a well-formed contiguous serializable seed', () => {
  638. const goodSeed = [
  639. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
  640. { type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
  641. content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
  642. }), surfaceOp: 'append' as const },
  643. { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
  644. ] as SessionEvent[]
  645. const session = Session.create(SessionId('seed-ok'), goodSeed)
  646. expect(session.snapshotEvents().slice(0, 3)).toEqual(goodSeed)
  647. expect(session.firstLiveSeq).toBe(3)
  648. })
  649. it('reads each seed array entry once so validation and storage use the same event', () => {
  650. const accepted = {
  651. type: 'turn/start' as const,
  652. seq: 0,
  653. time: 1,
  654. data: { turn: 1 },
  655. }
  656. const drifted = { ...accepted, seq: 99, data: { invalid: 1n } }
  657. let reads = 0
  658. const seed = new Array<SessionEvent>(1)
  659. Object.defineProperty(seed, 0, {
  660. enumerable: true,
  661. get: () => {
  662. reads += 1
  663. return reads === 1 ? accepted : drifted
  664. },
  665. })
  666. const session = Session.create(SessionId('seed-entry-snapshot'), seed)
  667. expect(reads).toBe(1)
  668. expect(session.snapshotEvents().slice(0, 1)).toEqual([accepted])
  669. })
  670. it('reads a nested seed-data getter once and stores its first JSON value', () => {
  671. let reads = 0
  672. const data = Object.defineProperty({}, 'value', {
  673. enumerable: true,
  674. get: () => {
  675. reads += 1
  676. return reads === 1 ? 'accepted' : 1n
  677. },
  678. })
  679. const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
  680. const session = Session.create(SessionId('seed-nested-drift'), seed)
  681. expect(reads).toBe(1)
  682. expect(session.snapshotEvents()[0]!.data).toEqual({ value: 'accepted' })
  683. })
  684. it('rejects non-JSON surface metadata in a seed event', () => {
  685. const seed = [{
  686. type: 'user/message',
  687. seq: 0,
  688. time: 1,
  689. data: createUserMessage({
  690. content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
  691. }),
  692. surfaceOp: { op: 'replace', startSeq: 1n, endSeq: 2 },
  693. }] as unknown as SessionEvent[]
  694. expect(() => Session.create(SessionId('seed-bad-metadata'), seed))
  695. .toThrow(/losslessly JSON-serializable/)
  696. })
  697. it('rejects exotic seed metadata before cloning can erase its prototype', () => {
  698. class ReplaceOp {
  699. readonly op = 'replace' as const
  700. readonly startSeq = 0
  701. readonly endSeq = 0
  702. }
  703. const seed = [{
  704. type: 'user/message',
  705. seq: 0,
  706. time: 1,
  707. data: createUserMessage({
  708. content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
  709. }),
  710. surfaceOp: new ReplaceOp(),
  711. }] as unknown as SessionEvent[]
  712. expect(() => Session.create(SessionId('seed-exotic-metadata'), seed))
  713. .toThrow(/losslessly JSON-serializable/)
  714. })
  715. it('rejects an exotic seed event shell before spreading erases its prototype', () => {
  716. class SeedEvent {
  717. readonly type = 'turn/start' as const
  718. readonly seq = SessionSeq(0)
  719. readonly time = 1
  720. readonly data = { turn: 1 }
  721. }
  722. const seed: SessionEvent[] = [new SeedEvent()]
  723. expect(() => Session.create(SessionId('seed-exotic-shell'), seed))
  724. .toThrow(/not losslessly JSON-serializable/)
  725. })
  726. it('accepts a null-prototype seed event shell as a plain JSON record', () => {
  727. const event = Object.assign(Object.create(null) as Record<string, unknown>, {
  728. type: 'turn/start' as const,
  729. seq: 0,
  730. time: 1,
  731. data: { turn: 1 },
  732. }) as unknown as SessionEvent
  733. const session = Session.create(SessionId('seed-null-prototype'), [event])
  734. expect(session.snapshotEvents().slice(0, 1)).toEqual([{ ...event }])
  735. })
  736. it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
  737. let reads = 0
  738. const surfaceOp = Object.defineProperty({ op: 'replace', endSeq: 0 }, 'startSeq', {
  739. enumerable: true,
  740. get: () => {
  741. reads += 1
  742. return reads === 1 ? 0 : 1n
  743. },
  744. })
  745. const seed = [{
  746. type: 'user/message',
  747. seq: 0,
  748. time: 1,
  749. data: createUserMessage({
  750. content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
  751. }),
  752. surfaceOp: 'append',
  753. }, {
  754. type: 'user/message',
  755. seq: 1,
  756. time: 2,
  757. data: createUserMessage({
  758. content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
  759. }),
  760. surfaceOp,
  761. sourceEventSeqs: [0],
  762. }] as unknown as SessionEvent[]
  763. const session = Session.create(SessionId('seed-unstable-metadata'), seed)
  764. const event = session.snapshotEvents()[1]!
  765. if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
  766. expect(reads).toBe(1)
  767. expect(event.surfaceOp).toEqual({ op: 'replace', startSeq: 0, endSeq: 0 })
  768. })
  769. it.each([
  770. ['an Error', new Error('validator failed'), 'validator failed'],
  771. ['a non-Error value', 'validator failed', 'invalid surface metadata'],
  772. ] as const)('adds seed context when surface validation throws %s', (_name, failure, expected) => {
  773. const originalHasOwn = Object.hasOwn
  774. const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => {
  775. if ((object as Record<string, unknown>)['op'] === 'replace') throw failure
  776. return originalHasOwn(object, property)
  777. })
  778. const seed = [{
  779. type: 'user/message',
  780. seq: 0,
  781. time: 1,
  782. data: createUserMessage({
  783. content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
  784. }),
  785. surfaceOp: 'append',
  786. }, {
  787. type: 'user/message',
  788. seq: 1,
  789. time: 2,
  790. data: createUserMessage({
  791. content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
  792. }),
  793. surfaceOp: { op: 'replace', startSeq: 0, endSeq: 0 },
  794. sourceEventSeqs: [0],
  795. }] as unknown as SessionEvent[]
  796. try {
  797. expect(() => Session.create(SessionId('seed-non-error-metadata-failure'), seed))
  798. .toThrow(`invalid seed event at index 1: ${expected}`)
  799. } finally {
  800. hasOwn.mockRestore()
  801. }
  802. })
  803. it('snapshots the seed: mutating the original after construction does not affect session.snapshotEvents()', () => {
  804. const seed = [
  805. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
  806. { type: 'user/message' as const, seq: 1, time: 2, data: {
  807. id: MessageId('seed-input'),
  808. role: 'user' as const,
  809. content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const },
  810. }, surfaceOp: 'append' as const },
  811. { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
  812. ] as SessionEvent[]
  813. const session = Session.create(SessionId('seed-snapshot'), seed)
  814. // Mutate the ORIGINAL seed objects after construction: a shared reference
  815. // would let this rewrite the forked log (or reintroduce non-serializable
  816. // data past validation). The snapshot must shield session.snapshotEvents().
  817. const um = seed[1]!
  818. ;(um.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
  819. ;(um.data as Record<string, unknown>)['injected'] = 1n // would have failed validation
  820. const logged = session.snapshotEvents()[1]!
  821. expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
  822. expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
  823. })
  824. it('snapshots append data: mutating the passed object after append does not affect session.snapshotEvents()', () => {
  825. const session = Session.create(SessionId('append-snapshot'))
  826. const data = {
  827. id: MessageId('append-input'),
  828. role: 'user' as const,
  829. content: [{ type: 'text' as const, text: 'original' }],
  830. source: { kind: 'user' as const },
  831. }
  832. const event = session.append('user/message', data, { surfaceOp: 'append' })
  833. // Mutate the caller's object after append returns. A shared reference would
  834. // make session.snapshotEvents() diverge from the value that passed validation.
  835. data.content[0]!.text = 'HACKED'
  836. ;(data as Record<string, unknown>)['injected'] = 1n
  837. const logged = session.snapshotEvents()[0]!
  838. expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
  839. expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
  840. // The returned event carries the same snapshot, not the caller's input.
  841. expect((event.data.content[0] as { text: string }).text).toBe('original')
  842. })
  843. it('reads a nested append-data getter once and stores its first JSON value', () => {
  844. const session = Session.create(SessionId('append-nested-drift'))
  845. let reads = 0
  846. const data = Object.defineProperty({}, 'value', {
  847. enumerable: true,
  848. get: () => {
  849. reads += 1
  850. return reads === 1 ? 'accepted' : 1n
  851. },
  852. })
  853. const event = session.append('request/context', data as never)
  854. expect(reads).toBe(1)
  855. expect(event.data).toEqual({ value: 'accepted' })
  856. expect(session.snapshotEvents()).toEqual([event])
  857. })
  858. it('rejects non-JSON surface metadata before appending the event', () => {
  859. const session = Session.create(SessionId('append-bad-metadata'))
  860. expect(() => session.append(
  861. 'user/message',
  862. createUserMessage({
  863. content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
  864. }),
  865. { surfaceOp: { op: 'replace', startSeq: 1n, endSeq: 2 } } as never,
  866. )).toThrow(/non-JSON-serializable surface metadata/)
  867. expect(session.snapshotEvents()).toEqual([])
  868. })
  869. it('rejects exotic surface metadata before cloning can erase its prototype', () => {
  870. class ReplaceOp {
  871. readonly op = 'replace' as const
  872. readonly startSeq = SessionSeq(0)
  873. readonly endSeq = SessionSeq(0)
  874. }
  875. const session = Session.create(SessionId('append-exotic-metadata'))
  876. expect(() => session.append(
  877. 'user/message',
  878. createUserMessage({
  879. content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
  880. }),
  881. { surfaceOp: new ReplaceOp() },
  882. )).toThrow(/non-JSON-serializable surface metadata/)
  883. expect(session.snapshotEvents()).toEqual([])
  884. })
  885. it('reads a nested append-metadata getter once and stores its first JSON value', () => {
  886. const session = Session.create(SessionId('append-unstable-metadata'))
  887. const source = session.append(
  888. 'user/message',
  889. createUserMessage({
  890. content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
  891. }),
  892. { surfaceOp: 'append' },
  893. )
  894. let reads = 0
  895. const surfaceOp = Object.defineProperty({ op: 'replace', endSeq: 0 }, 'startSeq', {
  896. enumerable: true,
  897. get: () => {
  898. reads += 1
  899. return reads === 1 ? 0 : 1n
  900. },
  901. })
  902. const event = session.append(
  903. 'user/message',
  904. createUserMessage({
  905. content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
  906. }),
  907. { surfaceOp, sourceEventSeqs: [0] } as never,
  908. )
  909. expect(reads).toBe(1)
  910. expect(event.surfaceOp).toEqual({ op: 'replace', startSeq: 0, endSeq: 0 })
  911. expect(session.snapshotEvents()).toEqual([source, event])
  912. })
  913. it('rejects invalid plain surface metadata shapes at append', () => {
  914. const session = Session.create(SessionId('append-invalid-surface-shape'))
  915. const appendRaw = session.append.bind(session) as unknown as (
  916. type: SessionEventType,
  917. data: unknown,
  918. opts?: unknown,
  919. ) => SessionEvent
  920. const data = { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }
  921. expect(() => appendRaw('user/message', data, { surfaceOp: 'invalid' }))
  922. .toThrow(/invalid surfaceOp/)
  923. expect(() => appendRaw('user/message', data, {
  924. surfaceOp: { op: 'replace', startSeq: -1, endSeq: 0 },
  925. })).toThrow(/invalid replace surfaceOp/)
  926. expect(() => appendRaw('user/message', data, {
  927. surfaceOp: 'append',
  928. sourceEventSeqs: [0, -1],
  929. })).toThrow(/non-negative safe integers/)
  930. expect(session.snapshotEvents()).toEqual([])
  931. })
  932. it('rejects surface metadata on non-surface append and seed events', () => {
  933. const session = Session.create(SessionId('non-surface-metadata'))
  934. const appendRaw = session.append.bind(session) as unknown as (
  935. type: SessionEventType,
  936. data: unknown,
  937. opts?: unknown,
  938. ) => SessionEvent
  939. expect(() => appendRaw(
  940. 'turn/start',
  941. { turn: 1 },
  942. { surfaceOp: 'append' },
  943. )).toThrow(/not surface-eligible and cannot carry surfaceOp/)
  944. expect(() => Session.create(SessionId('non-surface-metadata-seed'), [{
  945. type: 'turn/start',
  946. seq: SessionSeq(0),
  947. time: 1,
  948. data: { turn: 1 },
  949. surfaceOp: 'append',
  950. } as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/)
  951. expect(session.snapshotEvents()).toEqual([])
  952. })
  953. it('deep-freezes seeded and appended event snapshots', () => {
  954. const seeded = Session.create(SessionId('seed-frozen'), [{
  955. type: 'turn/start',
  956. seq: SessionSeq(0),
  957. time: 1,
  958. data: { turn: 1 },
  959. }])
  960. const seededEvent = seeded.snapshotEvents()[0]!
  961. if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
  962. expect(Object.isFrozen(seededEvent)).toBe(true)
  963. expect(Object.isFrozen(seededEvent.data)).toBe(true)
  964. expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
  965. const appended = Session.create(SessionId('append-frozen'))
  966. const appendedEvent = appended.append('user/message', createUserMessage({
  967. content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
  968. }), { surfaceOp: 'append' })
  969. expect(Object.isFrozen(appendedEvent)).toBe(true)
  970. expect(Object.isFrozen(appendedEvent.data)).toBe(true)
  971. expect(Object.isFrozen(appendedEvent.data.content)).toBe(true)
  972. expect(Object.isFrozen(appendedEvent.data.content[0])).toBe(true)
  973. expect(() => { (appendedEvent.data.content[0] as { text: string }).text = 'mutated' }).toThrow(TypeError)
  974. })
  975. it('returns cached frozen event-array snapshots that do not grow after append', () => {
  976. const session = Session.create(SessionId('events-snapshot'))
  977. session.append('turn/start', { turn: 1 })
  978. const before = session.snapshotEvents()
  979. const beforeEvent = before[0]!
  980. if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
  981. expect(session.snapshotEvents()).toBe(before)
  982. expect(Object.isFrozen(before)).toBe(true)
  983. expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError)
  984. expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError)
  985. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  986. const after = session.snapshotEvents()
  987. expect(before).toHaveLength(1)
  988. expect(after).toHaveLength(2)
  989. expect(after).not.toBe(before)
  990. expect(session.snapshotEvents()).toBe(after)
  991. })
  992. it('reads one event without materializing an array and snapshots half-open ranges', () => {
  993. const session = Session.create(SessionId('event-reads'))
  994. const start = session.append('turn/start', { turn: 1 })
  995. const end = session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  996. expect(session.eventAt(SessionSeq(0))).toBe(start)
  997. expect(session.eventAt(SessionSeq(1))).toBe(end)
  998. expect(session.eventAt(SessionSeq(2))).toBeUndefined()
  999. const range = session.snapshotEvents(SessionLogOffset(1), SessionLogOffset(2))
  1000. expect(range).toEqual([end])
  1001. expect(Object.isFrozen(range)).toBe(true)
  1002. session.append('turn/start', { turn: 2 })
  1003. expect(range).toEqual([end])
  1004. })
  1005. it('detaches and freezes an explicitly supplied session header', () => {
  1006. const input = {
  1007. version: SESSION_FORMAT_VERSION,
  1008. id: SessionId('header-owned'),
  1009. createdAt: 123,
  1010. cwd: '/accepted',
  1011. parentSession: SessionId('parent'),
  1012. isSeeded: true,
  1013. } satisfies SessionHeader
  1014. const session = Session.create(SessionId('header-owned'), [], input, SessionLogOffset(0))
  1015. input.cwd = '/caller-mutated'
  1016. expect(session.header).toEqual({
  1017. version: SESSION_FORMAT_VERSION,
  1018. id: 'header-owned',
  1019. createdAt: 123,
  1020. cwd: '/accepted',
  1021. parentSession: 'parent',
  1022. isSeeded: true,
  1023. })
  1024. expect(session.header).not.toBe(input)
  1025. expect(Object.isFrozen(session.header)).toBe(true)
  1026. expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false)
  1027. expect(session.id).toBe('header-owned')
  1028. expect(session.header.cwd).toBe('/accepted')
  1029. })
  1030. it('rejects an exotic, non-JSON, or mismatched supplied header', () => {
  1031. class ExoticHeader implements SessionHeader {
  1032. readonly version = SESSION_FORMAT_VERSION
  1033. readonly id = SessionId('header-invalid')
  1034. readonly createdAt = 123
  1035. readonly isSeeded = false
  1036. }
  1037. expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader()))
  1038. .toThrow(/not losslessly JSON-serializable/)
  1039. expect(() => Session.fromRestore(
  1040. SessionId('header-invalid'),
  1041. [],
  1042. new ExoticHeader(),
  1043. SessionLogOffset(0),
  1044. 'detached',
  1045. ))
  1046. .toThrow(/not a plain JSON record/)
  1047. for (const header of [null, 1, []]) {
  1048. expect(() => Session.fromRestore(
  1049. SessionId('header-invalid'),
  1050. [],
  1051. header as unknown as SessionHeader,
  1052. SessionLogOffset(0),
  1053. 'detached',
  1054. )).toThrow(/not a plain JSON record/)
  1055. }
  1056. expect(() => Session.create(SessionId('header-invalid'), undefined, {
  1057. version: SESSION_FORMAT_VERSION,
  1058. id: SessionId('header-invalid'),
  1059. createdAt: 123,
  1060. isSeeded: false,
  1061. parentSession: 1n,
  1062. } as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
  1063. expect(() => Session.create(SessionId('header-invalid'), undefined, {
  1064. version: SESSION_FORMAT_VERSION,
  1065. id: SessionId('other'),
  1066. createdAt: 123,
  1067. isSeeded: false,
  1068. })).toThrow(/does not match session id/)
  1069. })
  1070. it('rejects invalid scalar fields in an explicitly supplied header', () => {
  1071. const base = {
  1072. version: SESSION_FORMAT_VERSION,
  1073. id: SessionId('header-shape'),
  1074. createdAt: 123,
  1075. isSeeded: false,
  1076. }
  1077. const cases: Array<{ header: unknown; error: RegExp }> = [
  1078. { header: 1, error: /not a plain JSON record/ },
  1079. { header: null, error: /not a plain JSON record/ },
  1080. { header: { ...base, version: SESSION_FORMAT_VERSION + 1 }, error: /header version/ },
  1081. { header: { ...base, createdAt: '123' }, error: /createdAt must be a non-negative safe integer/ },
  1082. { header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
  1083. { header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
  1084. { header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
  1085. { header: { ...base, isSeeded: 'yes' }, error: /isSeeded must be a boolean/ },
  1086. { header: { ...base, seedLength: 1 }, error: /invalid field "seedLength"/ },
  1087. ]
  1088. for (const { header, error } of cases) {
  1089. expect(() => Session.create(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
  1090. }
  1091. })
  1092. it('retains opaque logical header metadata except the physical v0 seed field', () => {
  1093. const id = SessionId('header-extension')
  1094. const session = Session.create(id, undefined, {
  1095. version: SESSION_FORMAT_VERSION,
  1096. id,
  1097. createdAt: 123,
  1098. isSeeded: false,
  1099. extension: { value: 'kept' },
  1100. } as SessionHeader)
  1101. expect((session.header as SessionHeader & { extension: { value: string } }).extension)
  1102. .toEqual({ value: 'kept' })
  1103. })
  1104. it('rejects seed records with invalid fixed-envelope fields', () => {
  1105. const base = {
  1106. type: 'turn/start',
  1107. seq: 0,
  1108. time: 1,
  1109. data: { turn: 1 },
  1110. }
  1111. const cases: unknown[] = [
  1112. { ...base, extra: true },
  1113. { ...base, type: 1 },
  1114. { ...base, seq: '0' },
  1115. { ...base, seq: 0.5 },
  1116. { ...base, seq: -1 },
  1117. { ...base, time: '1' },
  1118. { ...base, time: 0.5 },
  1119. { type: base.type, seq: base.seq, time: base.time },
  1120. { ...base, ignorable: false },
  1121. { ...base, ignorable: 'yes' },
  1122. ]
  1123. for (const [index, event] of cases.entries()) {
  1124. expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
  1125. .toThrow(/invalid event envelope/)
  1126. }
  1127. // `ignorable: true` is the one accepted marker value (unknown-type skip contract).
  1128. const marked = Session.create(SessionId('ignorable-envelope'), [
  1129. { ...base, ignorable: true } as SessionEvent,
  1130. ])
  1131. expect(marked.snapshotEvents()[0]?.ignorable).toBe(true)
  1132. })
  1133. })
  1134. describe('SessionStore', () => {
  1135. it('creates sessions, emits session/created and session/event', async () => {
  1136. const ctx = new Context()
  1137. await ctx.plugin(SessionStore)
  1138. const created: Session[] = []
  1139. const events: [Session, SessionEvent][] = []
  1140. ctx.on('session/created', session => void created.push(session))
  1141. ctx.on('session/event', (session, event) => void events.push([session, event]))
  1142. const session = ctx.sessions.create()
  1143. expect(created).toEqual([session])
  1144. // The store-owned append publication hooks are module-private. A JavaScript caller
  1145. // may create an unrelated property with the old implementation's name,
  1146. // but cannot suppress the durable event feed.
  1147. expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
  1148. session.append('turn/start', { turn: 1 })
  1149. session.append('user/message', createUserMessage({
  1150. content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
  1151. }), { surfaceOp: 'append' })
  1152. expect(events).toHaveLength(2)
  1153. expect(events[1]![0]).toBe(session)
  1154. expect(events[1]![1].type).toBe('user/message')
  1155. expect(ctx.sessions.get(session.id)).toBe(session)
  1156. expect(ctx.sessions.list()).toEqual([session])
  1157. })
  1158. it('rejects duplicate ids and supports seeding', async () => {
  1159. const ctx = new Context()
  1160. await ctx.plugin(SessionStore)
  1161. const a = ctx.sessions.create(SessionId('fixed'))
  1162. expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
  1163. a.append('turn/start', { turn: 1 })
  1164. a.append('user/message', createUserMessage({
  1165. content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
  1166. }), { surfaceOp: 'append' })
  1167. const forked = ctx.sessions.create(SessionId('fork'), { seed: a.snapshotEvents() })
  1168. expect(forked.deriveMessages()).toEqual(a.deriveMessages())
  1169. })
  1170. it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => {
  1171. // A stale prepared object must not replace the live same-id entry; its later
  1172. // detach would otherwise remove the wrong session.
  1173. const ctx = new Context()
  1174. await ctx.plugin(SessionStore)
  1175. const stale = ctx.sessions.prepare(SessionId('racy'))
  1176. const live = ctx.sessions.create(SessionId('racy'))
  1177. expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/)
  1178. // The live session is intact and still the store entry.
  1179. expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
  1180. })
  1181. it('prepare() + enter() + announce() register a session and emit session/created', async () => {
  1182. const ctx = new Context()
  1183. await ctx.plugin(SessionStore)
  1184. const created: Session[] = []
  1185. ctx.on('session/created', session => void created.push(session))
  1186. const session = ctx.sessions.prepare(SessionId('lifecycle'))
  1187. // prepare alone does NOT enter the store.
  1188. expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
  1189. const detach = ctx.sessions.enter(session)
  1190. expect(ctx.sessions.get(SessionId('lifecycle'))).toBe(session)
  1191. // enter does NOT announce.
  1192. expect(created).toEqual([])
  1193. ctx.sessions.announce(session)
  1194. expect(created).toEqual([session])
  1195. // The detach disposer removes the entry + stops notification.
  1196. detach()
  1197. detach() // idempotent: cannot disturb a later same-id lifecycle
  1198. expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
  1199. })
  1200. it('prevents simultaneous attachment of one session object to two stores', async () => {
  1201. const firstCtx = new Context()
  1202. const secondCtx = new Context()
  1203. await firstCtx.plugin(SessionStore)
  1204. await secondCtx.plugin(SessionStore)
  1205. const session = Session.create(SessionId('owned-key'))
  1206. const detachFirst = firstCtx.sessions.enter(session)
  1207. expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
  1208. expect(firstCtx.sessions.get(SessionId('owned-key'))).toBe(session)
  1209. detachFirst()
  1210. expect(firstCtx.sessions.get(SessionId('owned-key'))).toBeUndefined()
  1211. const detachSecond = secondCtx.sessions.enter(session)
  1212. expect(secondCtx.sessions.get(SessionId('owned-key'))).toBe(session)
  1213. detachSecond()
  1214. })
  1215. it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => {
  1216. const ctx = new Context()
  1217. await ctx.plugin(SessionStore)
  1218. let created = 0
  1219. let disposed = 0
  1220. let reentrantError = ''
  1221. ctx.on('session/created', (session) => {
  1222. created += 1
  1223. try {
  1224. ctx.sessions.announce(session)
  1225. } catch (error: unknown) {
  1226. reentrantError = String(error)
  1227. }
  1228. })
  1229. ctx.on('session/disposed', () => { disposed += 1 })
  1230. const session = ctx.sessions.prepare(SessionId('once'))
  1231. const detach = ctx.sessions.enter(session)
  1232. ctx.sessions.announce(session)
  1233. expect(reentrantError).toMatch(/already announced/)
  1234. expect(() => { ctx.sessions.announce(session) }).toThrow(/already announced/)
  1235. detach()
  1236. expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
  1237. })
  1238. it('defers a reentrant detach until the creation dispatch unwinds', async () => {
  1239. const ctx = new Context()
  1240. await ctx.plugin(SessionStore)
  1241. const order: string[] = []
  1242. const session = ctx.sessions.prepare(SessionId('reentrant-detach'))
  1243. const detach = ctx.sessions.enter(session)
  1244. ctx.on('session/created', (created) => {
  1245. order.push('created:first')
  1246. detach()
  1247. expect(ctx.sessions.get(created.id)).toBe(created)
  1248. })
  1249. ctx.on('session/created', (created) => {
  1250. order.push('created:second')
  1251. expect(ctx.sessions.get(created.id)).toBe(created)
  1252. })
  1253. ctx.on('session/disposed', (disposed) => {
  1254. order.push('disposed')
  1255. expect(ctx.sessions.get(disposed.id)).toBeUndefined()
  1256. })
  1257. ctx.sessions.announce(session)
  1258. expect(order).toEqual(['created:first', 'created:second', 'disposed'])
  1259. expect(ctx.sessions.get(session.id)).toBeUndefined()
  1260. detach()
  1261. })
  1262. it('rolls back create when its owner unloads from session/created', async () => {
  1263. const ctx = new Context()
  1264. await ctx.plugin(SessionStore)
  1265. let ownerCtx!: Context
  1266. const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['sessions'] }))
  1267. const id = SessionId('create-unload-race')
  1268. ctx.on('session/created', (session) => {
  1269. if (session.id === id) void owner.dispose()
  1270. })
  1271. ownerCtx.sessions.create(id)
  1272. await owner.dispose()
  1273. expect(ctx.sessions.get(id)).toBeUndefined()
  1274. })
  1275. it('synthesizes a minimal current-version header for a bare-created session', async () => {
  1276. const ctx = new Context()
  1277. await ctx.plugin(SessionStore)
  1278. const session = ctx.sessions.create(SessionId('plain'))
  1279. expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain', isSeeded: false })
  1280. expect(Number.isSafeInteger(session.header.createdAt)).toBe(true)
  1281. expect(session.header.cwd).toBeUndefined()
  1282. expect(session.header.parentSession).toBeUndefined()
  1283. })
  1284. it('attaches cwd and parentSession from meta to the header', async () => {
  1285. const ctx = new Context()
  1286. await ctx.plugin(SessionStore)
  1287. const session = ctx.sessions.create(SessionId('child'), {
  1288. meta: { cwd: '/work/project', parentSession: SessionId('parent') },
  1289. })
  1290. expect(session.header).toMatchObject({
  1291. version: SESSION_FORMAT_VERSION,
  1292. id: 'child',
  1293. cwd: '/work/project',
  1294. parentSession: 'parent',
  1295. isSeeded: false,
  1296. })
  1297. })
  1298. it('attaches subagent origin and delegationDepth from meta to the header', async () => {
  1299. const ctx = new Context()
  1300. await ctx.plugin(SessionStore)
  1301. const session = ctx.sessions.create(SessionId('delegated-child'), {
  1302. meta: { parentSession: SessionId('parent'), origin: 'subagent', delegationDepth: 2 },
  1303. })
  1304. expect(session.header).toMatchObject({
  1305. id: 'delegated-child',
  1306. parentSession: 'parent',
  1307. origin: 'subagent',
  1308. delegationDepth: 2,
  1309. })
  1310. })
  1311. it('rejects non-JSON and invalid scalar session metadata', async () => {
  1312. const ctx = new Context()
  1313. await ctx.plugin(SessionStore)
  1314. const cases: Array<{ meta: unknown; error: RegExp }> = [
  1315. { meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
  1316. { meta: { cwd: 1 }, error: /header cwd must be a string/ },
  1317. { meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
  1318. { meta: { createdAt: '123' }, error: /header createdAt must be a non-negative safe integer/ },
  1319. { meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ },
  1320. { meta: { createdAt: -1 }, error: /header createdAt must be a non-negative safe integer/ },
  1321. { meta: { createdAt: Number.MAX_SAFE_INTEGER + 1 }, error: /header createdAt must be a non-negative safe integer/ },
  1322. { meta: { isSeeded: 'yes' }, error: /isSeeded must be a boolean/ },
  1323. { meta: { origin: 'fork' }, error: /origin must be "subagent"/ },
  1324. { meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ },
  1325. { meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ },
  1326. { meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ },
  1327. { meta: { agentPreset: 1 }, error: /agentPreset must be a string/ },
  1328. ]
  1329. for (const [index, { meta, error }] of cases.entries()) {
  1330. expect(() => ctx.sessions.prepare(SessionId(`bad-meta-${index}`), {
  1331. meta: meta as NonNullable<CreateSessionOptions['meta']>,
  1332. })).toThrow(error)
  1333. }
  1334. })
  1335. it('rejects a non-absolute meta.cwd', async () => {
  1336. const ctx = new Context()
  1337. await ctx.plugin(SessionStore)
  1338. expect(() => ctx.sessions.create(SessionId('rel'), { meta: { cwd: 'relative/path' } }))
  1339. .toThrow(/cwd must be an absolute path/)
  1340. // the rejected session was not registered
  1341. expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined()
  1342. })
  1343. it('a bare Session() constructed without the store still exposes a current-version header', () => {
  1344. const session = Session.create(SessionId('bare'))
  1345. expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare', isSeeded: false })
  1346. expect(typeof session.header.createdAt).toBe('number')
  1347. })
  1348. it('detaches sessions when the creating fiber is disposed (HMR safety)', async () => {
  1349. const ctx = new Context()
  1350. await ctx.plugin(SessionStore)
  1351. let session!: Session
  1352. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  1353. session = inner.sessions.create(SessionId('scoped'))
  1354. }, { inject: ['sessions'] }))
  1355. expect(ctx.sessions.get(SessionId('scoped'))).toBe(session)
  1356. let observed = 0
  1357. ctx.on('session/event', () => void observed++)
  1358. await fiber.dispose()
  1359. expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
  1360. session.append('user/message', createUserMessage({
  1361. content: [{ type: 'text', text: 'late' }], source: { kind: 'user' },
  1362. }), { surfaceOp: 'append' })
  1363. expect(observed).toBe(0)
  1364. })
  1365. it('pairs a partial session/created announcement with disposal during rollback', async () => {
  1366. const ctx = new Context()
  1367. await ctx.plugin(SessionStore)
  1368. let threw = false
  1369. const disposed: Session[] = []
  1370. ctx.on('session/disposed', (session) => { disposed.push(session) })
  1371. ctx.on('session/created', () => {
  1372. if (!threw) { threw = true; throw new Error('boom created listener') }
  1373. })
  1374. // The throwing emit must roll the store entry back, not leak it.
  1375. expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener')
  1376. expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked
  1377. expect(disposed.map(session => session.id)).toEqual(['fixed'])
  1378. // A subsequent create of the SAME id succeeds (the already-exists check is
  1379. // not wedged) and its store-owned publication hooks are correctly wired.
  1380. const events: SessionEvent[] = []
  1381. ctx.on('session/event', (_session, event) => void events.push(event))
  1382. const session = ctx.sessions.create(SessionId('fixed'))
  1383. expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
  1384. session.append('turn/start', { turn: 1 })
  1385. session.append('user/message', createUserMessage({
  1386. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  1387. }), { surfaceOp: 'append' })
  1388. expect(events.at(-1)?.type).toBe('user/message')
  1389. })
  1390. it('contains session/event observer failures after the append commit point', async () => {
  1391. const ctx = new Context()
  1392. await ctx.plugin(SessionStore)
  1393. const warnings: string[] = []
  1394. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  1395. const session = ctx.sessions.create(SessionId('contained-event'))
  1396. const heard: SessionEvent[] = []
  1397. let committedBeforeNotify = false
  1398. ctx.on('session/event', (observedSession, event) => {
  1399. committedBeforeNotify = observedSession.snapshotEvents().at(-1) === event
  1400. throw new Error('sync event observer')
  1401. })
  1402. ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never)
  1403. ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
  1404. let appended!: SessionEvent
  1405. expect(() => {
  1406. appended = session.append('turn/start', {
  1407. turn: 1,
  1408. })
  1409. }).not.toThrow()
  1410. expect(committedBeforeNotify).toBe(true)
  1411. expect(session.snapshotEvents()).toEqual([appended])
  1412. expect(heard).toEqual([appended])
  1413. await Promise.resolve()
  1414. await Promise.resolve()
  1415. expect(warnings).toEqual([
  1416. 'session "contained-event": session/event listener threw: Error: sync event observer',
  1417. 'session "contained-event": session/event listener rejected: Error: async event observer',
  1418. ])
  1419. })
  1420. it('runs internal dispatch validation on one frozen candidate before commit and resets after a veto', async () => {
  1421. const ctx = new Context()
  1422. await ctx.plugin(SessionStore)
  1423. const session = ctx.sessions.create(SessionId('dispatch-veto'))
  1424. const validations: Array<{ event: SessionEvent; logLength: number; frozen: boolean }> = []
  1425. const observed: SessionEvent[] = []
  1426. let reject = true
  1427. ctx.on('internal/dispatch', (_mode, name, args) => {
  1428. if (name !== 'session/event') return
  1429. const [observedSession, event] = args as [Session, SessionEvent]
  1430. validations.push({
  1431. event,
  1432. logLength: observedSession.snapshotEvents().length,
  1433. frozen: Object.isFrozen(event) && Object.isFrozen(event.data),
  1434. })
  1435. if (reject) {
  1436. reject = false
  1437. throw new Error('reject first candidate')
  1438. }
  1439. })
  1440. ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
  1441. expect(() => session.append('turn/start', {
  1442. turn: 1,
  1443. })).toThrow('reject first candidate')
  1444. expect(session.snapshotEvents()).toEqual([])
  1445. expect(observed).toEqual([])
  1446. const appended = session.append('turn/start', {
  1447. turn: 1,
  1448. })
  1449. expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([
  1450. { logLength: 0, frozen: true },
  1451. { logLength: 0, frozen: true },
  1452. ])
  1453. expect(validations.map(({ event }) => event.seq)).toEqual([0, 0])
  1454. expect(validations[1]!.event).toBe(appended)
  1455. expect(session.snapshotEvents()).toEqual([appended])
  1456. expect(observed).toEqual([appended])
  1457. })
  1458. it('does not publish a surface transition rejected by internal dispatch', async () => {
  1459. const ctx = new Context()
  1460. await ctx.plugin(SessionStore)
  1461. const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
  1462. session.append('turn/start', { turn: 1 })
  1463. session.append('step/start', { turn: 1, step: 1 })
  1464. session.append('user/message', createUserMessage({
  1465. content: [{ type: 'text', text: 'source' }],
  1466. source: { kind: 'user' },
  1467. }), { surfaceOp: 'append' })
  1468. const surface = session.surface
  1469. let reject = true
  1470. ctx.on('internal/dispatch', (_mode, name) => {
  1471. if (name === 'session/event' && reject) {
  1472. reject = false
  1473. throw new Error('reject surface candidate')
  1474. }
  1475. })
  1476. expect(() => session.append('user/message', createUserMessage({
  1477. content: [{ type: 'text', text: 'replacement' }],
  1478. source: { kind: 'plugin', plugin: 'test' },
  1479. }), {
  1480. surfaceOp: { op: 'replace', startSeq: SessionSeq(2), endSeq: SessionSeq(2) },
  1481. sourceEventSeqs: [SessionSeq(2)],
  1482. })).toThrow('reject surface candidate')
  1483. expect(session.snapshotEvents()).toHaveLength(3)
  1484. expect(surface.nodes).toEqual([2])
  1485. expect(surface.replaceGeneration).toBe(0)
  1486. session.append('user/message', createUserMessage({
  1487. content: [{ type: 'text', text: 'next' }],
  1488. source: { kind: 'user' },
  1489. }), { surfaceOp: 'append' })
  1490. expect(surface.nodes).toEqual([2, 3])
  1491. expect(surface.replaceGeneration).toBe(0)
  1492. })
  1493. it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => {
  1494. const ctx = new Context()
  1495. await ctx.plugin(SessionStore)
  1496. const session = ctx.sessions.create(SessionId('dispatch-check'))
  1497. const observed: SessionEvent[] = []
  1498. ctx.on('internal/dispatch', (_mode, name) => {
  1499. if (name === 'session/event') throw new Error('dispatch instrumentation rejected the carrier')
  1500. })
  1501. ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
  1502. expect(() => session.append('turn/start', {
  1503. turn: 1,
  1504. })).toThrow('dispatch instrumentation rejected the carrier')
  1505. expect(session.snapshotEvents()).toEqual([])
  1506. expect(observed).toEqual([])
  1507. })
  1508. it('contains a reentrant observer append without reordering later observers', async () => {
  1509. const ctx = new Context()
  1510. await ctx.plugin(SessionStore)
  1511. const warnings: string[] = []
  1512. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  1513. const session = ctx.sessions.create(SessionId('reentrant-observer'))
  1514. const heard: SessionEvent[] = []
  1515. ctx.on('session/event', (observedSession) => {
  1516. observedSession.append('request/context', { provider: 'mock', model: 'mock' })
  1517. })
  1518. ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
  1519. const appended = session.append('turn/start', {
  1520. turn: 1,
  1521. })
  1522. expect(session.snapshotEvents()).toEqual([appended])
  1523. expect(heard).toEqual([appended])
  1524. expect(warnings).toEqual([
  1525. 'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being published',
  1526. ])
  1527. })
  1528. it('defers detach through dispatch resolution, commit, and observer publication', async () => {
  1529. const ctx = new Context()
  1530. await ctx.plugin(SessionStore)
  1531. const order: string[] = []
  1532. const session = ctx.sessions.prepare(SessionId('detach-during-append'))
  1533. const detach = ctx.sessions.enter(session)
  1534. ctx.on('internal/dispatch', (_mode, name, args) => {
  1535. if (name !== 'session/event') return
  1536. const session = args[0] as Session
  1537. order.push(`resolve:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
  1538. detach()
  1539. })
  1540. ctx.on('session/event', (session) => {
  1541. order.push(`observe:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
  1542. })
  1543. ctx.on('session/disposed', (session) => {
  1544. order.push(`dispose:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
  1545. })
  1546. ctx.sessions.announce(session)
  1547. const appended = session.append('turn/start', {
  1548. turn: 1,
  1549. })
  1550. expect(session.snapshotEvents()).toEqual([appended])
  1551. expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached'])
  1552. expect(ctx.sessions.get(session.id)).toBeUndefined()
  1553. })
  1554. it('observes async session/created rejection without rolling back or starving peers', async () => {
  1555. const ctx = new Context()
  1556. await ctx.plugin(SessionStore)
  1557. const warnings: string[] = []
  1558. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  1559. const heard: string[] = []
  1560. ctx.on('session/created', () => Promise.reject(new Error('late creation failure')) as never)
  1561. ctx.on('session/created', (session) => { heard.push(session.id) })
  1562. const session = ctx.sessions.create(SessionId('async-created'))
  1563. await Promise.resolve()
  1564. await Promise.resolve()
  1565. expect(ctx.sessions.get(session.id)).toBe(session)
  1566. expect(heard).toEqual(['async-created'])
  1567. expect(warnings).toEqual([
  1568. 'session "async-created": session/created listener rejected: Error: late creation failure',
  1569. ])
  1570. })
  1571. it('contains synchronous and async session/disposed listener failures per observer', async () => {
  1572. const ctx = new Context()
  1573. await ctx.plugin(SessionStore)
  1574. const warnings: string[] = []
  1575. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  1576. const heard: string[] = []
  1577. ctx.on('session/disposed', () => { throw new Error('sync disposed') })
  1578. ctx.on('session/disposed', () => Promise.reject(new Error('async disposed')) as never)
  1579. ctx.on('session/disposed', (session) => { heard.push(session.id) })
  1580. const unannounced = ctx.sessions.prepare(SessionId('never-announced'))
  1581. const detachUnannounced = ctx.sessions.enter(unannounced)
  1582. detachUnannounced()
  1583. expect(heard).toEqual([])
  1584. const announced = ctx.sessions.prepare(SessionId('contained-disposal'))
  1585. const detach = ctx.sessions.enter(announced)
  1586. ctx.sessions.announce(announced)
  1587. expect(() => { detach() }).not.toThrow()
  1588. await Promise.resolve()
  1589. await Promise.resolve()
  1590. expect(heard).toEqual(['contained-disposal'])
  1591. expect(warnings).toEqual([
  1592. 'session "contained-disposal": session/disposed listener threw: Error: sync disposed',
  1593. 'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
  1594. ])
  1595. })
  1596. it('contains internal dispatch failure after session detachment', async () => {
  1597. const ctx = new Context()
  1598. await ctx.plugin(SessionStore)
  1599. const warnings: string[] = []
  1600. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  1601. const heard: Session[] = []
  1602. ctx.on('internal/dispatch', (_mode, name) => {
  1603. if (name === 'session/disposed') throw new Error('disposed dispatch instrumentation')
  1604. })
  1605. ctx.on('session/disposed', (session) => { heard.push(session) })
  1606. const session = ctx.sessions.prepare(SessionId('disposed-dispatch'))
  1607. const detach = ctx.sessions.enter(session)
  1608. ctx.sessions.announce(session)
  1609. expect(() => { detach() }).not.toThrow()
  1610. expect(ctx.sessions.get(session.id)).toBeUndefined()
  1611. expect(heard).toEqual([])
  1612. expect(warnings).toEqual([
  1613. 'session "disposed-dispatch": session/disposed dispatch threw: Error: disposed dispatch instrumentation',
  1614. ])
  1615. })
  1616. it('does not let internal dispatch replace the disposed callback tuple', async () => {
  1617. const ctx = new Context()
  1618. await ctx.plugin(SessionStore)
  1619. const replacement = Session.create(SessionId('replacement-disposed'))
  1620. const heard: Session[] = []
  1621. ctx.on('internal/dispatch', (_mode, name, args) => {
  1622. if (name === 'session/disposed') args[0] = replacement
  1623. })
  1624. ctx.on('session/disposed', (session) => { heard.push(session) })
  1625. const session = ctx.sessions.prepare(SessionId('fixed-disposed-tuple'))
  1626. const detach = ctx.sessions.enter(session)
  1627. ctx.sessions.announce(session)
  1628. detach()
  1629. expect(heard).toEqual([session])
  1630. })
  1631. })