surface.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  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. foldSurface,
  7. isSurfaceEligibleType,
  8. isSurfaceEvent,
  9. } from '@deepseek-ai/dsh-session'
  10. import { CallId } from '@deepseek-ai/dsh-llm'
  11. /** Build a minimal session with turn boundaries and a single user message. */
  12. function surfaceSession(): Session {
  13. const s = new Session(SessionId('ss'))
  14. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  15. s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  16. s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
  17. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  18. return s
  19. }
  20. function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
  21. return {
  22. type: 'user/message',
  23. seq,
  24. time: seq,
  25. data: { content: [], source: { kind: 'user' } },
  26. surfaceOp: 'append',
  27. ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
  28. } as unknown as SessionEvent
  29. }
  30. function toolResultEvent(
  31. seq: number,
  32. callId: string,
  33. surfaceOp: SurfaceEvent['surfaceOp'] = 'append',
  34. sourceEventSeqs?: number[],
  35. ): SessionEvent {
  36. return {
  37. type: 'tool/result',
  38. seq,
  39. time: seq,
  40. data: {
  41. turn: 1,
  42. step: 1,
  43. callId: CallId(callId),
  44. content: [{ type: 'text', text: `result ${seq}` }],
  45. isError: false,
  46. },
  47. surfaceOp,
  48. ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
  49. }
  50. }
  51. describe('foldSurface provenance', () => {
  52. it('accepts absent or valid provenance and complete replacement coverage', () => {
  53. const events = [
  54. provenanceEvent(0, undefined),
  55. provenanceEvent(1, undefined),
  56. {
  57. ...provenanceEvent(2, [0, 1]),
  58. surfaceOp: { op: 'replace', start: 0, end: 1 },
  59. },
  60. ] as SessionEvent[]
  61. expect(() => foldSurface(events)).not.toThrow()
  62. })
  63. it('rejects provenance on a non-surface event', () => {
  64. const event = {
  65. type: 'turn/start',
  66. seq: 0,
  67. time: 1,
  68. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  69. sourceEventSeqs: [0],
  70. } as unknown as SessionEvent
  71. expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/)
  72. })
  73. it('accepts explicit empty provenance on an assistant message', () => {
  74. const event = {
  75. type: 'assistant/message',
  76. seq: 0,
  77. time: 0,
  78. data: {
  79. provenance: { provider: 'mock', model: 'mock' },
  80. turn: 1,
  81. step: 1,
  82. content: [],
  83. },
  84. surfaceOp: 'append',
  85. sourceEventSeqs: [],
  86. } as SessionEvent
  87. expect(() => foldSurface([event])).not.toThrow()
  88. })
  89. it.each([
  90. ['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/],
  91. ['an empty array', [provenanceEvent(0, [])], /must not be empty/],
  92. ['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/],
  93. ['a sparse array', [provenanceEvent(0, Array<number>(1))], /densely contain/],
  94. ['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/],
  95. ['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/],
  96. ['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/],
  97. ['a self reference', [provenanceEvent(0, [0])], /must reference earlier events/],
  98. ['a non-contiguous event seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /seq 2 is not contiguous; expected 1/],
  99. ['incomplete replacement coverage', [
  100. provenanceEvent(0, undefined),
  101. provenanceEvent(1, undefined),
  102. { ...provenanceEvent(2, [0]), surfaceOp: { op: 'replace', start: 0, end: 1 } },
  103. ], /missing 1/],
  104. ] as const)(
  105. 'rejects %s',
  106. (_name, events, expected) => {
  107. expect(() => foldSurface(events as unknown as SessionEvent[])).toThrow(expected)
  108. },
  109. )
  110. })
  111. describe('foldSurface tool-result rewrites', () => {
  112. it('rejects a replacement spanning multiple current nodes', () => {
  113. const events = [
  114. provenanceEvent(0, undefined),
  115. provenanceEvent(1, undefined),
  116. toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]),
  117. ]
  118. expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/)
  119. })
  120. it('rejects a replacement targeting a non-result node', () => {
  121. const events = [
  122. provenanceEvent(0, undefined),
  123. toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]),
  124. ]
  125. expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/)
  126. })
  127. it('rejects changes outside tool-result content', () => {
  128. const events = [
  129. toolResultEvent(0, 'original'),
  130. toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]),
  131. ]
  132. expect(() => foldSurface(events)).toThrow(/may change only content/)
  133. })
  134. it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => {
  135. const withMeta = (seq: number, meta: unknown, surfaceOp: SurfaceEvent['surfaceOp'] = 'append', sourceEventSeqs?: number[]): SessionEvent => {
  136. const event = toolResultEvent(seq, 'c-meta', surfaceOp, sourceEventSeqs)
  137. return { ...event, data: { ...(event.data as object), meta } } as SessionEvent
  138. }
  139. // Structurally equal arrays (fresh references) pass the rest-field equality.
  140. expect(() => foldSurface([
  141. withMeta(0, { tags: ['a', { n: 1 }] }),
  142. withMeta(1, { tags: ['a', { n: 1 }] }, { op: 'replace', start: 0, end: 0 }, [0]),
  143. ])).not.toThrow()
  144. // Same length, drifted element: the array branch must reject.
  145. expect(() => foldSurface([
  146. withMeta(0, { tags: ['a'] }),
  147. withMeta(1, { tags: ['b'] }, { op: 'replace', start: 0, end: 0 }, [0]),
  148. ])).toThrow(/may change only content/)
  149. // Array vs non-array on one side: the mixed-shape guard rejects.
  150. expect(() => foldSurface([
  151. withMeta(0, { tags: ['a'] }),
  152. withMeta(1, { tags: 'a' }, { op: 'replace', start: 0, end: 0 }, [0]),
  153. ])).toThrow(/may change only content/)
  154. // Same key count, different key names: the hasOwn branch rejects.
  155. expect(() => foldSurface([
  156. withMeta(0, { left: 1 }),
  157. withMeta(1, { right: 1 }, { op: 'replace', start: 0, end: 0 }, [0]),
  158. ])).toThrow(/may change only content/)
  159. // Different key counts: the key-length branch rejects.
  160. expect(() => foldSurface([
  161. withMeta(0, { one: 1 }),
  162. withMeta(1, { one: 1, two: 2 }, { op: 'replace', start: 0, end: 0 }, [0]),
  163. ])).toThrow(/may change only content/)
  164. })
  165. })
  166. describe('SurfaceManager', () => {
  167. it('shares ordered entries and nested replacement ranges with foldSurface', () => {
  168. const s = new Session(SessionId('shared-fold'))
  169. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  170. s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  171. s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
  172. s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [{ type: 'text', text: 'summary 2' }] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] })
  173. const folded = foldSurface(s.events)
  174. expect(folded.nodes).toEqual(s.surface.nodes)
  175. expect(folded.replacements).toEqual([
  176. { seq: 2, start: 0, end: 0, shadowedSeqs: [0] },
  177. { seq: 3, start: 2, end: 1, shadowedSeqs: [2, 1] },
  178. ])
  179. folded.nodes[0] = 99
  180. folded.replacements[0]!.shadowedSeqs.push(99)
  181. expect(s.surface.nodes).toEqual([3])
  182. expect(foldSurface(s.events).nodes).toEqual([3])
  183. expect(foldSurface(s.events).replacements[0]!.shadowedSeqs).toEqual([0])
  184. })
  185. it('does not retain fold-only replacement history in incremental state', () => {
  186. const s = new Session(SessionId('incremental-state'))
  187. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  188. s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
  189. expect(s.surface.nodes).toEqual([1])
  190. const manager = s.surface as unknown as { _state: object }
  191. expect(Object.hasOwn(manager._state, 'replacements')).toBe(false)
  192. expect(foldSurface(s.events).replacements).toEqual([
  193. { seq: 1, start: 0, end: 0, shadowedSeqs: [0] },
  194. ])
  195. })
  196. it('foldSurface reports the same invalid replacement failures as the incremental manager', () => {
  197. const events = [
  198. provenanceEvent(0, undefined),
  199. { ...provenanceEvent(1, [0]), surfaceOp: { op: 'replace', start: 42, end: 0 } },
  200. ] as SessionEvent[]
  201. expect(() => foldSurface(events)).toThrow(/start seq 42 not found/)
  202. expect(() => new Session(SessionId('shared-fold-invalid'), events))
  203. .toThrow(/start seq 42 not found/)
  204. })
  205. it('leaves incremental state unchanged when candidate validation fails', () => {
  206. const s = new Session(SessionId('atomic-validation'))
  207. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  208. const surface = s.surface
  209. const nodes = surface.nodes
  210. expect(nodes).toEqual(foldSurface(s.events).nodes)
  211. expect(surface.replaceGeneration).toBe(0)
  212. expect(() => s.append(
  213. 'assistant/message',
  214. { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'invalid' }] },
  215. { surfaceOp: { op: 'replace', start: 0, end: 0 } },
  216. )).toThrow(/missing 0/)
  217. expect(s.events).toHaveLength(1)
  218. expect(s.surface).toBe(surface)
  219. expect(surface.nodes).toEqual([0])
  220. expect(surface.replaceGeneration).toBe(0)
  221. expect(surface.nodes).toEqual(foldSurface(s.events).nodes)
  222. s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  223. expect(surface.nodes).toBe(nodes)
  224. expect(surface.nodes).toEqual([0, 1])
  225. expect(surface.replaceGeneration).toBe(0)
  226. expect(surface.nodes).toEqual(foldSurface(s.events).nodes)
  227. })
  228. it('foldSurface rejects a surface-eligible event without its mandatory marker', () => {
  229. const malformed: SessionEvent = {
  230. type: 'user/message',
  231. seq: 0,
  232. time: 1,
  233. data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } },
  234. }
  235. expect(() => foldSurface([malformed]))
  236. .toThrow(/surface-eligible and requires a surfaceOp marker/)
  237. })
  238. it('foldSurface rejects surfaceOp on a non-surface event', () => {
  239. const malformed = {
  240. type: 'turn/start',
  241. seq: 0,
  242. time: 1,
  243. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  244. surfaceOp: 'append',
  245. } as unknown as SessionEvent
  246. expect(() => foldSurface([malformed]))
  247. .toThrow(/not surface-eligible and cannot carry surfaceOp/)
  248. })
  249. it('folds an ordered sequence list from surfaceOp: append markers', () => {
  250. const s = surfaceSession()
  251. const nodes = s.surface.nodes
  252. // Only the user/message and assistant/message carry surfaceOp: 'append'.
  253. // The turn boundaries do not have surface markers.
  254. expect(nodes).toEqual([1, 2])
  255. })
  256. it('empty surface yields empty nodes', () => {
  257. const s = new Session(SessionId('empty'))
  258. // Only turn boundaries, no surface nodes.
  259. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  260. s.append('step/start', { turn: 1, step: 1 })
  261. s.append('step/end', { turn: 1, step: 1 })
  262. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  263. expect(s.surface.nodes.length).toBe(0)
  264. // deriveMessages returns empty array
  265. expect(s.deriveMessages()).toEqual([])
  266. })
  267. it('picks up new events incrementally (delta processing)', () => {
  268. const s = surfaceSession()
  269. expect(s.surface.nodes.length).toBe(2)
  270. // Append another surface node
  271. s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
  272. expect(s.surface.nodes.length).toBe(3)
  273. expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
  274. })
  275. it('replays identically from a seeded log with surface markers', () => {
  276. const original = surfaceSession()
  277. original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
  278. const replayed = new Session(SessionId('replay'), [...original.events])
  279. // Surface rebuilds from the seeded log's markers.
  280. expect(replayed.surface.nodes).toEqual([1, 2, 4])
  281. expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
  282. })
  283. it('rebuild with replace operation splices out shadowed nodes', () => {
  284. const s = surfaceSession()
  285. s.append('assistant/message',
  286. { provenance: { provider: 'mock', model: 'mock' }, turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] },
  287. { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] },
  288. )
  289. expect(s.surface.nodes).toEqual([4])
  290. })
  291. it('replace with both ends at real nodes splices only the range', () => {
  292. const s = new Session(SessionId('range'))
  293. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  294. s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
  295. s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
  296. // Replace seq 0 through 1 inclusive: shadow a and b, keep c.
  297. s.append('assistant/message',
  298. { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] },
  299. { surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] },
  300. ) // seq 3
  301. expect(s.surface.nodes).toEqual([3, 2])
  302. })
  303. it('single-node replacement (start === end)', () => {
  304. const s = new Session(SessionId('single'))
  305. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  306. s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
  307. // Replace only seq 1 (single node).
  308. s.append('assistant/message',
  309. { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
  310. { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
  311. ) // seq 2
  312. expect(s.surface.nodes).toEqual([0, 2])
  313. })
  314. it('throws when replace start is not found', () => {
  315. const s = new Session(SessionId('bad-start'))
  316. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  317. expect(() => s.append('assistant/message',
  318. { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
  319. { surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [0] },
  320. )).toThrow(/surface replace: start seq 5 not found/)
  321. })
  322. it('throws when replace end is not found', () => {
  323. const s = new Session(SessionId('bad-end'))
  324. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  325. expect(() => s.append('assistant/message',
  326. { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
  327. { surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] },
  328. )).toThrow(/surface replace: end seq 99 not found/)
  329. })
  330. it('throws when start is after end', () => {
  331. const s = new Session(SessionId('reversed'))
  332. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  333. s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
  334. // start=1, end=0 would be reversed order.
  335. expect(() => s.append('assistant/message',
  336. { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
  337. { surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] },
  338. )).toThrow(/start seq 1.*after end seq 0/)
  339. })
  340. it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
  341. const s = new Session(SessionId('immutable'))
  342. s.append('user/message', { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  343. const sources = [0]
  344. s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources })
  345. // Mutate caller's array after append.
  346. sources.push(1)
  347. sources[0] = 99
  348. const logged = s.events[1]! as SurfaceEvent
  349. expect(logged.sourceEventSeqs).toEqual([0])
  350. })
  351. it('replace starting at non-head position preserves surrounding order', () => {
  352. const s = new Session(SessionId('mid-replace'))
  353. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  354. s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
  355. s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
  356. // Replace the middle node (seq 1) only, keeping seq 0 and seq 2.
  357. s.append('assistant/message',
  358. { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
  359. { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
  360. ) // seq 3
  361. expect(s.surface.nodes).toEqual([0, 3, 2])
  362. })
  363. it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
  364. const s = new Session(SessionId('immutable-op'))
  365. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  366. const op = { op: 'replace' as const, start: 0, end: 0 }
  367. s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: op, sourceEventSeqs: [0] })
  368. // Mutate caller's object after append.
  369. op.start = 99
  370. const logged = s.events[1]! as SurfaceEvent
  371. expect(logged.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
  372. })
  373. })
  374. describe('deriveMessages with surface', () => {
  375. it('uses the surface path when surface markers are present', () => {
  376. const s = surfaceSession()
  377. const messages = s.deriveMessages()
  378. expect(messages).toHaveLength(2)
  379. expect(messages[0]!.role).toBe('user')
  380. expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'hello' })
  381. expect(messages[1]!.role).toBe('assistant')
  382. expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: 'hi' })
  383. })
  384. it('surface path skips non-surface events (chunks, boundaries)', () => {
  385. const s = new Session(SessionId('filter'))
  386. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  387. s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
  388. s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
  389. s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  390. s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
  391. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  392. // Chunks and boundaries are NOT in the surface, so only 2 messages.
  393. expect(s.deriveMessages()).toHaveLength(2)
  394. })
  395. it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
  396. const s = new Session(SessionId('compacted'))
  397. s.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  398. s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'compacted' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
  399. // Only the compaction node is visible.
  400. const messages = s.deriveMessages()
  401. expect(messages).toHaveLength(1)
  402. expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
  403. })
  404. it('injected-context and steering/message appear on surface', () => {
  405. const s = new Session(SessionId('ctx'))
  406. s.append('user/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
  407. s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  408. const messages = s.deriveMessages()
  409. expect(messages).toHaveLength(2)
  410. expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
  411. expect(messages[1]!.content).toEqual([{ type: 'text', text: 'focus' }])
  412. })
  413. })
  414. describe('Session.append surface opts', () => {
  415. it('records sourceEventSeqs and surfaceOp on the event', () => {
  416. const s = new Session(SessionId('opts'))
  417. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  418. s.append('step/start', { turn: 1, step: 1 })
  419. const event = s.append('assistant/message',
  420. { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] },
  421. { surfaceOp: 'append', sourceEventSeqs: [0, 1] },
  422. )
  423. expect(event.sourceEventSeqs).toEqual([0, 1])
  424. expect(event.surfaceOp).toBe('append')
  425. // The logged event matches the returned event.
  426. expect((s.events[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
  427. expect((s.events[2]! as SurfaceEvent).surfaceOp).toBe('append')
  428. })
  429. it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
  430. // An empty-content assistant/message is surface-eligible (it can host usage)
  431. // but _deriveOneMessage returns null for it, so the surface derivation path's
  432. // null-check is exercised — the node is on the surface yet produces no message.
  433. const seed: SessionEvent[] = [
  434. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  435. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  436. { type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
  437. { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
  438. { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
  439. ]
  440. const s = new Session(SessionId('nomessage'), seed)
  441. // The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
  442. expect(s.deriveMessages()).toHaveLength(0)
  443. })
  444. it('a non-surface event carries no surface fields', () => {
  445. const s = new Session(SessionId('noopts'))
  446. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  447. expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
  448. expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
  449. })
  450. it('surfaceOp primitives are not cloned (they are immutable)', () => {
  451. const s = new Session(SessionId('prim'))
  452. const event = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
  453. // The string 'append' is a primitive — identity-preserving is fine.
  454. expect(event.surfaceOp).toBe('append')
  455. })
  456. it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
  457. // A raw event (not built via append, which mandates the marker) of a
  458. // surface-eligible type but with no surfaceOp must NOT narrow to a
  459. // SurfaceEvent — it would otherwise be silently dropped from the surface.
  460. const noMarker: SessionEvent = {
  461. type: 'user/message', seq: 0, time: 1,
  462. data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
  463. }
  464. expect(isSurfaceEvent(noMarker)).toBe(false)
  465. // A non-surface type is rejected too (the type gate).
  466. const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }
  467. expect(isSurfaceEvent(boundary)).toBe(false)
  468. // A properly-marked surface event narrows.
  469. const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
  470. expect(isSurfaceEvent(marked)).toBe(true)
  471. })
  472. })
  473. describe('surface type guards', () => {
  474. it('isSurfaceEligibleType is true only for message-producing types', () => {
  475. expect(isSurfaceEligibleType('user/message')).toBe(true)
  476. expect(isSurfaceEligibleType('assistant/message')).toBe(true)
  477. expect(isSurfaceEligibleType('tool/result')).toBe(true)
  478. expect(isSurfaceEligibleType('steering/message')).toBe(true)
  479. expect(isSurfaceEligibleType('turn/start')).toBe(false)
  480. expect(isSurfaceEligibleType('assistant/chunk')).toBe(false)
  481. })
  482. it('isSurfaceEvent narrows a fully-formed surface event', () => {
  483. const s = surfaceSession()
  484. const userMessage = s.events.find(e => e.type === 'user/message')!
  485. expect(isSurfaceEvent(userMessage)).toBe(true)
  486. })
  487. it('isSurfaceEvent rejects a non-surface-eligible type', () => {
  488. const s = surfaceSession()
  489. const turnStart = s.events.find(e => e.type === 'turn/start')!
  490. expect(isSurfaceEvent(turnStart)).toBe(false)
  491. })
  492. it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
  493. // A surface-eligible type whose mandatory surfaceOp is absent — the state a
  494. // seed/load log can carry before the marker is validated. surfaceOp is
  495. // optional on SessionEvent, so this is a representable runtime value.
  496. const markerless: SessionEvent = {
  497. type: 'user/message',
  498. seq: 0,
  499. time: 0,
  500. data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
  501. }
  502. expect(isSurfaceEligibleType(markerless.type)).toBe(true)
  503. expect(isSurfaceEvent(markerless)).toBe(false)
  504. })
  505. })
  506. describe('SurfaceManager.replaceGeneration', () => {
  507. it('folds the pending log delta on access and counts replaces', () => {
  508. const s = new Session(SessionId('gen'))
  509. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  510. s.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  511. s.append('user/message', { content: [{ type: 'text', text: 'two' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  512. // Read the generation FIRST — before nodes — so the getter itself folds
  513. // the pending delta rather than piggybacking on a nodes read.
  514. expect(s.surface.replaceGeneration).toBe(0)
  515. const nodes = s.surface.nodes
  516. s.append('user/message', {
  517. content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
  518. }, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
  519. expect(s.surface.replaceGeneration).toBe(1)
  520. })
  521. })