surface.spec.ts 24 KB

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