surface.spec.ts 36 KB

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