partial.client.spec.ts 4.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. /**
  2. * PartialAccumulator: six-variant chunk folding, sparse-index compaction, and
  3. * the block/snapshot reference discipline (a delta swaps only that block).
  4. */
  5. import { describe, expect, it } from 'vitest'
  6. import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client'
  7. import { PartialAccumulator } from '../src/client/conversation-nodes/partial.ts'
  8. const chunk = (c: Record<string, unknown>): StreamChunk => c as unknown as StreamChunk
  9. describe('PartialAccumulator', () => {
  10. it('builds empty blocks per block-start type, unknown type falls to other', () => {
  11. const acc = new PartialAccumulator(1, 0)
  12. acc.push(chunk({ type: 'block-start', index: 0, blockType: 'text' }))
  13. acc.push(chunk({ type: 'block-start', index: 1, blockType: 'reasoning' }))
  14. acc.push(chunk({ type: 'block-start', index: 2, blockType: 'tool-call' }))
  15. acc.push(chunk({ type: 'block-start', index: 3, blockType: 'no-such' }))
  16. expect(acc.toPartial().blocks).toEqual([
  17. { kind: 'text', text: '' },
  18. { kind: 'reasoning', text: '' },
  19. { kind: 'tool-call', callId: '', name: '', argsRaw: '' },
  20. { kind: 'other', block: null },
  21. ])
  22. })
  23. it('accumulates text deltas, starting from empty when prev is missing or another kind', () => {
  24. const acc = new PartialAccumulator(1, 0)
  25. acc.push(chunk({ type: 'text-delta', index: 0, text: '无 start ' })) // prev missing
  26. acc.push(chunk({ type: 'text-delta', index: 0, text: '也累积' }))
  27. expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '无 start 也累积' }])
  28. acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '换型重起' })) // prev is text → restart
  29. expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '换型重起' }])
  30. })
  31. it('accumulates reasoning deltas on the reasoning lane', () => {
  32. const acc = new PartialAccumulator(1, 0)
  33. acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
  34. acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '思' }))
  35. acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '考' }))
  36. expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
  37. })
  38. it('continues from a materialized history prefix', () => {
  39. const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }])
  40. acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' }))
  41. expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }])
  42. })
  43. it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
  44. const acc = new PartialAccumulator(1, 0)
  45. acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))
  46. acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c2-late', name: 'echo', argumentsDelta: ':1}' }))
  47. expect(acc.toPartial().blocks).toEqual([
  48. { kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{"a":1}' },
  49. ])
  50. })
  51. it('replaces the accumulated block wholesale on block-end', () => {
  52. const acc = new PartialAccumulator(1, 0)
  53. acc.push(chunk({ type: 'text-delta', index: 0, text: '中间态' }))
  54. acc.push(chunk({ type: 'block-end', index: 0, block: { type: 'text', text: '定稿全文' } }))
  55. expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '定稿全文' }])
  56. })
  57. it('returns false (no notification) for usage/finish/unknown variants and keeps blocks', () => {
  58. const acc = new PartialAccumulator(1, 0)
  59. acc.push(chunk({ type: 'text-delta', index: 0, text: 'x' }))
  60. const before = acc.toPartial()
  61. expect(acc.push(chunk({ type: 'usage', usage: {} }))).toBe(false)
  62. expect(acc.push(chunk({ type: 'finish', reason: 'stop' }))).toBe(false)
  63. expect(acc.push(chunk({ type: 'future-variant' }))).toBe(false)
  64. expect(acc.toPartial()).toBe(before) // unchanged: same snapshot reference
  65. })
  66. it('compacts sparse indexes into a dense render-order array', () => {
  67. const acc = new PartialAccumulator(1, 0)
  68. acc.push(chunk({ type: 'block-start', index: 2, blockType: 'text' }))
  69. acc.push(chunk({ type: 'text-delta', index: 2, text: '先到的高位' }))
  70. acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
  71. const { blocks } = acc.toPartial()
  72. expect(blocks).toHaveLength(2) // no undefined holes
  73. expect(blocks[0]).toEqual({ kind: 'reasoning', text: '' })
  74. expect(blocks[1]).toEqual({ kind: 'text', text: '先到的高位' })
  75. })
  76. it('keeps the snapshot reference stable without changes and swaps it once per mutation', () => {
  77. const acc = new PartialAccumulator(3, 1)
  78. const first = acc.toPartial()
  79. expect(first).toMatchObject({ turn: 3, step: 1, blocks: [] })
  80. expect(acc.toPartial()).toBe(first)
  81. acc.push(chunk({ type: 'text-delta', index: 0, text: 'a' }))
  82. const second = acc.toPartial()
  83. expect(second).not.toBe(first)
  84. expect(acc.toPartial()).toBe(second)
  85. })
  86. })