1
0

surface.spec.ts 22 KB

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