surface.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import { describe, expect, it } from 'vitest'
  2. import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
  3. import { Session, SessionId, 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', { 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('folds an ordered sequence list from surfaceOp: append markers', () => {
  16. const s = surfaceSession()
  17. const nodes = s.surface.nodes
  18. // Only the user/message and assistant/message carry surfaceOp: 'append'.
  19. // The turn boundaries do not have surface markers.
  20. expect(nodes).toEqual([1, 2])
  21. })
  22. it('empty surface yields empty nodes', () => {
  23. const s = new Session(SessionId('empty'))
  24. // Only turn boundaries, no surface nodes.
  25. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  26. s.append('step/start', { turn: 1, step: 1 })
  27. s.append('step/end', { turn: 1, step: 1 })
  28. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  29. expect(s.surface.nodes.length).toBe(0)
  30. // deriveMessages returns empty array
  31. expect(s.deriveMessages()).toEqual([])
  32. })
  33. it('picks up new events incrementally (delta processing)', () => {
  34. const s = surfaceSession()
  35. expect(s.surface.nodes.length).toBe(2)
  36. // Append another surface node
  37. s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
  38. expect(s.surface.nodes.length).toBe(3)
  39. expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
  40. })
  41. it('replays identically from a seeded log with surface markers', () => {
  42. const original = surfaceSession()
  43. original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
  44. const replayed = new Session(SessionId('replay'), [...original.events])
  45. // Surface rebuilds from the seeded log's markers.
  46. expect(replayed.surface.nodes).toEqual([1, 2, 4])
  47. expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
  48. })
  49. it('rebuild with replace operation splices out shadowed nodes', () => {
  50. const s = surfaceSession()
  51. // seq: 0=turn/start, 1=user, 2=assistant, 3=turn/end
  52. // Surface nodes: seq 1 (user), seq 2 (assistant).
  53. // Replace both with a compaction marker. Both 1 and 2 are valid surface seqs.
  54. s.append('assistant/message',
  55. { turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] },
  56. { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] },
  57. )
  58. // Now the surface should have just the compaction node.
  59. expect(s.surface.nodes).toEqual([4])
  60. })
  61. it('replace with both ends at real nodes splices only the range', () => {
  62. const s = new Session(SessionId('range'))
  63. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  64. s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
  65. s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
  66. // Replace seq 0 through 1 inclusive: shadow a and b, keep c.
  67. s.append('assistant/message',
  68. { turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] },
  69. { surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] },
  70. ) // seq 3
  71. expect(s.surface.nodes).toEqual([3, 2])
  72. })
  73. it('single-node replacement (start === end)', () => {
  74. const s = new Session(SessionId('single'))
  75. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  76. s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
  77. // Replace only seq 1 (single node).
  78. s.append('assistant/message',
  79. { turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
  80. { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
  81. ) // seq 2
  82. expect(s.surface.nodes).toEqual([0, 2])
  83. })
  84. it('throws when replace start is not found', () => {
  85. const s = new Session(SessionId('bad-start'))
  86. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  87. s.append('assistant/message',
  88. { turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
  89. { surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [5, 0] },
  90. )
  91. expect(() => s.surface.nodes).toThrow(/surface replace: start seq 5 not found/)
  92. })
  93. it('throws when replace end is not found', () => {
  94. const s = new Session(SessionId('bad-end'))
  95. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  96. s.append('assistant/message',
  97. { turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
  98. { surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] },
  99. )
  100. expect(() => s.surface.nodes).toThrow(/surface replace: end seq 99 not found/)
  101. })
  102. it('throws when start is after end', () => {
  103. const s = new Session(SessionId('reversed'))
  104. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  105. s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
  106. // start=1, end=0 would be reversed order.
  107. s.append('assistant/message',
  108. { turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
  109. { surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] },
  110. )
  111. expect(() => s.surface.nodes).toThrow(/start seq 1.*after end seq 0/)
  112. })
  113. it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
  114. const s = new Session(SessionId('immutable'))
  115. const sources = [10, 20]
  116. s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources })
  117. // Mutate caller's array after append.
  118. sources.push(30)
  119. sources[0] = 99
  120. const logged = s.events[0]! as SurfaceEvent
  121. expect(logged.sourceEventSeqs).toEqual([10, 20])
  122. })
  123. it('replace starting at non-head position preserves surrounding order', () => {
  124. const s = new Session(SessionId('mid-replace'))
  125. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
  126. s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
  127. s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
  128. // Replace the middle node (seq 1) only, keeping seq 0 and seq 2.
  129. s.append('assistant/message',
  130. { turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
  131. { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
  132. ) // seq 3
  133. expect(s.surface.nodes).toEqual([0, 3, 2])
  134. })
  135. it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
  136. const s = new Session(SessionId('immutable-op'))
  137. s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  138. const op = { op: 'replace' as const, start: 0, end: 0 }
  139. s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: op, sourceEventSeqs: [0] })
  140. // Mutate caller's object after append.
  141. op.start = 99
  142. const logged = s.events[1]! as SurfaceEvent
  143. expect(logged.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
  144. })
  145. })
  146. describe('deriveMessages with surface', () => {
  147. it('uses the surface path when surface markers are present', () => {
  148. const s = surfaceSession()
  149. const messages = s.deriveMessages()
  150. expect(messages).toHaveLength(2)
  151. expect(messages[0]!.role).toBe('user')
  152. expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'hello' })
  153. expect(messages[1]!.role).toBe('assistant')
  154. expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: 'hi' })
  155. })
  156. it('surface path skips non-surface events (chunks, boundaries)', () => {
  157. const s = new Session(SessionId('filter'))
  158. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  159. s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
  160. s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
  161. s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  162. s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
  163. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  164. // Chunks and boundaries are NOT in the surface, so only 2 messages.
  165. expect(s.deriveMessages()).toHaveLength(2)
  166. })
  167. it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
  168. const s = new Session(SessionId('compacted'))
  169. s.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  170. s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'compacted' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
  171. // Only the compaction node is visible.
  172. const messages = s.deriveMessages()
  173. expect(messages).toHaveLength(1)
  174. expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
  175. })
  176. it('context/message and steering/message appear on surface', () => {
  177. const s = new Session(SessionId('ctx'))
  178. s.append('context/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
  179. s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  180. const messages = s.deriveMessages()
  181. expect(messages).toHaveLength(2)
  182. expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
  183. expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
  184. })
  185. })
  186. describe('Session.append surface opts', () => {
  187. it('records sourceEventSeqs and surfaceOp on the event', () => {
  188. const s = new Session(SessionId('opts'))
  189. const event = s.append('assistant/message',
  190. { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] },
  191. { surfaceOp: 'append', sourceEventSeqs: [3, 5, 7] },
  192. )
  193. expect(event.sourceEventSeqs).toEqual([3, 5, 7])
  194. expect(event.surfaceOp).toBe('append')
  195. // The logged event matches the returned event.
  196. expect((s.events[0]! as SurfaceEvent).sourceEventSeqs).toEqual([3, 5, 7])
  197. expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append')
  198. })
  199. it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
  200. // An empty-content assistant/message is surface-eligible (it can host usage)
  201. // but _deriveOneMessage returns null for it, so the surface derivation path's
  202. // null-check is exercised — the node is on the surface yet produces no message.
  203. const seed: SessionEvent[] = [
  204. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  205. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  206. { type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [] }, surfaceOp: 'append' },
  207. { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
  208. { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
  209. ]
  210. const s = new Session(SessionId('nomessage'), seed)
  211. // The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
  212. expect(s.deriveMessages()).toHaveLength(0)
  213. })
  214. it('a non-surface event carries no surface fields', () => {
  215. const s = new Session(SessionId('noopts'))
  216. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  217. expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
  218. expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
  219. })
  220. it('surfaceOp primitives are not cloned (they are immutable)', () => {
  221. const s = new Session(SessionId('prim'))
  222. const event = s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
  223. // The string 'append' is a primitive — identity-preserving is fine.
  224. expect(event.surfaceOp).toBe('append')
  225. })
  226. it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
  227. // A raw event (not built via append, which mandates the marker) of a
  228. // surface-eligible type but with no surfaceOp must NOT narrow to a
  229. // SurfaceEvent — it would otherwise be silently dropped from the surface.
  230. const noMarker: SessionEvent = {
  231. type: 'user/message', seq: 0, time: 1,
  232. data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
  233. }
  234. expect(isSurfaceEvent(noMarker)).toBe(false)
  235. // A non-surface type is rejected too (the type gate).
  236. const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }
  237. expect(isSurfaceEvent(boundary)).toBe(false)
  238. // A properly-marked surface event narrows.
  239. const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
  240. expect(isSurfaceEvent(marked)).toBe(true)
  241. })
  242. })
  243. describe('surface type guards', () => {
  244. it('isSurfaceEligibleType is true only for message-producing types', () => {
  245. expect(isSurfaceEligibleType('user/message')).toBe(true)
  246. expect(isSurfaceEligibleType('assistant/message')).toBe(true)
  247. expect(isSurfaceEligibleType('tool/result')).toBe(true)
  248. expect(isSurfaceEligibleType('context/message')).toBe(true)
  249. expect(isSurfaceEligibleType('steering/message')).toBe(true)
  250. expect(isSurfaceEligibleType('turn/start')).toBe(false)
  251. expect(isSurfaceEligibleType('assistant/chunk')).toBe(false)
  252. })
  253. it('isSurfaceEvent narrows a fully-formed surface event', () => {
  254. const s = surfaceSession()
  255. const userMessage = s.events.find(e => e.type === 'user/message')!
  256. expect(isSurfaceEvent(userMessage)).toBe(true)
  257. })
  258. it('isSurfaceEvent rejects a non-surface-eligible type', () => {
  259. const s = surfaceSession()
  260. const turnStart = s.events.find(e => e.type === 'turn/start')!
  261. expect(isSurfaceEvent(turnStart)).toBe(false)
  262. })
  263. it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
  264. // A surface-eligible type whose mandatory surfaceOp is absent — the state a
  265. // seed/load log can carry before the marker is validated. surfaceOp is
  266. // optional on SessionEvent, so this is a representable runtime value.
  267. const markerless: SessionEvent = {
  268. type: 'user/message',
  269. seq: 0,
  270. time: 0,
  271. data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
  272. }
  273. expect(isSurfaceEligibleType(markerless.type)).toBe(true)
  274. expect(isSurfaceEvent(markerless)).toBe(false)
  275. })
  276. })
  277. describe('SurfaceManager.replaceGeneration', () => {
  278. it('folds the pending log delta on access and counts replaces', () => {
  279. const s = new Session(SessionId('gen'))
  280. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  281. s.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  282. s.append('user/message', { content: [{ type: 'text', text: 'two' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  283. // Read the generation FIRST — before nodes — so the getter itself folds
  284. // the pending delta rather than piggybacking on a nodes read.
  285. expect(s.surface.replaceGeneration).toBe(0)
  286. const nodes = s.surface.nodes
  287. s.append('context/message', {
  288. content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
  289. }, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
  290. expect(s.surface.replaceGeneration).toBe(1)
  291. })
  292. })