surface.spec.ts 34 KB

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