ImageBlock had no production producer and every consumer dropped it: the deepseek serializer skipped it, the pi-ai converter skipped it as unrepresentable, the ACP bridge neither advertises image prompt capability nor forwards image blocks, and compact-basic charged a flat 85-token estimate and rendered an [image] placeholder. A block constructed today would silently vanish from the wire — the vocabulary advertised a capability no path honors, the silent-data-loss shape the defensive patterns warn against. The only constructors were tests pinning the skip/estimate branches. Remove ImageBlock and its ContentBlockMap entry (its cache?: CacheHint field leaves with it; CacheHint itself and the other two cache? fields are out of scope). compact-basic loses its explicit image estimate and placeholder arms (the merge-extensible default arms absorb the case); the deepseek serializer, pi-ai converter, and ACP codec already handled image in their default arms, so only their image-naming comments change. The codec's inbound rejection of ACP-protocol image prompt content stays — that guards wire content a client can send regardless of our vocabulary. Tests that constructed harness image blocks to pin the removed branches are dropped (the 85-token estimate pin) or retargeted onto plugin-added block types / other non-text blocks, which the surviving default arms own. Docs, the type-equiv pastes, and the content-block vocabulary RFC's block list and multimodal-home consequence are updated in the same change; the RFC moves to implemented/ and the index is regenerated. A real multimodal feature reintroduces image via declaration merging together with the adapter mapping, ACP advertisement, and compaction pricing that honor it.
172 lines
8.6 KiB
TypeScript
172 lines
8.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { BlockAssembler, CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
|
|
describe('BlockAssembler', () => {
|
|
it('assembles interleaved text, reasoning, and tool-call deltas', () => {
|
|
const chunks: StreamChunk[] = [
|
|
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
|
{ type: 'reasoning-delta', index: 0, text: 'thinking…' },
|
|
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking…' } },
|
|
{ type: 'block-start', index: 1, blockType: 'text' },
|
|
{ type: 'text-delta', index: 1, text: 'Hello' },
|
|
{ type: 'text-delta', index: 1, text: ' world' },
|
|
{ type: 'block-start', index: 2, blockType: 'tool-call' },
|
|
{ type: 'tool-call-delta', index: 2, id: CallId('call-1'), name: 'echo', argumentsDelta: '{"text":' },
|
|
{ type: 'tool-call-delta', index: 2, id: CallId('call-1'), argumentsDelta: '"hi"}' },
|
|
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
|
|
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
|
]
|
|
const assembler = new BlockAssembler()
|
|
for (const chunk of chunks) assembler.push(chunk)
|
|
|
|
expect(assembler.blocks()).toEqual([
|
|
{ type: 'reasoning', text: 'thinking…' },
|
|
{ type: 'text', text: 'Hello world' },
|
|
{ type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{"text":"hi"}' },
|
|
])
|
|
expect(assembler.usage).toEqual({ inputTokens: 10, outputTokens: 5 })
|
|
expect(assembler.finish).toEqual({ kind: 'tool-calls' })
|
|
expect(assembler.message().role).toBe('assistant')
|
|
})
|
|
|
|
it('returns the completed block from push() on block-end', () => {
|
|
const assembler = new BlockAssembler()
|
|
expect(assembler.push({ type: 'block-start', index: 0, blockType: 'text' })).toBeUndefined()
|
|
expect(assembler.push({ type: 'text-delta', index: 0, text: 'hi' })).toBeUndefined()
|
|
const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } })
|
|
expect(block).toEqual({ type: 'text', text: 'hi' })
|
|
})
|
|
|
|
it('tolerates deltas without explicit block-start/end', () => {
|
|
const assembler = new BlockAssembler()
|
|
assembler.push({ type: 'text-delta', index: 0, text: 'implicit' })
|
|
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'implicit' }])
|
|
expect(assembler.finish).toEqual({ kind: 'stop' })
|
|
})
|
|
|
|
it('returns undefined usage when no usage chunk was received', () => {
|
|
const assembler = new BlockAssembler()
|
|
assembler.push({ type: 'text-delta', index: 0, text: 'no usage' })
|
|
expect(assembler.usage).toBeUndefined()
|
|
})
|
|
|
|
it('reuses an existing partial when ensure() is called with a tracked index', () => {
|
|
const assembler = new BlockAssembler()
|
|
// block-start creates the partial; block-end calls ensure() on the same index
|
|
assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
|
|
// push a delta first to guarantee the partial exists
|
|
assembler.push({ type: 'text-delta', index: 0, text: 'hi' })
|
|
// block-end's ensure() must find the existing partial (the second branch path)
|
|
const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } })
|
|
expect(block).toEqual({ type: 'text', text: 'hi' })
|
|
})
|
|
|
|
it('throws from assemble() when a partial has an unhandled blockType', () => {
|
|
const assembler = new BlockAssembler()
|
|
// A partial whose blockType is not text/reasoning/tool-call cannot be
|
|
// assembled without its block-end. A plugin-added block type (here
|
|
// 'video', via the merge-extensible ContentBlockMap) opened by a
|
|
// block-start with no closing block-end exercises that throw.
|
|
assembler.push({ type: 'block-start', index: 0, blockType: 'video' } as unknown as StreamChunk)
|
|
expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "video"')
|
|
})
|
|
|
|
it('mustGet throws when an index is missing from the partials map (invariant violation)', () => {
|
|
const assembler = new BlockAssembler()
|
|
// Force the invariant violation: manually corrupt the data structures.
|
|
/* eslint-disable */
|
|
const hack = assembler as any
|
|
hack.order.push(99)
|
|
/* eslint-enable */
|
|
expect(() => assembler.blocks()).toThrow('BlockAssembler invariant violated')
|
|
})
|
|
|
|
it('ignores duplicate block-start for the same index', () => {
|
|
const assembler = new BlockAssembler()
|
|
assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
|
|
assembler.push({ type: 'text-delta', index: 0, text: 'one' })
|
|
// duplicate block-start — should be no-op (false branch of has check)
|
|
assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
|
|
assembler.push({ type: 'text-delta', index: 0, text: ' two' })
|
|
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'one two' } })
|
|
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'one two' }])
|
|
})
|
|
|
|
it('ignores tool-call-delta stragglers after block-end', () => {
|
|
const assembler = new BlockAssembler()
|
|
assembler.push({ type: 'block-start', index: 0, blockType: 'tool-call' })
|
|
assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'echo', argumentsDelta: '{}' })
|
|
assembler.push({ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' } })
|
|
// straggler after block-end — partial.block is set, so early return
|
|
assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'evil', argumentsDelta: 'oops' })
|
|
expect(assembler.blocks()).toEqual([{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }])
|
|
})
|
|
|
|
it('assembles tool-call with generated id fallback when no id provided', () => {
|
|
const assembler = new BlockAssembler()
|
|
assembler.push({ type: 'tool-call-delta', index: 0, argumentsDelta: '{}' } as StreamChunk)
|
|
// No id and no name provided — uses fallback id `call-{index}` and empty name
|
|
const blocks = assembler.blocks()
|
|
expect(blocks).toEqual([
|
|
{ type: 'tool-call', id: CallId('call-0'), name: '', arguments: '{}' },
|
|
])
|
|
})
|
|
|
|
it('exposes usage via the getter when a usage chunk was received', () => {
|
|
const assembler = new BlockAssembler()
|
|
assembler.push({ type: 'text-delta', index: 0, text: 'msg' })
|
|
assembler.push({ type: 'usage', usage: { inputTokens: 5, outputTokens: 3 } })
|
|
expect(assembler.usage).toEqual({ inputTokens: 5, outputTokens: 3 })
|
|
})
|
|
})
|
|
|
|
describe('assertNever', () => {
|
|
it('throws with diagnostics when a value escapes a closed union at runtime', async () => {
|
|
const { assertNever } = await import('@deepseek-ai/dsh-llm')
|
|
expect(() => assertNever({ type: 'rogue' } as never, 'test-context'))
|
|
.toThrow('unreachable variant in test-context: {"type":"rogue"}')
|
|
expect(() => assertNever(undefined as never)).toThrow('unreachable variant: undefined')
|
|
})
|
|
|
|
it('BlockAssembler.push rejects chunks outside the closed StreamChunk union', () => {
|
|
const assembler = new BlockAssembler()
|
|
expect(() => assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk))
|
|
.toThrow('unreachable variant in BlockAssembler.push')
|
|
})
|
|
})
|
|
|
|
describe('BlockAssembler regressions (property-test findings)', () => {
|
|
it('first block-end wins: a duplicate block-end for a closed index is ignored', () => {
|
|
// Found by fast-check (the property-testing RFC): two block-ends at the same index made the
|
|
// streamed prefix (first block) disagree with final blocks() (second
|
|
// block). The first close must win — same straggler rule as post-close
|
|
// deltas — so the prefix returned incrementally by push() and the final
|
|
// blocks() stay identical.
|
|
const chunks: StreamChunk[] = [
|
|
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } },
|
|
{ type: 'block-end', index: 0, block: { type: 'text', text: 'second' } },
|
|
]
|
|
const streaming = new BlockAssembler()
|
|
const closed = []
|
|
for (const chunk of chunks) {
|
|
const block = streaming.push(chunk)
|
|
if (block) closed.push(block)
|
|
}
|
|
|
|
const oneShot = new BlockAssembler()
|
|
for (const chunk of chunks) oneShot.push(chunk)
|
|
|
|
expect(closed).toEqual([{ type: 'reasoning', text: 'first' }])
|
|
expect(oneShot.blocks()).toEqual([{ type: 'reasoning', text: 'first' }])
|
|
expect(closed).toEqual(oneShot.blocks())
|
|
})
|
|
|
|
it('push returns undefined for a duplicate block-end (it closed nothing)', () => {
|
|
const a = new BlockAssembler()
|
|
expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'x' } }))
|
|
.toEqual({ type: 'text', text: 'x' })
|
|
expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'y' } }))
|
|
.toBeUndefined()
|
|
})
|
|
})
|