projection.spec.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import { describe, expect, it } from 'vitest'
  2. import { createAssistantMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
  3. import type { ContentBlock, ImageBlock } from '@deepseek-ai/dsh-llm'
  4. import { deriveEventMessage, foldSurface, Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
  5. import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
  6. import { imageOffloadProjection } from '../src/projection.ts'
  7. function createSession(...args: Parameters<typeof Session.create>): Session {
  8. args[4] = [imageOffloadProjection]
  9. return Session.create(...args)
  10. }
  11. const image: ImageBlock = {
  12. type: 'image',
  13. attachment: { attachmentId: `sha256:${'a'.repeat(64)}` as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
  14. }
  15. function input(session: Session, content: ContentBlock[] = [image, image]) {
  16. return session.append('user/message', createUserMessage({ content, source: { kind: 'user' } }), { surfaceOp: 'append' })
  17. }
  18. function marked(session: Session): boolean[] {
  19. const result: boolean[] = []
  20. const visit = (content: readonly ContentBlock[]): void => {
  21. for (const block of content) {
  22. if (block.type === 'image') result.push(block.offloaded === true)
  23. if (block.type === 'tool-result') visit(block.content)
  24. }
  25. }
  26. for (const message of session.deriveMessages()) visit(message.content)
  27. return result
  28. }
  29. describe('durable image selections', () => {
  30. it('preserves message identity, original data, and previously derived snapshots', () => {
  31. const session = createSession(SessionId('images'))
  32. const source = input(session, [
  33. { type: 'text', text: 'before' }, image,
  34. { type: 'tool-result', toolCallId: ToolCallId('nested'), content: [image, { type: 'text', text: 'after' }] },
  35. { type: 'tool-result', toolCallId: ToolCallId('text'), content: [{ type: 'text', text: 'unchanged' }] },
  36. image,
  37. ])
  38. const before = session.deriveMessages()
  39. session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [1] }] })
  40. expect(marked(session)).toEqual([false, true, false])
  41. const after = session.deriveMessages()
  42. expect(after[0]?.id).toBe(before[0]?.id)
  43. expect(after[0]).toBe(session.deriveEventMessage(source))
  44. expect(Object.isFrozen(after[0])).toBe(true)
  45. expect(Object.isFrozen(after[0]?.content)).toBe(true)
  46. expect(after[0]?.content[0]).toBe(before[0]?.content[0])
  47. expect(after[0]?.content[3]).toBe(before[0]?.content[3])
  48. expect(JSON.stringify(source)).not.toContain('offloaded')
  49. expect(JSON.stringify(before)).not.toContain('offloaded')
  50. expect(session.surface.nodes).toEqual([source.seq])
  51. expect(session.surface.replaceGeneration).toBe(0)
  52. expect(session.surface.contentGeneration).toBe(1)
  53. expect(session.deriveEventMessage(session.snapshotEvents().at(-1)!)).toBeNull()
  54. session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0, 2] }] })
  55. expect(marked(session)).toEqual([true, true, true])
  56. expect(session.surface.contentGeneration).toBe(2)
  57. expect(after[0]).not.toBe(session.deriveMessages()[0])
  58. })
  59. it('reconstructs the same selections through pure folding, resume, restore, and fork', () => {
  60. const session = createSession(SessionId('source'))
  61. const source = input(session)
  62. const before = session.snapshotEvents()
  63. session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] })
  64. const events = session.snapshotEvents()
  65. const fold = foldSurface(events, [imageOffloadProjection])
  66. expect(deriveEventMessage(source, fold.projectedMessages)).toEqual(session.deriveMessages()[0])
  67. expect(marked(createSession(SessionId('before'), before))).toEqual([false, false])
  68. expect(marked(createSession(SessionId('resume'), events))).toEqual([true, false])
  69. const restored = Session.fromRestore(session.id, events, session.header, SessionLogOffset(0), 'shared-frozen', [imageOffloadProjection])
  70. expect(marked(restored)).toEqual([true, false])
  71. const childId = SessionId('child')
  72. const child = createSession(childId, events, {
  73. ...session.header, id: childId, parentSession: session.id, isSeeded: true,
  74. }, SessionLogOffset(events.length))
  75. expect(marked(child)).toEqual([true, false])
  76. input(child, [image])
  77. expect(marked(child)).toEqual([true, false, false])
  78. expect(marked(session)).toEqual([true, false])
  79. })
  80. it('counts all images in a tool result without changing its call identity', () => {
  81. const session = createSession(SessionId('tool'))
  82. const source = session.append('tool/result', {
  83. turn: 1, step: 1,
  84. message: createToolResultMessage({ callId: ToolCallId('shot'), isError: false, content: [image, image] }),
  85. }, { surfaceOp: 'append' })
  86. session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [1] }] })
  87. expect(marked(session)).toEqual([false, true])
  88. expect(session.deriveEventMessage(source)?.source).toEqual(source.data.message.source)
  89. })
  90. it.each([
  91. null, {}, { targets: [] }, { targets: 'bad' }, { targets: [null] },
  92. { targets: [{ seq: 0, imageIndexes: [] }] },
  93. { targets: [{ seq: 0, imageIndexes: [0], extra: true }] },
  94. { targets: [{ seq: 0, imageIndexes: [0] }], extra: true },
  95. { targets: [{ seq: -1, imageIndexes: [0] }] },
  96. { targets: [{ seq: 0, imageIndexes: [-1] }] },
  97. { targets: [{ seq: 0, imageIndexes: [0.5] }] },
  98. { targets: [{ seq: 0, imageIndexes: ['0'] }] },
  99. { targets: [{ seq: 0, imageIndexes: [1, 0] }] },
  100. { targets: [{ seq: 0, imageIndexes: [0, 0] }] },
  101. { targets: [{ seq: 0, imageIndexes: [2] }] },
  102. { targets: [{ seq: 1, imageIndexes: [0] }] },
  103. { targets: [{ seq: 0, imageIndexes: [0] }, { seq: 0, imageIndexes: [1] }] },
  104. ])('rejects malformed selections at append and replay without partial application: %j', (data) => {
  105. const session = createSession(SessionId('invalid'))
  106. input(session)
  107. const events = session.snapshotEvents()
  108. const candidate = { type: 'image/offload', seq: SessionSeq(1), time: 0, data } as SessionEvent
  109. expect(() => session.append('image/offload', data as SessionEventMap['image/offload'])).toThrow(/image\/offload/)
  110. expect(() => createSession(SessionId('seed'), [...events, candidate])).toThrow(/image\/offload/)
  111. expect(() => foldSurface([...events, candidate], [imageOffloadProjection])).toThrow(/image\/offload/)
  112. expect(session.snapshotEvents()).toEqual(events)
  113. expect(marked(session)).toEqual([false, false])
  114. expect(session.surface.contentGeneration).toBe(0)
  115. session.append('image/offload', { targets: [{ seq: SessionSeq(0), imageIndexes: [1] }] })
  116. expect(marked(session)).toEqual([false, true])
  117. })
  118. it('rejects repeated offloads and shadowed or assistant targets', () => {
  119. const session = createSession(SessionId('invalid-targets'))
  120. const source = input(session)
  121. session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] })
  122. expect(() => session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] })).toThrow(/already offloaded/)
  123. session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' } }), {
  124. surfaceOp: { op: 'replace', startSeq: source.seq, endSeq: source.seq }, sourceEventSeqs: [source.seq],
  125. })
  126. expect(() => session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [1] }] })).toThrow(/not a current/)
  127. const assistant = session.append('assistant/message', {
  128. turn: 1, step: 1, stream: [],
  129. message: createAssistantMessage({ content: [image], source: { provider: 'mock', model: 'mock' } }),
  130. }, { surfaceOp: 'append' })
  131. expect(() => session.append('image/offload', { targets: [{ seq: assistant.seq, imageIndexes: [0] }] })).toThrow(/must be user\/message/)
  132. })
  133. })