surface.spec.ts 35 KB

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