derived-cache.spec.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  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.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 = 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. turn: 1, step: 1,
  27. message: createMessage({
  28. role: 'assistant',
  29. content: [{ type: 'text', text: 'reply' }],
  30. source: {
  31. kind: 'model',
  32. ...{ provider: 'mock', model: 'mock' },
  33. },
  34. }),
  35. }, { surfaceOp: 'append' })
  36. expect(session.deriveMessages()).toEqual(scratch(session))
  37. session.append('assistant/message', {
  38. turn: 1, step: 2,
  39. message: createMessage({
  40. role: 'assistant',
  41. content: [],
  42. source: {
  43. kind: 'model',
  44. ...{ provider: 'mock', model: 'mock' },
  45. },
  46. }),
  47. usage: { inputTokens: 1, outputTokens: 0 },
  48. }, { surfaceOp: 'append' })
  49. expect(session.deriveMessages()).toEqual(scratch(session))
  50. })
  51. it('rebuilds on a surface replace and still matches scratch', () => {
  52. const session = Session.create(SessionId('cache-replace'))
  53. session.append('turn/start', { turn: 1 })
  54. userText(session, 'one')
  55. userText(session, 'two')
  56. const beforeReplace = session.deriveMessages()
  57. expect(beforeReplace).toHaveLength(2)
  58. const nodes = session.surface.nodes
  59. session.append('user/message', createUserMessage({
  60. content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
  61. }), { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
  62. expect(session.deriveMessages()).toHaveLength(1)
  63. expect(session.deriveMessages()).toEqual(scratch(session))
  64. expect(beforeReplace).toHaveLength(2)
  65. })
  66. it('returns a fresh array per call: later appends never grow a held snapshot', () => {
  67. const session = Session.create(SessionId('cache-snapshot'))
  68. session.append('turn/start', { turn: 1 })
  69. userText(session, 'one')
  70. const first = session.deriveMessages()
  71. userText(session, 'two')
  72. const second = session.deriveMessages()
  73. expect(first).toHaveLength(1)
  74. expect(second).toHaveLength(2)
  75. // Array snapshots share their frozen message projections.
  76. expect(second[0]).toBe(first[0])
  77. expect(Object.isFrozen(first[0])).toBe(true)
  78. })
  79. })
  80. describe('Session.deriveEventMessage — the per-event projection', () => {
  81. it('projects one appended event exactly as the full derivation projects its node', () => {
  82. const session = Session.create(SessionId('per-event'))
  83. session.append('turn/start', { turn: 1 })
  84. const event = session.append('user/message', createUserMessage({
  85. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  86. }), { surfaceOp: 'append' })
  87. // Full and per-event derivation share one projection.
  88. expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
  89. })
  90. it('reuses the logged event\'s already frozen content', () => {
  91. const session = Session.create(SessionId('per-event-clone'))
  92. session.append('turn/start', { turn: 1 })
  93. const event = session.append('user/message', createUserMessage({
  94. content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' },
  95. }), { surfaceOp: 'append' })
  96. const message = session.deriveEventMessage(event)!
  97. expect(message.content).toBe(event.data.content)
  98. expect(Object.isFrozen(message.content)).toBe(true)
  99. expect(Object.isFrozen(message.content[0])).toBe(true)
  100. expect(() => { (message.content[0] as { text: string }).text = 'mutated' }).toThrow()
  101. expect(session.deriveMessages().at(-1)!.content).toEqual([{ type: 'text', text: 'orig' }])
  102. })
  103. it('projects null for events that produce no message (boundaries, empty assistant)', () => {
  104. const session = Session.create(SessionId('per-event-null'))
  105. session.append('turn/start', { turn: 1 })
  106. const boundary = session.append('step/start', { turn: 1, step: 1 })
  107. expect(session.deriveEventMessage(boundary)).toBeNull()
  108. const empty = session.append('assistant/message', {
  109. turn: 1, step: 1,
  110. message: createMessage({
  111. role: 'assistant',
  112. content: [],
  113. source: {
  114. kind: 'model',
  115. ...{ provider: 'mock', model: 'mock' },
  116. },
  117. }),
  118. }, { surfaceOp: 'append' })
  119. expect(session.deriveEventMessage(empty)).toBeNull()
  120. })
  121. })