derived-cache.spec.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. /**
  2. * Derived-message cache tests: the session projects each surface node exactly
  3. * once (O(new nodes) per call), rebuilds on a surface rewrite (replace /
  4. * invalidate — the replaceGeneration signal), returns a fresh array snapshot
  5. * per call over shared frozen messages, and stays deep-equal to a from-scratch
  6. * replay derivation at every step — the incremental==scratch property the
  7. * reconstructability RFC's invariant enforces in dev at request time.
  8. */
  9. import { describe, expect, it } from 'vitest'
  10. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  11. function userText(session: Session, text: string): void {
  12. session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  13. }
  14. /** From-scratch oracle: replay the log into a fresh session and derive. */
  15. function scratch(session: Session): unknown {
  16. return new Session(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages()
  17. }
  18. describe('derived-message cache', () => {
  19. it('stays deep-equal to a from-scratch replay derivation as the log grows', () => {
  20. const session = new Session(SessionId('cache-grow'))
  21. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  22. userText(session, 'one')
  23. expect(session.deriveMessages()).toEqual(scratch(session))
  24. userText(session, 'two')
  25. session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' })
  26. expect(session.deriveMessages()).toEqual(scratch(session))
  27. // An empty-content assistant/message (usage host) projects to nothing.
  28. session.append('assistant/message', { turn: 1, step: 2, content: [], usage: { inputTokens: 1, outputTokens: 0 } }, { surfaceOp: 'append' })
  29. expect(session.deriveMessages()).toEqual(scratch(session))
  30. })
  31. it('rebuilds on a surface replace and still matches scratch', () => {
  32. const session = new Session(SessionId('cache-replace'))
  33. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  34. userText(session, 'one')
  35. userText(session, 'two')
  36. const beforeReplace = session.deriveMessages()
  37. expect(beforeReplace).toHaveLength(2)
  38. const nodes = session.surface.nodes
  39. session.append('context/message', {
  40. content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
  41. }, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
  42. expect(session.deriveMessages()).toHaveLength(1)
  43. expect(session.deriveMessages()).toEqual(scratch(session))
  44. // The array a caller took before the replace is untouched.
  45. expect(beforeReplace).toHaveLength(2)
  46. })
  47. it('returns a fresh array per call: later appends never grow a held snapshot', () => {
  48. const session = new Session(SessionId('cache-snapshot'))
  49. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  50. userText(session, 'one')
  51. const first = session.deriveMessages()
  52. userText(session, 'two')
  53. const second = session.deriveMessages()
  54. expect(first).toHaveLength(1)
  55. expect(second).toHaveLength(2)
  56. // Shared projection objects: the same frozen message instance, once ever.
  57. expect(second[0]).toBe(first[0])
  58. expect(Object.isFrozen(first[0])).toBe(true)
  59. })
  60. it('rebuilds after surface.invalidate() (the generation covers wholesale rebuilds too)', () => {
  61. const session = new Session(SessionId('cache-invalidate'))
  62. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  63. userText(session, 'one')
  64. const before = session.deriveMessages()
  65. session.surface.invalidate()
  66. const after = session.deriveMessages()
  67. expect(after).toEqual(before)
  68. // A rebuild re-projects: fresh objects, same values.
  69. expect(after[0]).not.toBe(before[0])
  70. })
  71. })
  72. describe('Session.deriveEventMessage — the per-event projection', () => {
  73. it('projects one appended event exactly as the full derivation projects its node', () => {
  74. const session = new Session(SessionId('per-event'))
  75. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  76. const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  77. // The fold path (deriveMessages) and the per-event path share the
  78. // projection, so an external reconstructor cannot disagree with the cache.
  79. expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
  80. })
  81. it('clones content off the log: the projection never aliases the logged event', () => {
  82. const session = new Session(SessionId('per-event-clone'))
  83. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  84. const event = session.append('user/message', { content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  85. const message = session.deriveEventMessage(event)!
  86. expect(message.content).not.toBe(event.data.content)
  87. // deriveEventMessage returns an unfrozen clone (the cache freezes ITS
  88. // copies); mutating it must not reach the log.
  89. ;(message.content[0] as { text: string }).text = 'mutated'
  90. expect(session.deriveMessages().at(-1)!.content).toEqual([{ type: 'text', text: 'orig' }])
  91. })
  92. it('projects null for events that produce no message (boundaries, empty assistant)', () => {
  93. const session = new Session(SessionId('per-event-null'))
  94. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  95. const boundary = session.append('step/start', { turn: 1, step: 1 })
  96. expect(session.deriveEventMessage(boundary)).toBeNull()
  97. const empty = session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
  98. expect(session.deriveEventMessage(empty)).toBeNull()
  99. })
  100. })