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