derived-cache.spec.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
  2. /**
  3. * Derived-message cache contract against a scratch oracle: project new nodes
  4. * once, rebuild on surface replacements, return fresh arrays over shared
  5. * frozen messages, and remain value-equal to replay at every step.
  6. */
  7. import { describe, expect, it } from 'vitest'
  8. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  9. function userText(session: Session, text: string): void {
  10. session.append('user/message', createUserMessage({
  11. content: [{ type: 'text', text }], source: { kind: 'user' },
  12. }), { surfaceOp: 'append' })
  13. }
  14. /** From-scratch oracle: replay the log into a fresh session and derive. */
  15. function scratch(session: Session): unknown {
  16. return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), session.snapshotEvents()).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 = Session.create(SessionId('cache-grow'))
  21. session.append('turn/start', { turn: 1 })
  22. userText(session, 'one')
  23. expect(session.deriveMessages()).toEqual(scratch(session))
  24. userText(session, 'two')
  25. session.append('assistant/message', {
  26. stream: [],
  27. turn: 1, step: 1,
  28. message: createMessage({
  29. role: 'assistant',
  30. content: [{ type: 'text', text: 'reply' }],
  31. source: {
  32. kind: 'model',
  33. ...{ provider: 'mock', model: 'mock' },
  34. },
  35. }),
  36. }, { surfaceOp: 'append' })
  37. expect(session.deriveMessages()).toEqual(scratch(session))
  38. session.append('assistant/message', {
  39. stream: [],
  40. turn: 1, step: 2,
  41. message: createMessage({
  42. role: 'assistant',
  43. content: [],
  44. source: {
  45. kind: 'model',
  46. ...{ provider: 'mock', model: 'mock' },
  47. },
  48. }),
  49. usage: { inputTokens: 1, outputTokens: 0 },
  50. }, { surfaceOp: 'append' })
  51. expect(session.deriveMessages()).toEqual(scratch(session))
  52. })
  53. it('rebuilds on a surface replace and still matches scratch', () => {
  54. const session = Session.create(SessionId('cache-replace'))
  55. session.append('turn/start', { turn: 1 })
  56. userText(session, 'one')
  57. userText(session, 'two')
  58. const beforeReplace = session.deriveMessages()
  59. expect(beforeReplace).toHaveLength(2)
  60. const nodes = session.surface.nodes
  61. session.append('user/message', createUserMessage({
  62. content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
  63. }), { surfaceOp: { op: 'replace', startSeq: nodes[0]!, endSeq: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
  64. expect(session.deriveMessages()).toHaveLength(1)
  65. expect(session.deriveMessages()).toEqual(scratch(session))
  66. expect(beforeReplace).toHaveLength(2)
  67. })
  68. it('returns a fresh array per call: later appends never grow a held snapshot', () => {
  69. const session = Session.create(SessionId('cache-snapshot'))
  70. session.append('turn/start', { turn: 1 })
  71. userText(session, 'one')
  72. const first = session.deriveMessages()
  73. userText(session, 'two')
  74. const second = session.deriveMessages()
  75. expect(first).toHaveLength(1)
  76. expect(second).toHaveLength(2)
  77. // Array snapshots share their frozen message projections.
  78. expect(second[0]).toBe(first[0])
  79. expect(Object.isFrozen(first[0])).toBe(true)
  80. })
  81. })
  82. describe('Session.deriveEventMessage — the per-event projection', () => {
  83. it('projects one appended event exactly as the full derivation projects its node', () => {
  84. const session = Session.create(SessionId('per-event'))
  85. session.append('turn/start', { turn: 1 })
  86. const event = session.append('user/message', createUserMessage({
  87. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  88. }), { surfaceOp: 'append' })
  89. // Full and per-event derivation share one projection.
  90. expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
  91. })
  92. it('reuses the logged event\'s already frozen content', () => {
  93. const session = Session.create(SessionId('per-event-clone'))
  94. session.append('turn/start', { turn: 1 })
  95. const event = session.append('user/message', createUserMessage({
  96. content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' },
  97. }), { surfaceOp: 'append' })
  98. const message = session.deriveEventMessage(event)!
  99. expect(message.content).toBe(event.data.content)
  100. expect(Object.isFrozen(message.content)).toBe(true)
  101. expect(Object.isFrozen(message.content[0])).toBe(true)
  102. expect(() => { (message.content[0] as { text: string }).text = 'mutated' }).toThrow()
  103. expect(session.deriveMessages().at(-1)!.content).toEqual([{ type: 'text', text: 'orig' }])
  104. })
  105. it('projects null for events that produce no message (boundaries, empty assistant)', () => {
  106. const session = Session.create(SessionId('per-event-null'))
  107. session.append('turn/start', { turn: 1 })
  108. const boundary = session.append('step/start', { turn: 1, step: 1 })
  109. expect(session.deriveEventMessage(boundary)).toBeNull()
  110. const empty = session.append('assistant/message', {
  111. stream: [],
  112. turn: 1, step: 1,
  113. message: createMessage({
  114. role: 'assistant',
  115. content: [],
  116. source: {
  117. kind: 'model',
  118. ...{ provider: 'mock', model: 'mock' },
  119. },
  120. }),
  121. }, { surfaceOp: 'append' })
  122. expect(session.deriveEventMessage(empty)).toBeNull()
  123. })
  124. })