surface.spec.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965
  1. import { describe, expect, it } from 'vitest'
  2. import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
  3. import {
  4. Session,
  5. SessionId,
  6. SessionLogOffset,
  7. SessionSeq,
  8. foldSurface,
  9. isAppendSurfaceEvent,
  10. isReplacementSurfaceEvent,
  11. isSurfaceEligibleType,
  12. isSurfaceEvent,
  13. } from '@deepseek-ai/dsh-session'
  14. import { SurfaceManager } from '@deepseek-ai/dsh-session/surface'
  15. import {
  16. createMessage,
  17. createSystemMessage,
  18. createToolResultMessage,
  19. createUserMessage,
  20. freezeMessage,
  21. ToolCallId,
  22. MessageId,
  23. } from '@deepseek-ai/dsh-llm'
  24. type TestSurfaceOp = 'append' | { op: 'replace'; startSeq: number; endSeq: number }
  25. function surfaceOp(value: TestSurfaceOp): SurfaceEvent['surfaceOp'] {
  26. return value === 'append'
  27. ? value
  28. : { op: 'replace', startSeq: SessionSeq(value.startSeq), endSeq: SessionSeq(value.endSeq) }
  29. }
  30. function replacementMessage(text: string) {
  31. return createUserMessage({
  32. content: [{ type: 'text', text }],
  33. source: { kind: 'plugin', plugin: 'test' },
  34. })
  35. }
  36. function sourceSeqs(...values: number[]) {
  37. return values.map(SessionSeq)
  38. }
  39. /** Build a minimal session with turn boundaries and a single user message. */
  40. function surfaceSession(): Session {
  41. const s = Session.create(SessionId('ss'))
  42. s.append('turn/start', { turn: 1 })
  43. s.append('user/message', createUserMessage({
  44. content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
  45. }), { surfaceOp: 'append' })
  46. s.append('assistant/message', {
  47. stream: [],
  48. turn: 1, step: 1,
  49. message: createMessage({
  50. role: 'assistant',
  51. content: [{ type: 'text', text: 'hi' }],
  52. source: {
  53. kind: 'model',
  54. ...{ provider: 'mock', model: 'mock' },
  55. },
  56. }),
  57. }, { surfaceOp: 'append' })
  58. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  59. return s
  60. }
  61. function provenanceEvent(seq: SessionSeq, sourceEventSeqs: unknown): SessionEvent {
  62. return {
  63. type: 'user/message',
  64. seq,
  65. time: seq,
  66. data: createUserMessage({
  67. content: [], source: { kind: 'user' },
  68. }),
  69. surfaceOp: 'append',
  70. ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
  71. } as unknown as SessionEvent
  72. }
  73. function toolResultEvent(
  74. seq: SessionSeq,
  75. callId: string,
  76. op: TestSurfaceOp = 'append',
  77. sourceEventSeqs?: number[],
  78. ): SessionEvent {
  79. return {
  80. type: 'tool/result',
  81. seq,
  82. time: seq,
  83. data: {
  84. turn: 1,
  85. step: 1,
  86. message: createToolResultMessage({
  87. callId: ToolCallId(callId),
  88. content: [{ type: 'text', text: `result ${seq}` }],
  89. isError: false,
  90. }),
  91. },
  92. surfaceOp: surfaceOp(op),
  93. ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs: sourceSeqs(...sourceEventSeqs) },
  94. }
  95. }
  96. describe('foldSurface source-event references', () => {
  97. it('accepts absent or valid source-event references and complete replacement coverage', () => {
  98. const events = [
  99. provenanceEvent(SessionSeq(0), undefined),
  100. provenanceEvent(SessionSeq(1), undefined),
  101. {
  102. ...provenanceEvent(SessionSeq(2), [0, 1]),
  103. surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 1 }),
  104. },
  105. ] as SessionEvent[]
  106. expect(() => foldSurface(events)).not.toThrow()
  107. })
  108. it('rejects source-event references on a non-surface event', () => {
  109. const event = {
  110. type: 'turn/start',
  111. seq: SessionSeq(0),
  112. time: 1,
  113. data: { turn: 1 },
  114. sourceEventSeqs: sourceSeqs(0),
  115. } as unknown as SessionEvent
  116. expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/)
  117. })
  118. it('rejects obsolete source-event provenance on an assistant message', () => {
  119. const event = {
  120. type: 'assistant/message',
  121. seq: SessionSeq(0),
  122. time: 0,
  123. data: {
  124. turn: 1,
  125. step: 1,
  126. message: createMessage({
  127. role: 'assistant',
  128. content: [],
  129. source: {
  130. kind: 'model',
  131. ...{ provider: 'mock', model: 'mock' },
  132. },
  133. }),
  134. stream: [],
  135. },
  136. surfaceOp: 'append',
  137. sourceEventSeqs: [],
  138. } as unknown as SessionEvent
  139. expect(() => foldSurface([event])).toThrow(/embeds its source stream/)
  140. })
  141. it.each([
  142. ['a non-array', [{ ...provenanceEvent(SessionSeq(0), undefined), sourceEventSeqs: 'invalid' }], /must be an array/],
  143. ['an empty array', [provenanceEvent(SessionSeq(0), [])], /must not be empty/],
  144. ['duplicates', [provenanceEvent(SessionSeq(0), undefined), provenanceEvent(SessionSeq(1), [0, 0])], /must not contain duplicates/],
  145. ['a sparse array', [provenanceEvent(SessionSeq(0), Array<number>(1))], /densely contain/],
  146. ['a non-number', [{ ...provenanceEvent(SessionSeq(0), undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/],
  147. ['a fractional number', [provenanceEvent(SessionSeq(0), [0.5])], /non-negative safe integers/],
  148. ['a negative number', [provenanceEvent(SessionSeq(0), [-1])], /non-negative safe integers/],
  149. ['a self reference', [provenanceEvent(SessionSeq(0), [0])], /must reference earlier events/],
  150. ['a non-contiguous event seq', [provenanceEvent(SessionSeq(0), undefined), provenanceEvent(SessionSeq(2), [1])], /seq 2 is not contiguous; expected 1/],
  151. ['incomplete replacement coverage', [
  152. provenanceEvent(SessionSeq(0), undefined),
  153. provenanceEvent(SessionSeq(1), undefined),
  154. { ...provenanceEvent(SessionSeq(2), [0]), surfaceOp: { op: 'replace', startSeq: 0, endSeq: 1 } },
  155. ], /missing 1/],
  156. ] as const)(
  157. 'rejects %s',
  158. (_name, events, expected) => {
  159. expect(() => foldSurface(events as unknown as SessionEvent[])).toThrow(expected)
  160. },
  161. )
  162. })
  163. describe('foldSurface tool-result rewrites', () => {
  164. it('rejects a replacement spanning multiple current nodes', () => {
  165. const events = [
  166. provenanceEvent(SessionSeq(0), undefined),
  167. provenanceEvent(SessionSeq(1), undefined),
  168. toolResultEvent(SessionSeq(2), 'rewrite', { op: 'replace', startSeq: 0, endSeq: 1 }, [0, 1]),
  169. ]
  170. expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/)
  171. })
  172. it('rejects a replacement targeting a non-result node', () => {
  173. const events = [
  174. provenanceEvent(SessionSeq(0), undefined),
  175. toolResultEvent(SessionSeq(1), 'rewrite', { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
  176. ]
  177. expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/)
  178. })
  179. it('rejects changes outside tool-result content', () => {
  180. const events = [
  181. toolResultEvent(SessionSeq(0), 'original'),
  182. toolResultEvent(SessionSeq(1), 'changed', { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
  183. ]
  184. expect(() => foldSurface(events)).toThrow(/may change only content/)
  185. })
  186. it.each([
  187. ['toolCallId', { toolCallId: ToolCallId('changed') }],
  188. ['isError', { isError: true }],
  189. ] as const)('rejects a replacement that changes the result block %s', (_field, patch) => {
  190. const original = toolResultEvent(SessionSeq(0), 'original')
  191. const data = original.data as Extract<SessionEvent, { type: 'tool/result' }>['data']
  192. const result = data.message.content[0]
  193. const replacement = {
  194. ...original,
  195. seq: SessionSeq(1),
  196. time: 1,
  197. data: {
  198. ...data,
  199. message: freezeMessage({
  200. ...data.message,
  201. content: [{ ...result, ...patch }] as [typeof result],
  202. }),
  203. },
  204. surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }),
  205. sourceEventSeqs: sourceSeqs(0),
  206. } as SessionEvent
  207. expect(() => foldSurface([original, replacement])).toThrow(/may change only content/)
  208. })
  209. it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => {
  210. const withMeta = (seq: SessionSeq, meta: unknown, op: TestSurfaceOp = 'append', sourceEventSeqs?: number[]): SessionEvent => {
  211. const event = toolResultEvent(SessionSeq(seq), 'c-meta', op, sourceEventSeqs)
  212. const data = event.data as Extract<SessionEvent, { type: 'tool/result' }>['data']
  213. return {
  214. ...event,
  215. data: {
  216. ...data,
  217. message: freezeMessage({ ...data.message, id: MessageId('meta-message') }),
  218. meta,
  219. },
  220. } as SessionEvent
  221. }
  222. // Structurally equal arrays (fresh references) pass the rest-field equality.
  223. expect(() => foldSurface([
  224. withMeta(SessionSeq(0), { tags: ['a', { n: 1 }] }),
  225. withMeta(SessionSeq(1), { tags: ['a', { n: 1 }] }, { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
  226. ])).not.toThrow()
  227. // Same length, drifted element: the array branch must reject.
  228. expect(() => foldSurface([
  229. withMeta(SessionSeq(0), { tags: ['a'] }),
  230. withMeta(SessionSeq(1), { tags: ['b'] }, { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
  231. ])).toThrow(/may change only content/)
  232. // Array vs non-array on one side: the mixed-shape guard rejects.
  233. expect(() => foldSurface([
  234. withMeta(SessionSeq(0), { tags: ['a'] }),
  235. withMeta(SessionSeq(1), { tags: 'a' }, { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
  236. ])).toThrow(/may change only content/)
  237. // Same key count, different key names: the hasOwn branch rejects.
  238. expect(() => foldSurface([
  239. withMeta(SessionSeq(0), { left: 1 }),
  240. withMeta(SessionSeq(1), { right: 1 }, { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
  241. ])).toThrow(/may change only content/)
  242. // Different key counts: the key-length branch rejects.
  243. expect(() => foldSurface([
  244. withMeta(SessionSeq(0), { one: 1 }),
  245. withMeta(SessionSeq(1), { one: 1, two: 2 }, { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
  246. ])).toThrow(/may change only content/)
  247. })
  248. })
  249. describe('SurfaceManager', () => {
  250. it('folds a contiguous window without materializing earlier event sequences', () => {
  251. const baseSeq = 400_000
  252. const events = [
  253. provenanceEvent(SessionSeq(baseSeq), undefined),
  254. provenanceEvent(SessionSeq(baseSeq + 1), undefined),
  255. {
  256. ...provenanceEvent(SessionSeq(baseSeq + 2), [baseSeq]),
  257. surfaceOp: surfaceOp({ op: 'replace', startSeq: baseSeq, endSeq: baseSeq }),
  258. },
  259. ] as SessionEvent[]
  260. const surface = new SurfaceManager(events, SessionLogOffset(baseSeq))
  261. expect(surface.nodes).toEqual([baseSeq + 2, baseSeq + 1])
  262. expect(surface.replaceGeneration).toBe(1)
  263. })
  264. it('validates tool-result rewrites against a nonzero window offset', () => {
  265. const baseSeq = 400_000
  266. const original = toolResultEvent(SessionSeq(baseSeq), 'call')
  267. const events: SessionEvent[] = [
  268. original,
  269. {
  270. ...original,
  271. seq: SessionSeq(baseSeq + 1),
  272. time: baseSeq + 1,
  273. surfaceOp: surfaceOp({ op: 'replace', startSeq: baseSeq, endSeq: baseSeq }),
  274. sourceEventSeqs: sourceSeqs(baseSeq),
  275. } as SessionEvent,
  276. ]
  277. expect(new SurfaceManager(events, SessionLogOffset(baseSeq)).nodes).toEqual([baseSeq + 1])
  278. })
  279. it('rejects a replacement that crosses a loaded window head', () => {
  280. const baseSeq = 400_000
  281. const events = [
  282. provenanceEvent(SessionSeq(baseSeq), undefined),
  283. {
  284. ...provenanceEvent(SessionSeq(baseSeq + 1), [baseSeq - 1, baseSeq]),
  285. surfaceOp: surfaceOp({ op: 'replace', startSeq: baseSeq - 1, endSeq: baseSeq }),
  286. },
  287. ] as SessionEvent[]
  288. expect(() => new SurfaceManager(events, SessionLogOffset(baseSeq)).nodes)
  289. .toThrow(`surface replace: start seq ${baseSeq - 1} not found in surface`)
  290. })
  291. it('shares ordered entries and nested replacement ranges with foldSurface', () => {
  292. const s = Session.create(SessionId('shared-fold'))
  293. s.append('user/message', createUserMessage({
  294. content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
  295. }), { surfaceOp: 'append' })
  296. s.append('user/message', createUserMessage({
  297. content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
  298. }), { surfaceOp: 'append' })
  299. s.append('user/message', replacementMessage('summary'), {
  300. surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }),
  301. sourceEventSeqs: sourceSeqs(0),
  302. })
  303. s.append('user/message', replacementMessage('summary 2'), {
  304. surfaceOp: surfaceOp({ op: 'replace', startSeq: 2, endSeq: 1 }),
  305. sourceEventSeqs: sourceSeqs(2, 1),
  306. })
  307. const folded = foldSurface(s.snapshotEvents())
  308. expect(folded.nodes).toEqual(s.surface.nodes)
  309. expect(folded.replacements).toEqual([
  310. { seq: 2, start: 0, end: 0, shadowedSeqs: [0] },
  311. { seq: 3, start: 2, end: 1, shadowedSeqs: [2, 1] },
  312. ])
  313. folded.nodes[0] = SessionSeq(99)
  314. folded.replacements[0]!.shadowedSeqs.push(SessionSeq(99))
  315. expect(s.surface.nodes).toEqual([3])
  316. expect(foldSurface(s.snapshotEvents()).nodes).toEqual([3])
  317. expect(foldSurface(s.snapshotEvents()).replacements[0]!.shadowedSeqs).toEqual([0])
  318. })
  319. it('does not retain fold-only replacement history in incremental state', () => {
  320. const s = Session.create(SessionId('incremental-state'))
  321. s.append('user/message', createUserMessage({
  322. content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
  323. }), { surfaceOp: 'append' })
  324. s.append('user/message', replacementMessage('b'), {
  325. surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }),
  326. sourceEventSeqs: sourceSeqs(0),
  327. })
  328. expect(s.surface.nodes).toEqual([1])
  329. const manager = s.surface as unknown as { _state: object }
  330. expect(Object.hasOwn(manager._state, 'replacements')).toBe(false)
  331. expect(foldSurface(s.snapshotEvents()).replacements).toEqual([
  332. { seq: 1, start: 0, end: 0, shadowedSeqs: [0] },
  333. ])
  334. })
  335. it('foldSurface reports the same invalid replacement failures as the incremental manager', () => {
  336. const events = [
  337. provenanceEvent(SessionSeq(0), undefined),
  338. { type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } },
  339. { ...provenanceEvent(SessionSeq(2), [0]), surfaceOp: { op: 'replace', startSeq: 1, endSeq: 0 } },
  340. ] as SessionEvent[]
  341. expect(() => foldSurface(events)).toThrow(/start seq 1 not found/)
  342. expect(() => Session.create(SessionId('shared-fold-invalid'), events))
  343. .toThrow(/start seq 1 not found/)
  344. })
  345. it('rejects negative-zero replacement event sequences', () => {
  346. const event = {
  347. ...provenanceEvent(SessionSeq(0), [0]),
  348. surfaceOp: { op: 'replace', startSeq: -0, endSeq: -0 },
  349. } as SessionEvent
  350. expect(() => foldSurface([event])).toThrow(/invalid replace surfaceOp/)
  351. })
  352. it('leaves incremental state unchanged when candidate validation fails', () => {
  353. const s = Session.create(SessionId('atomic-validation'))
  354. s.append('user/message', createUserMessage({
  355. content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
  356. }), { surfaceOp: 'append' })
  357. const surface = s.surface
  358. const nodes = surface.nodes
  359. expect(nodes).toEqual(foldSurface(s.snapshotEvents()).nodes)
  360. expect(surface.replaceGeneration).toBe(0)
  361. expect(() => s.append(
  362. 'assistant/message',
  363. {
  364. stream: [],
  365. turn: 1, step: 1,
  366. message: createMessage({
  367. role: 'assistant',
  368. content: [{ type: 'text', text: 'invalid' }],
  369. source: {
  370. kind: 'model',
  371. ...{ provider: 'mock', model: 'mock' },
  372. },
  373. }),
  374. },
  375. { surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }) },
  376. )).toThrow(/missing 0/)
  377. expect(s.snapshotEvents()).toHaveLength(1)
  378. expect(s.surface).toBe(surface)
  379. expect(surface.nodes).toEqual([0])
  380. expect(surface.replaceGeneration).toBe(0)
  381. expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes)
  382. s.append('user/message', createUserMessage({
  383. content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
  384. }), { surfaceOp: 'append' })
  385. expect(surface.nodes).toBe(nodes)
  386. expect(surface.nodes).toEqual([0, 1])
  387. expect(surface.replaceGeneration).toBe(0)
  388. expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes)
  389. })
  390. it('foldSurface rejects a surface-eligible event without its mandatory marker', () => {
  391. const malformed = {
  392. type: 'user/message',
  393. seq: SessionSeq(0),
  394. time: 1,
  395. data: createUserMessage({
  396. content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' },
  397. }),
  398. } as unknown as SessionEvent
  399. expect(() => foldSurface([malformed]))
  400. .toThrow(/surface-eligible and requires a surfaceOp marker/)
  401. })
  402. it('foldSurface rejects surfaceOp on a non-surface event', () => {
  403. const malformed = {
  404. type: 'turn/start',
  405. seq: SessionSeq(0),
  406. time: 1,
  407. data: { turn: 1 },
  408. surfaceOp: 'append',
  409. } as unknown as SessionEvent
  410. expect(() => foldSurface([malformed]))
  411. .toThrow(/not surface-eligible and cannot carry surfaceOp/)
  412. })
  413. it('folds an ordered sequence list from surfaceOp: append markers', () => {
  414. const s = surfaceSession()
  415. const nodes = s.surface.nodes
  416. // Only the user/message and assistant/message carry surfaceOp: 'append'.
  417. // The turn boundaries do not have surface markers.
  418. expect(nodes).toEqual([1, 2])
  419. })
  420. it('empty surface yields empty nodes', () => {
  421. const s = Session.create(SessionId('empty'))
  422. s.append('turn/start', { turn: 1 })
  423. s.append('step/start', { turn: 1, step: 1 })
  424. s.append('step/end', { turn: 1, step: 1 })
  425. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  426. expect(s.surface.nodes.length).toBe(0)
  427. expect(s.deriveMessages()).toEqual([])
  428. })
  429. it('picks up new events incrementally (delta processing)', () => {
  430. const s = surfaceSession()
  431. expect(s.surface.nodes.length).toBe(2)
  432. s.append('tool/result', {
  433. turn: 1, step: 1,
  434. message: createToolResultMessage({
  435. callId: ToolCallId('c1'),
  436. content: [{ type: 'text', text: 'ok' }],
  437. isError: false,
  438. }),
  439. }, { surfaceOp: 'append' })
  440. expect(s.surface.nodes.length).toBe(3)
  441. expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
  442. })
  443. it('replays identically from a seeded log with surface markers', () => {
  444. const original = surfaceSession()
  445. original.append('tool/result', {
  446. turn: 1, step: 1,
  447. message: createToolResultMessage({
  448. callId: ToolCallId('c1'),
  449. content: [{ type: 'text', text: 'ok' }],
  450. isError: false,
  451. }),
  452. }, { surfaceOp: 'append' })
  453. const replayed = Session.create(SessionId('replay'), original.snapshotEvents())
  454. expect(replayed.surface.nodes).toEqual([1, 2, 4])
  455. expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
  456. })
  457. it('rebuild with replace operation splices out shadowed nodes', () => {
  458. const s = surfaceSession()
  459. s.append('user/message',
  460. replacementMessage('summary'),
  461. { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 2 }), sourceEventSeqs: sourceSeqs(1, 2) },
  462. )
  463. expect(s.surface.nodes).toEqual([4])
  464. })
  465. it('replace with both ends at real nodes splices only the range', () => {
  466. const s = Session.create(SessionId('range'))
  467. s.append('user/message', createUserMessage({
  468. content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
  469. }), { surfaceOp: 'append' }) // seq 0
  470. s.append('user/message', createUserMessage({
  471. content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
  472. }), { surfaceOp: 'append' }) // seq 1
  473. s.append('user/message', createUserMessage({
  474. content: [{ type: 'text', text: 'c' }], source: { kind: 'user' },
  475. }), { surfaceOp: 'append' }) // seq 2
  476. // Replace seq 0 through 1 inclusive: shadow a and b, keep c.
  477. s.append('user/message',
  478. replacementMessage('summary'),
  479. { surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 1 }), sourceEventSeqs: sourceSeqs(0, 1) },
  480. ) // seq 3
  481. expect(s.surface.nodes).toEqual([3, 2])
  482. })
  483. it('single-node replacement (start === end)', () => {
  484. const s = Session.create(SessionId('single'))
  485. s.append('user/message', createUserMessage({
  486. content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
  487. }), { surfaceOp: 'append' }) // seq 0
  488. s.append('user/message', createUserMessage({
  489. content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
  490. }), { surfaceOp: 'append' }) // seq 1
  491. // Replace only seq 1 (single node).
  492. s.append('user/message',
  493. replacementMessage('x'),
  494. { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 1 }), sourceEventSeqs: sourceSeqs(1) },
  495. ) // seq 2
  496. expect(s.surface.nodes).toEqual([0, 2])
  497. })
  498. it('throws when replace start is not found', () => {
  499. const s = Session.create(SessionId('bad-start'))
  500. s.append('user/message', createUserMessage({
  501. content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
  502. }), { surfaceOp: 'append' }) // seq 0
  503. s.append('turn/start', { turn: 1 })
  504. expect(() => s.append('user/message',
  505. replacementMessage('y'),
  506. { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 0 }), sourceEventSeqs: sourceSeqs(0) },
  507. )).toThrow(/surface replace: start seq 1 not found/)
  508. })
  509. it('throws when replace end is not found', () => {
  510. const s = Session.create(SessionId('bad-end'))
  511. s.append('user/message', createUserMessage({
  512. content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
  513. }), { surfaceOp: 'append' }) // seq 0
  514. s.append('turn/start', { turn: 1 })
  515. expect(() => s.append('user/message',
  516. replacementMessage('y'),
  517. { surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 1 }), sourceEventSeqs: sourceSeqs(0) },
  518. )).toThrow(/surface replace: end seq 1 not found/)
  519. })
  520. it('throws when start is after end', () => {
  521. const s = Session.create(SessionId('reversed'))
  522. s.append('user/message', createUserMessage({
  523. content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
  524. }), { surfaceOp: 'append' }) // seq 0
  525. s.append('user/message', createUserMessage({
  526. content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
  527. }), { surfaceOp: 'append' }) // seq 1
  528. // start=1, end=0 would be reversed order.
  529. expect(() => s.append('user/message',
  530. replacementMessage('y'),
  531. { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 0 }), sourceEventSeqs: sourceSeqs(1, 0) },
  532. )).toThrow(/start seq 1.*after end seq 0/)
  533. })
  534. it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
  535. const s = Session.create(SessionId('immutable'))
  536. s.append('user/message', createUserMessage({
  537. content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
  538. }), { surfaceOp: 'append' })
  539. const sources = sourceSeqs(0)
  540. s.append('user/message', replacementMessage('h'), {
  541. surfaceOp: 'append',
  542. sourceEventSeqs: sources,
  543. })
  544. // Mutate caller's array after append.
  545. sources.push(SessionSeq(1))
  546. sources[0] = SessionSeq(99)
  547. const logged = s.snapshotEvents()[1]! as SurfaceEvent
  548. expect(logged.sourceEventSeqs).toEqual([0])
  549. })
  550. it('replace starting at non-head position preserves surrounding order', () => {
  551. const s = Session.create(SessionId('mid-replace'))
  552. s.append('user/message', createUserMessage({
  553. content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
  554. }), { surfaceOp: 'append' }) // seq 0
  555. s.append('user/message', createUserMessage({
  556. content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
  557. }), { surfaceOp: 'append' }) // seq 1
  558. s.append('user/message', createUserMessage({
  559. content: [{ type: 'text', text: 'c' }], source: { kind: 'user' },
  560. }), { surfaceOp: 'append' }) // seq 2
  561. // Replace the middle node (seq 1) only, keeping seq 0 and seq 2.
  562. s.append('user/message',
  563. replacementMessage('x'),
  564. { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 1 }), sourceEventSeqs: sourceSeqs(1) },
  565. ) // seq 3
  566. expect(s.surface.nodes).toEqual([0, 3, 2])
  567. })
  568. it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
  569. const s = Session.create(SessionId('immutable-op'))
  570. s.append('user/message', createUserMessage({
  571. content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
  572. }), { surfaceOp: 'append' })
  573. const op = { op: 'replace' as const, startSeq: SessionSeq(0), endSeq: SessionSeq(0) }
  574. s.append('user/message', replacementMessage('s'), {
  575. surfaceOp: op,
  576. sourceEventSeqs: sourceSeqs(0),
  577. })
  578. // Mutate caller's object after append.
  579. op.startSeq = SessionSeq(99)
  580. const logged = s.snapshotEvents()[1]! as SurfaceEvent
  581. expect(logged.surfaceOp).toEqual({ op: 'replace', startSeq: 0, endSeq: 0 })
  582. })
  583. })
  584. describe('deriveMessages with surface', () => {
  585. it('uses the surface path when surface markers are present', () => {
  586. const s = surfaceSession()
  587. const messages = s.deriveMessages()
  588. expect(messages).toHaveLength(2)
  589. expect(messages[0]!.role).toBe('user')
  590. expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'hello' })
  591. expect(messages[1]!.role).toBe('assistant')
  592. expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: 'hi' })
  593. })
  594. it('surface path skips non-surface events (attempts, boundaries)', () => {
  595. const s = Session.create(SessionId('filter'))
  596. s.append('turn/start', { turn: 1 })
  597. s.append('assistant/attempt', {
  598. turn: 1, step: 1,
  599. stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['h', 'i'] }],
  600. })
  601. s.append('user/message', createUserMessage({
  602. content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
  603. }), { surfaceOp: 'append' })
  604. s.append('assistant/message', {
  605. stream: [],
  606. turn: 1, step: 1,
  607. message: createMessage({
  608. role: 'assistant',
  609. content: [{ type: 'text', text: 'hi' }],
  610. source: {
  611. kind: 'model',
  612. ...{ provider: 'mock', model: 'mock' },
  613. },
  614. }),
  615. }, { surfaceOp: 'append' })
  616. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  617. // Attempts and boundaries are NOT in the surface, so only 2 messages.
  618. expect(s.deriveMessages()).toHaveLength(2)
  619. })
  620. it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
  621. const s = Session.create(SessionId('compacted'))
  622. s.append('user/message', createUserMessage({
  623. content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
  624. }), { surfaceOp: 'append' })
  625. s.append('user/message', replacementMessage('compacted'), {
  626. surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }),
  627. sourceEventSeqs: sourceSeqs(0),
  628. })
  629. // Only the compaction node is visible.
  630. const messages = s.deriveMessages()
  631. expect(messages).toHaveLength(1)
  632. expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
  633. })
  634. it('injected-context and user messages appear on surface', () => {
  635. const s = Session.create(SessionId('ctx'))
  636. s.append('user/message', createUserMessage({
  637. content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
  638. }), { surfaceOp: 'append' })
  639. s.append('user/message', createUserMessage({
  640. content: [{ type: 'text', text: 'focus' }],
  641. source: { kind: 'user' },
  642. }), { surfaceOp: 'append' })
  643. const messages = s.deriveMessages()
  644. expect(messages).toHaveLength(2)
  645. expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
  646. expect(messages[1]!.content).toEqual([{ type: 'text', text: 'focus' }])
  647. })
  648. })
  649. describe('Session.append surface opts', () => {
  650. it('records sourceEventSeqs and surfaceOp on a source-derived event', () => {
  651. const s = Session.create(SessionId('opts'))
  652. s.append('turn/start', { turn: 1 })
  653. s.append('step/start', { turn: 1, step: 1 })
  654. const event = s.append(
  655. 'user/message',
  656. createUserMessage({
  657. content: [{ type: 'text', text: 'h' }],
  658. source: { kind: 'plugin', plugin: 'test' },
  659. }),
  660. { surfaceOp: 'append', sourceEventSeqs: sourceSeqs(0, 1) },
  661. )
  662. expect(event.sourceEventSeqs).toEqual([0, 1])
  663. expect(event.surfaceOp).toBe('append')
  664. // The logged event matches the returned event.
  665. expect((s.snapshotEvents()[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
  666. expect((s.snapshotEvents()[2]! as SurfaceEvent).surfaceOp).toBe('append')
  667. })
  668. it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
  669. // An empty-content assistant/message is surface-eligible (it can host usage)
  670. // but _deriveOneMessage returns null for it, so the surface derivation path's
  671. // null-check is exercised — the node is on the surface yet produces no message.
  672. const seed: SessionEvent[] = [
  673. { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
  674. { type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } },
  675. { type: 'assistant/message', seq: SessionSeq(2), time: 3, data: {
  676. stream: [],
  677. turn: 1, step: 1,
  678. message: createMessage({
  679. role: 'assistant',
  680. content: [],
  681. source: {
  682. kind: 'model',
  683. ...{ provider: 'mock', model: 'mock' },
  684. },
  685. }),
  686. }, surfaceOp: 'append' },
  687. { type: 'step/end', seq: SessionSeq(3), time: 4, data: { turn: 1, step: 1 } },
  688. { type: 'turn/end', seq: SessionSeq(4), time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
  689. ]
  690. const s = Session.create(SessionId('nomessage'), seed)
  691. // The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
  692. expect(s.deriveMessages()).toHaveLength(0)
  693. })
  694. it('a non-surface event carries no surface fields', () => {
  695. const s = Session.create(SessionId('noopts'))
  696. s.append('turn/start', { turn: 1 })
  697. expect((s.snapshotEvents()[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
  698. expect((s.snapshotEvents()[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
  699. })
  700. it('surfaceOp primitives are not cloned (they are immutable)', () => {
  701. const s = Session.create(SessionId('prim'))
  702. const event = s.append('assistant/message', {
  703. stream: [],
  704. turn: 1, step: 1,
  705. message: createMessage({
  706. role: 'assistant',
  707. content: [],
  708. source: {
  709. kind: 'model',
  710. ...{ provider: 'mock', model: 'mock' },
  711. },
  712. }),
  713. }, { surfaceOp: 'append' })
  714. // The string 'append' is a primitive — identity-preserving is fine.
  715. expect(event.surfaceOp).toBe('append')
  716. })
  717. it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
  718. // A raw event (not built via append, which mandates the marker) of a
  719. // surface-eligible type but with no surfaceOp must NOT narrow to a
  720. // SurfaceEvent — it would otherwise be silently dropped from the surface.
  721. const noMarker = {
  722. type: 'user/message', seq: SessionSeq(0), time: 1,
  723. data: createUserMessage({
  724. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  725. }),
  726. } as unknown as SessionEvent
  727. expect(isSurfaceEvent(noMarker)).toBe(false)
  728. // A non-surface type is rejected too (the type gate).
  729. const boundary: SessionEvent = { type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } }
  730. expect(isSurfaceEvent(boundary)).toBe(false)
  731. // A properly-marked surface event narrows.
  732. const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
  733. expect(isSurfaceEvent(marked)).toBe(true)
  734. })
  735. })
  736. describe('surface type guards', () => {
  737. it('isSurfaceEligibleType is true only for message-producing types', () => {
  738. expect(isSurfaceEligibleType('user/message')).toBe(true)
  739. expect(isSurfaceEligibleType('assistant/message')).toBe(true)
  740. expect(isSurfaceEligibleType('tool/result')).toBe(true)
  741. expect(isSurfaceEligibleType('turn/start')).toBe(false)
  742. expect(isSurfaceEligibleType('assistant/attempt')).toBe(false)
  743. })
  744. it('isSurfaceEvent narrows a fully-formed surface event', () => {
  745. const s = surfaceSession()
  746. const userMessage = s.snapshotEvents().find(e => e.type === 'user/message')!
  747. expect(isSurfaceEvent(userMessage)).toBe(true)
  748. })
  749. it('isSurfaceEvent rejects a non-surface-eligible type', () => {
  750. const s = surfaceSession()
  751. const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')!
  752. expect(isSurfaceEvent(turnStart)).toBe(false)
  753. })
  754. it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
  755. // A seed/load record may lack its required marker before validation.
  756. const markerless = {
  757. type: 'user/message',
  758. seq: SessionSeq(0),
  759. time: 0,
  760. data: createUserMessage({
  761. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  762. }),
  763. } as unknown as SessionEvent
  764. expect(isSurfaceEligibleType(markerless.type)).toBe(true)
  765. expect(isSurfaceEvent(markerless)).toBe(false)
  766. })
  767. it('splits surface events into append-origin and replacement by their marker', () => {
  768. const s = surfaceSession()
  769. s.append('user/message', createUserMessage({
  770. content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' },
  771. }), { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 2 }), sourceEventSeqs: sourceSeqs(1, 2) })
  772. const appended = s.snapshotEvents().find(e => e.type === 'user/message')!
  773. const replacement = s.snapshotEvents().at(-1)!
  774. expect(isAppendSurfaceEvent(appended)).toBe(true)
  775. expect(isReplacementSurfaceEvent(appended)).toBe(false)
  776. expect(isAppendSurfaceEvent(replacement)).toBe(false)
  777. expect(isReplacementSurfaceEvent(replacement)).toBe(true)
  778. })
  779. it('rejects log-only and markerless events from both marker guards', () => {
  780. const s = surfaceSession()
  781. const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')!
  782. // A surface-eligible type whose mandatory marker is absent has no origin at
  783. // all: it never entered the surface.
  784. const markerless = {
  785. type: 'user/message',
  786. seq: SessionSeq(0),
  787. time: 0,
  788. data: createUserMessage({
  789. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  790. }),
  791. } as unknown as SessionEvent
  792. expect(isAppendSurfaceEvent(turnStart)).toBe(false)
  793. expect(isReplacementSurfaceEvent(turnStart)).toBe(false)
  794. expect(isAppendSurfaceEvent(markerless)).toBe(false)
  795. expect(isReplacementSurfaceEvent(markerless)).toBe(false)
  796. })
  797. })
  798. describe('SurfaceManager.replaceGeneration', () => {
  799. it('folds the pending log delta on access and counts replaces', () => {
  800. const s = Session.create(SessionId('gen'))
  801. s.append('turn/start', { turn: 1 })
  802. s.append('user/message', createUserMessage({
  803. content: [{ type: 'text', text: 'one' }], source: { kind: 'user' },
  804. }), { surfaceOp: 'append' })
  805. s.append('user/message', createUserMessage({
  806. content: [{ type: 'text', text: 'two' }], source: { kind: 'user' },
  807. }), { surfaceOp: 'append' })
  808. // Read the generation FIRST — before nodes — so the getter itself folds
  809. // the pending delta rather than piggybacking on a nodes read.
  810. expect(s.surface.replaceGeneration).toBe(0)
  811. const nodes = s.surface.nodes
  812. s.append('user/message', createUserMessage({
  813. content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
  814. }), { surfaceOp: { op: 'replace', startSeq: nodes[0]!, endSeq: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
  815. expect(s.surface.replaceGeneration).toBe(1)
  816. })
  817. })
  818. describe('system/message surface node', () => {
  819. function systemEvent(seq: number, text: string, op: TestSurfaceOp = 'append', sources?: number[]): SessionEvent {
  820. return {
  821. type: 'system/message',
  822. seq: SessionSeq(seq),
  823. time: seq,
  824. data: { turn: 1, step: 1, message: createSystemMessage(text, 'test-plugin') },
  825. surfaceOp: surfaceOp(op),
  826. ...sources === undefined ? {} : { sourceEventSeqs: sourceSeqs(...sources) },
  827. }
  828. }
  829. function userEvent(seq: number, op: TestSurfaceOp = 'append', sources?: number[]): SessionEvent {
  830. return {
  831. type: 'user/message',
  832. seq: SessionSeq(seq),
  833. time: seq,
  834. data: createUserMessage({ content: [{ type: 'text', text: `u${seq}` }], source: { kind: 'user' } }),
  835. surfaceOp: surfaceOp(op),
  836. ...sources === undefined ? {} : { sourceEventSeqs: sourceSeqs(...sources) },
  837. }
  838. }
  839. it('projects a system node as the leading system-role message and an empty one as no message', () => {
  840. const s = Session.create(SessionId('sys'))
  841. s.append('turn/start', { turn: 1 })
  842. s.append('step/start', { turn: 1, step: 1 })
  843. s.append('system/message', { turn: 1, step: 1, message: createSystemMessage('be brief', 'p') }, { surfaceOp: 'append' })
  844. s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
  845. expect(s.deriveMessages().map(message => message.role)).toEqual(['system', 'user'])
  846. expect(s.deriveMessages()[0]?.content).toEqual([{ type: 'text', text: 'be brief' }])
  847. const head = s.surface.nodes[0] as SessionSeq
  848. s.append('system/message', { turn: 1, step: 1, message: createSystemMessage('', 'p') }, {
  849. surfaceOp: { op: 'replace', startSeq: head, endSeq: head },
  850. sourceEventSeqs: [head],
  851. })
  852. expect(s.deriveMessages().map(message => message.role)).toEqual(['user'])
  853. expect(s.surface.nodes).toHaveLength(2)
  854. })
  855. it('replaces node 0 with a new system node and rejects other rewrites of the head', () => {
  856. const replaced = foldSurface([
  857. systemEvent(0, 'v1'), userEvent(1), systemEvent(2, 'v2', { op: 'replace', startSeq: 0, endSeq: 0 }, [0]),
  858. ])
  859. expect(replaced.nodes).toEqual(sourceSeqs(2, 1))
  860. expect(() => foldSurface([
  861. systemEvent(0, 'v1'), userEvent(1),
  862. userEvent(2, { op: 'replace', startSeq: 0, endSeq: 1 }, [0, 1]),
  863. ])).toThrow(/node 0 holds the system prompt/)
  864. expect(() => foldSurface([
  865. systemEvent(0, 'v1'), userEvent(1), systemEvent(2, 'v2', { op: 'replace', startSeq: 0, endSeq: 1 }, [0, 1]),
  866. ])).toThrow(/node 0 holds the system prompt/)
  867. })
  868. it('leaves later system nodes and a non-system head unprotected', () => {
  869. const later = foldSurface([
  870. systemEvent(0, 'v1'), userEvent(1), systemEvent(2, 'v2'), userEvent(3),
  871. userEvent(4, { op: 'replace', startSeq: 1, endSeq: 3 }, [1, 2, 3]),
  872. ])
  873. expect(later.nodes).toEqual(sourceSeqs(0, 4))
  874. const plainHead = foldSurface([
  875. userEvent(0), userEvent(1),
  876. userEvent(2, { op: 'replace', startSeq: 0, endSeq: 1 }, [0, 1]),
  877. ])
  878. expect(plainHead.nodes).toEqual(sourceSeqs(2))
  879. })
  880. it('rejects a seeded system/message with a non-system role or non-plugin source', () => {
  881. const good = systemEvent(0, 'v1')
  882. const badRole = { ...good, data: { ...good.data, message: { ...(good.data as { message: object }).message, role: 'user' } } }
  883. expect(() => Session.create(SessionId('bad-role'), [badRole as SessionEvent])).toThrow(/role "system"/)
  884. const badSource = { ...good, data: { ...good.data, message: { ...(good.data as { message: object }).message, source: { kind: 'user' } } } }
  885. expect(() => Session.create(SessionId('bad-source'), [badSource as SessionEvent])).toThrow(/plugin source/)
  886. })
  887. })