assembler.spec.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import { describe, expect, it } from 'vitest'
  2. import { BlockAssembler, CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
  3. describe('BlockAssembler', () => {
  4. it('assembles interleaved text, reasoning, and tool-call deltas', () => {
  5. const chunks: StreamChunk[] = [
  6. { type: 'block-start', index: 0, blockType: 'reasoning' },
  7. { type: 'reasoning-delta', index: 0, text: 'thinking…' },
  8. { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking…' } },
  9. { type: 'block-start', index: 1, blockType: 'text' },
  10. { type: 'text-delta', index: 1, text: 'Hello' },
  11. { type: 'text-delta', index: 1, text: ' world' },
  12. { type: 'block-start', index: 2, blockType: 'tool-call' },
  13. { type: 'tool-call-delta', index: 2, id: CallId('call-1'), name: 'echo', argumentsDelta: '{"text":' },
  14. { type: 'tool-call-delta', index: 2, id: CallId('call-1'), argumentsDelta: '"hi"}' },
  15. { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
  16. { type: 'finish', reason: { kind: 'tool-calls' } },
  17. ]
  18. const assembler = new BlockAssembler()
  19. for (const chunk of chunks) assembler.push(chunk)
  20. expect(assembler.blocks()).toEqual([
  21. { type: 'reasoning', text: 'thinking…' },
  22. { type: 'text', text: 'Hello world' },
  23. { type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{"text":"hi"}' },
  24. ])
  25. expect(assembler.usage).toEqual({ inputTokens: 10, outputTokens: 5 })
  26. expect(assembler.finish).toEqual({ kind: 'tool-calls' })
  27. expect(assembler.message().role).toBe('assistant')
  28. })
  29. it('returns the completed block from push() on block-end', () => {
  30. const assembler = new BlockAssembler()
  31. expect(assembler.push({ type: 'block-start', index: 0, blockType: 'text' })).toBeUndefined()
  32. expect(assembler.push({ type: 'text-delta', index: 0, text: 'hi' })).toBeUndefined()
  33. const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } })
  34. expect(block).toEqual({ type: 'text', text: 'hi' })
  35. })
  36. it('tolerates deltas without explicit block-start/end', () => {
  37. const assembler = new BlockAssembler()
  38. assembler.push({ type: 'text-delta', index: 0, text: 'implicit' })
  39. expect(assembler.blocks()).toEqual([{ type: 'text', text: 'implicit' }])
  40. expect(assembler.finish).toEqual({ kind: 'stop' })
  41. })
  42. it('returns undefined usage when no usage chunk was received', () => {
  43. const assembler = new BlockAssembler()
  44. assembler.push({ type: 'text-delta', index: 0, text: 'no usage' })
  45. expect(assembler.usage).toBeUndefined()
  46. })
  47. it('reuses an existing partial when ensure() is called with a tracked index', () => {
  48. const assembler = new BlockAssembler()
  49. // block-start creates the partial; block-end calls ensure() on the same index
  50. assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
  51. // push a delta first to guarantee the partial exists
  52. assembler.push({ type: 'text-delta', index: 0, text: 'hi' })
  53. // block-end's ensure() must find the existing partial (the second branch path)
  54. const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } })
  55. expect(block).toEqual({ type: 'text', text: 'hi' })
  56. })
  57. it('throws from assemble() when a partial has an unhandled blockType', () => {
  58. const assembler = new BlockAssembler()
  59. // Directly push a block-end for an image block whose block-start never
  60. // called ensure — but the image block-type flows through normally.
  61. // What we really need is a partial whose blockType is not text/reasoning/tool-call.
  62. // We can achieve this via a block-start for 'image' followed by blocks().
  63. assembler.push({ type: 'block-start', index: 0, blockType: 'image' } as unknown as StreamChunk)
  64. expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "image"')
  65. })
  66. it('mustGet throws when an index is missing from the partials map (invariant violation)', () => {
  67. const assembler = new BlockAssembler()
  68. // Force the invariant violation: manually corrupt the data structures.
  69. /* eslint-disable */
  70. const hack = assembler as any
  71. hack.order.push(99)
  72. /* eslint-enable */
  73. expect(() => assembler.blocks()).toThrow('BlockAssembler invariant violated')
  74. })
  75. it('assembles open blocks at end of stream via flushRemaining', () => {
  76. const assembler = new BlockAssembler()
  77. assembler.push({ type: 'text-delta', index: 0, text: 'open' })
  78. assembler.push({ type: 'reasoning-delta', index: 1, text: 'thinking' })
  79. // flushReady returns nothing because index 0 is incomplete and blocking
  80. const ready = assembler.flushReady()
  81. expect(ready).toEqual([])
  82. // flushRemaining assembles everything still open
  83. const remaining = assembler.flushRemaining()
  84. expect(remaining).toEqual([
  85. { type: 'text', text: 'open' },
  86. { type: 'reasoning', text: 'thinking' },
  87. ])
  88. // blocks() now matches the flushed view
  89. expect(assembler.blocks()).toEqual(remaining)
  90. })
  91. it('result() omits usage key when no usage was received', () => {
  92. const assembler = new BlockAssembler()
  93. assembler.push({ type: 'text-delta', index: 0, text: 'msg' })
  94. const result = assembler.result()
  95. expect(result.message).toBeDefined()
  96. expect(result.finish).toEqual({ kind: 'stop' })
  97. // usage should NOT be present on the object at all
  98. expect('usage' in result).toBe(false)
  99. })
  100. it('ignores duplicate block-start for the same index', () => {
  101. const assembler = new BlockAssembler()
  102. assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
  103. assembler.push({ type: 'text-delta', index: 0, text: 'one' })
  104. // duplicate block-start — should be no-op (false branch of has check)
  105. assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
  106. assembler.push({ type: 'text-delta', index: 0, text: ' two' })
  107. assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'one two' } })
  108. expect(assembler.blocks()).toEqual([{ type: 'text', text: 'one two' }])
  109. })
  110. it('ignores tool-call-delta stragglers after block-end', () => {
  111. const assembler = new BlockAssembler()
  112. assembler.push({ type: 'block-start', index: 0, blockType: 'tool-call' })
  113. assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'echo', argumentsDelta: '{}' })
  114. assembler.push({ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' } })
  115. // straggler after block-end — partial.block is set, so early return
  116. assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'evil', argumentsDelta: 'oops' })
  117. expect(assembler.blocks()).toEqual([{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }])
  118. })
  119. it('assembles tool-call with generated id fallback when no id provided', () => {
  120. const assembler = new BlockAssembler()
  121. assembler.push({ type: 'tool-call-delta', index: 0, argumentsDelta: '{}' } as StreamChunk)
  122. // No id and no name provided — uses fallback id `call-{index}` and empty name
  123. const blocks = assembler.blocks()
  124. expect(blocks).toEqual([
  125. { type: 'tool-call', id: CallId('call-0'), name: '', arguments: '{}' },
  126. ])
  127. })
  128. it('includes usage in result() when usage was received', () => {
  129. const assembler = new BlockAssembler()
  130. assembler.push({ type: 'text-delta', index: 0, text: 'msg' })
  131. assembler.push({ type: 'usage', usage: { inputTokens: 5, outputTokens: 3 } })
  132. const result = assembler.result()
  133. expect(result.usage).toEqual({ inputTokens: 5, outputTokens: 3 })
  134. expect('usage' in result).toBe(true)
  135. })
  136. })
  137. describe('assertNever', () => {
  138. it('throws with diagnostics when a value escapes a closed union at runtime', async () => {
  139. const { assertNever } = await import('@deepseek-ai/dsh-llm')
  140. expect(() => assertNever({ type: 'rogue' } as never, 'test-context'))
  141. .toThrow('unreachable variant in test-context: {"type":"rogue"}')
  142. expect(() => assertNever(undefined as never)).toThrow('unreachable variant: undefined')
  143. })
  144. it('BlockAssembler.push rejects chunks outside the closed StreamChunk union', () => {
  145. const assembler = new BlockAssembler()
  146. expect(() => assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk))
  147. .toThrow('unreachable variant in BlockAssembler.push')
  148. })
  149. })
  150. describe('BlockAssembler regressions (property-test findings)', () => {
  151. it('first block-end wins: a duplicate block-end for a closed index is ignored', () => {
  152. // Found by fast-check (the property-testing RFC): two block-ends at the same index made the
  153. // streamed prefix (first block) disagree with final blocks() (second
  154. // block). The first close must win — same straggler rule as post-close
  155. // deltas — so streaming and one-shot assembly stay identical.
  156. const chunks: StreamChunk[] = [
  157. { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } },
  158. { type: 'block-end', index: 0, block: { type: 'text', text: 'second' } },
  159. ]
  160. const streaming = new BlockAssembler()
  161. const flushed = []
  162. for (const chunk of chunks) {
  163. streaming.push(chunk)
  164. flushed.push(...streaming.flushReady())
  165. }
  166. flushed.push(...streaming.flushRemaining())
  167. const oneShot = new BlockAssembler()
  168. for (const chunk of chunks) oneShot.push(chunk)
  169. expect(flushed).toEqual([{ type: 'reasoning', text: 'first' }])
  170. expect(oneShot.blocks()).toEqual([{ type: 'reasoning', text: 'first' }])
  171. expect(flushed).toEqual(oneShot.blocks())
  172. })
  173. it('push returns undefined for a duplicate block-end (it closed nothing)', () => {
  174. const a = new BlockAssembler()
  175. expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'x' } }))
  176. .toEqual({ type: 'text', text: 'x' })
  177. expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'y' } }))
  178. .toBeUndefined()
  179. })
  180. })