derived-cache.spec.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. /**
  2. * Derived-message cache tests: the session projects each surface node exactly
  3. * once (O(new nodes) per call), rebuilds on a surface replace (the
  4. * 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]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
  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. })
  61. describe('Session.deriveEventMessage — the per-event projection', () => {
  62. it('projects one appended event exactly as the full derivation projects its node', () => {
  63. const session = new Session(SessionId('per-event'))
  64. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  65. const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  66. // The fold path (deriveMessages) and the per-event path share the
  67. // projection, so an external reconstructor cannot disagree with the cache.
  68. expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
  69. })
  70. it('reuses the logged event\'s already frozen content', () => {
  71. const session = new Session(SessionId('per-event-clone'))
  72. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  73. const event = session.append('user/message', { content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  74. const message = session.deriveEventMessage(event)!
  75. expect(message.content).toBe(event.data.content)
  76. expect(Object.isFrozen(message.content)).toBe(true)
  77. expect(Object.isFrozen(message.content[0])).toBe(true)
  78. expect(() => { (message.content[0] as { text: string }).text = 'mutated' }).toThrow()
  79. expect(session.deriveMessages().at(-1)!.content).toEqual([{ type: 'text', text: 'orig' }])
  80. })
  81. it('projects null for events that produce no message (boundaries, empty assistant)', () => {
  82. const session = new Session(SessionId('per-event-null'))
  83. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  84. const boundary = session.append('step/start', { turn: 1, step: 1 })
  85. expect(session.deriveEventMessage(boundary)).toBeNull()
  86. const empty = session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
  87. expect(session.deriveEventMessage(empty)).toBeNull()
  88. })
  89. })