Reorganize packages into a modular hierarchy
Move the 18 flat packages/<name> packages into role-grouped dirs: core/, llm/, bash/, session-persistence/, ui/, support/. Group dirs are pure containers; each package keeps its @deepseek-ai/dsh-* name. Collapse the per-package tsconfig paths maps (base + typecheck) into one @deepseek-ai/dsh-* wildcard with a candidate per group, and derive the publint list from the hierarchy. Update all depth-coupled globs/configs (workspace, tsdown, vitest, eslint, knip, tsconfig includes/refs, per-package tsconfigs, generators, doc-script scopes, type-equiv manifest) and the cross-package/script relative imports in tests. Fix doc-typecheck's workspacePaths() to parse tsconfig JSONC via the TypeScript API instead of a regex comment-strip, which corrupted the new wildcard `/*/` path candidates. WIP: doc cross-links and package/RFC docs still to update.
This commit is contained in:
203
packages/llm/llm/tests/assembler.spec.ts
Normal file
203
packages/llm/llm/tests/assembler.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
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()
|
||||
// Directly push a block-end for an image block whose block-start never
|
||||
// called ensure — but the image block-type flows through normally.
|
||||
// What we really need is a partial whose blockType is not text/reasoning/tool-call.
|
||||
// We can achieve this via a block-start for 'image' followed by blocks().
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'image' } as unknown as StreamChunk)
|
||||
expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "image"')
|
||||
})
|
||||
|
||||
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('assembles open blocks at end of stream via flushRemaining', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'text-delta', index: 0, text: 'open' })
|
||||
assembler.push({ type: 'reasoning-delta', index: 1, text: 'thinking' })
|
||||
|
||||
// flushReady returns nothing because index 0 is incomplete and blocking
|
||||
const ready = assembler.flushReady()
|
||||
expect(ready).toEqual([])
|
||||
|
||||
// flushRemaining assembles everything still open
|
||||
const remaining = assembler.flushRemaining()
|
||||
expect(remaining).toEqual([
|
||||
{ type: 'text', text: 'open' },
|
||||
{ type: 'reasoning', text: 'thinking' },
|
||||
])
|
||||
|
||||
// blocks() now matches the flushed view
|
||||
expect(assembler.blocks()).toEqual(remaining)
|
||||
})
|
||||
|
||||
it('result() omits usage key when no usage was received', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'text-delta', index: 0, text: 'msg' })
|
||||
const result = assembler.result()
|
||||
expect(result.message).toBeDefined()
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
// usage should NOT be present on the object at all
|
||||
expect('usage' in result).toBe(false)
|
||||
})
|
||||
|
||||
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('includes usage in result() when usage was received', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'text-delta', index: 0, text: 'msg' })
|
||||
assembler.push({ type: 'usage', usage: { inputTokens: 5, outputTokens: 3 } })
|
||||
const result = assembler.result()
|
||||
expect(result.usage).toEqual({ inputTokens: 5, outputTokens: 3 })
|
||||
expect('usage' in result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
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 streaming and one-shot assembly 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 flushed = []
|
||||
for (const chunk of chunks) {
|
||||
streaming.push(chunk)
|
||||
flushed.push(...streaming.flushReady())
|
||||
}
|
||||
flushed.push(...streaming.flushRemaining())
|
||||
|
||||
const oneShot = new BlockAssembler()
|
||||
for (const chunk of chunks) oneShot.push(chunk)
|
||||
|
||||
expect(flushed).toEqual([{ type: 'reasoning', text: 'first' }])
|
||||
expect(oneShot.blocks()).toEqual([{ type: 'reasoning', text: 'first' }])
|
||||
expect(flushed).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()
|
||||
})
|
||||
})
|
||||
150
packages/llm/llm/tests/properties.spec.ts
Normal file
150
packages/llm/llm/tests/properties.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Property-based tests for the BlockAssembler (the property-testing RFC).
|
||||
*
|
||||
* The assembler is protocol-shaped: arbitrary interleavings of block-start,
|
||||
* deltas, block-end, usage, and finish — valid and malformed (duplicate
|
||||
* indices, stragglers after block-end, missing block-start, delta-only). The
|
||||
* invariants below are the contract the agent loop and LlmService rely on.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
// A small pool of indices so collisions (duplicate-index bugs) are common.
|
||||
const indexArb = fc.integer({ min: 0, max: 4 })
|
||||
|
||||
const blockEndArb = (index: number): fc.Arbitrary<StreamChunk> => fc.oneof(
|
||||
fc.record({ text: fc.string() }).map((r): StreamChunk => (
|
||||
{ type: 'block-end', index, block: { type: 'text', text: r.text } }
|
||||
)),
|
||||
fc.record({ text: fc.string() }).map((r): StreamChunk => (
|
||||
{ type: 'block-end', index, block: { type: 'reasoning', text: r.text } }
|
||||
)),
|
||||
fc.record({ id: fc.string({ minLength: 1 }), name: fc.string(), args: fc.string() }).map((r): StreamChunk => (
|
||||
{ type: 'block-end', index, block: { type: 'tool-call', id: CallId(r.id), name: r.name, arguments: r.args } }
|
||||
)),
|
||||
)
|
||||
|
||||
/** One arbitrary chunk over the small index pool — valid and malformed mixes. */
|
||||
const chunkArb: fc.Arbitrary<StreamChunk> = indexArb.chain(index => fc.oneof(
|
||||
fc.constant<StreamChunk>({ type: 'block-start', index, blockType: 'text' }),
|
||||
fc.constant<StreamChunk>({ type: 'block-start', index, blockType: 'reasoning' }),
|
||||
fc.constant<StreamChunk>({ type: 'block-start', index, blockType: 'tool-call' }),
|
||||
fc.string().map((text): StreamChunk => ({ type: 'text-delta', index, text })),
|
||||
fc.string().map((text): StreamChunk => ({ type: 'reasoning-delta', index, text })),
|
||||
fc.record({ id: fc.string({ minLength: 1 }), argumentsDelta: fc.string() })
|
||||
.map((r): StreamChunk => ({ type: 'tool-call-delta', index, id: CallId(r.id), argumentsDelta: r.argumentsDelta })),
|
||||
blockEndArb(index),
|
||||
fc.constant<StreamChunk>({ type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }),
|
||||
fc.constant<StreamChunk>({ type: 'finish', reason: { kind: 'stop' } }),
|
||||
fc.constant<StreamChunk>({ type: 'finish', reason: { kind: 'tool-calls' } }),
|
||||
fc.string().map((message): StreamChunk => ({ type: 'finish', reason: { kind: 'error', message } })),
|
||||
))
|
||||
|
||||
/** A stream is an arbitrary list of chunks (we do NOT force a terminal finish). */
|
||||
const streamArb = fc.array(chunkArb, { maxLength: 30 })
|
||||
|
||||
/** Feed a fresh assembler, return it. */
|
||||
function feed(chunks: StreamChunk[]): BlockAssembler {
|
||||
const a = new BlockAssembler()
|
||||
for (const chunk of chunks) a.push(chunk)
|
||||
return a
|
||||
}
|
||||
|
||||
describe('BlockAssembler properties', () => {
|
||||
it('flushReady() ++ flushRemaining() === blocks(), in order', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const streaming = new BlockAssembler()
|
||||
const flushed: ContentBlock[] = []
|
||||
for (const chunk of chunks) {
|
||||
streaming.push(chunk)
|
||||
flushed.push(...streaming.flushReady())
|
||||
}
|
||||
flushed.push(...streaming.flushRemaining())
|
||||
|
||||
const oneShot = feed(chunks).blocks()
|
||||
expect(flushed).toEqual(oneShot)
|
||||
}))
|
||||
})
|
||||
|
||||
it('streamBlocks-style flush never yields a block before an earlier open one', () => {
|
||||
// flushReady is strict-order: once it stops at an open index, no later
|
||||
// index may be emitted until that one closes. We assert the flushed prefix
|
||||
// is always a prefix of the final blocks() order.
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const streaming = new BlockAssembler()
|
||||
const flushed: ContentBlock[] = []
|
||||
for (const chunk of chunks) {
|
||||
streaming.push(chunk)
|
||||
flushed.push(...streaming.flushReady())
|
||||
}
|
||||
const finalSoFar = streaming.blocks()
|
||||
// Everything flushed mid-stream is a prefix of the full ordered blocks.
|
||||
expect(finalSoFar.slice(0, flushed.length)).toEqual(flushed)
|
||||
}))
|
||||
})
|
||||
|
||||
it('partials map size never exceeds the number of distinct indices seen', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const distinct = new Set<number>()
|
||||
for (const chunk of chunks) {
|
||||
if ('index' in chunk) distinct.add(chunk.index)
|
||||
}
|
||||
const a = feed(chunks)
|
||||
// blocks() length equals the number of distinct indices that became
|
||||
// partials (block-bearing chunks). It can never exceed distinct indices.
|
||||
expect(a.blocks().length).toBeLessThanOrEqual(distinct.size)
|
||||
}))
|
||||
})
|
||||
|
||||
it('re-assembly is idempotent: blocks() is stable across repeated calls', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const a = feed(chunks)
|
||||
expect(a.blocks()).toEqual(a.blocks())
|
||||
// And message().content mirrors blocks().
|
||||
expect(a.message().content).toEqual(a.blocks())
|
||||
}))
|
||||
})
|
||||
|
||||
it('blocks() never throws and yields only valid content-block tags', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const blocks = feed(chunks).blocks()
|
||||
for (const block of blocks) {
|
||||
expect(['text', 'reasoning', 'tool-call', 'tool-result', 'image']).toContain(block.type)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
it('finish reflects the last finish chunk, or defaults to stop when none arrives', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const a = feed(chunks)
|
||||
const finishes = chunks.filter(c => c.type === 'finish')
|
||||
if (finishes.length === 0) {
|
||||
expect(a.finish).toEqual({ kind: 'stop' })
|
||||
} else {
|
||||
// last-write-wins: the assembler keeps the most recent finish reason.
|
||||
const last = finishes[finishes.length - 1]
|
||||
if (last?.type === 'finish') expect(a.finish).toEqual(last.reason)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
it('streaming and one-shot assembly agree on usage and finish', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
// Streaming consumer: push + flush as it goes.
|
||||
const streaming = new BlockAssembler()
|
||||
for (const chunk of chunks) {
|
||||
streaming.push(chunk)
|
||||
streaming.flushReady()
|
||||
}
|
||||
streaming.flushRemaining()
|
||||
// One-shot consumer: push all, then read.
|
||||
const oneShot = feed(chunks)
|
||||
expect(streaming.usage).toEqual(oneShot.usage)
|
||||
expect(streaming.finish).toEqual(oneShot.finish)
|
||||
}))
|
||||
})
|
||||
})
|
||||
171
packages/llm/llm/tests/service.spec.ts
Normal file
171
packages/llm/llm/tests/service.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { GenerateOptions, LlmAdapter, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
class ScriptedAdapter extends LlmAdapter {
|
||||
constructor(private script: StreamChunk[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
yield * this.script
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'hi' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
describe('LlmService', () => {
|
||||
it('routes stream() to the registered adapter and generate() assembles it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
|
||||
|
||||
const chunks: StreamChunk[] = []
|
||||
for await (const chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) chunks.push(chunk)
|
||||
expect(chunks).toHaveLength(3)
|
||||
|
||||
const result = await ctx.llm.generate({ model: 'test-model', messages: [] })
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
})
|
||||
|
||||
it('throws NO_ADAPTER for unregistered models', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.llm.generate({ model: 'nope', messages: [] })).rejects.toThrow('no adapter registered')
|
||||
})
|
||||
|
||||
it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.llm.registerAdapter(['scoped-model'], new ScriptedAdapter(SCRIPT))
|
||||
}, { inject: ['llm'] }))
|
||||
expect(ctx.llm.models()).toEqual(['scoped-model'])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
|
||||
it('lets llm/stream waterfall listeners wrap the underlying stream', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
|
||||
|
||||
ctx.on('llm/stream', function (_options, next) {
|
||||
const inner = next()
|
||||
return (async function * () {
|
||||
yield { type: 'block-start', index: 99, blockType: 'text' } satisfies StreamChunk
|
||||
yield * inner
|
||||
})()
|
||||
})
|
||||
|
||||
const chunks: StreamChunk[] = []
|
||||
for await (const chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) chunks.push(chunk)
|
||||
expect(chunks).toHaveLength(4)
|
||||
expect(chunks[0]).toMatchObject({ index: 99 })
|
||||
})
|
||||
|
||||
it('lets llm/generate waterfall listeners intercept and transform the result', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
|
||||
|
||||
ctx.on('llm/generate', async function (_options, next) {
|
||||
const result = await next()
|
||||
return { ...result, finish: { kind: 'max-tokens' } as const }
|
||||
})
|
||||
|
||||
const result = await ctx.llm.generate({ model: 'test-model', messages: [] })
|
||||
expect(result.finish).toEqual({ kind: 'max-tokens' })
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
|
||||
})
|
||||
|
||||
it('creates LlmError with a code for programmatic handling', () => {
|
||||
const err = new LlmError('something went wrong', 'CUSTOM_CODE')
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.name).toBe('LlmError')
|
||||
expect(err.message).toBe('something went wrong')
|
||||
expect(err.code).toBe('CUSTOM_CODE')
|
||||
})
|
||||
|
||||
it('LlmError extends the shared HarnessError base', async () => {
|
||||
const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const err = new LlmError('boom', 'AUTH', 401)
|
||||
expect(err).toBeInstanceOf(HarnessError)
|
||||
expect(isHarnessError(err)).toBe(true)
|
||||
expect(err.code).toBe('AUTH')
|
||||
expect(err.status).toBe(401)
|
||||
})
|
||||
|
||||
it('HarnessError carries a code, names itself by subclass, and chains cause', async () => {
|
||||
const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const root = new Error('root cause')
|
||||
const err = new HarnessError('wrapper', 'UNKNOWN', { cause: root })
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.name).toBe('HarnessError')
|
||||
expect(err.code).toBe('UNKNOWN')
|
||||
expect(err.cause).toBe(root)
|
||||
expect(isHarnessError(err)).toBe(true)
|
||||
expect(isHarnessError(root)).toBe(false)
|
||||
expect(isHarnessError('nope')).toBe(false)
|
||||
})
|
||||
|
||||
it('disposes adapter registration on adapter-change event emission', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const changes: string[][] = []
|
||||
ctx.on('llm/adapter-change', () => {
|
||||
changes.push([...ctx.llm.models()])
|
||||
})
|
||||
|
||||
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(changes).toEqual([['m1']])
|
||||
|
||||
dispose()
|
||||
expect(changes).toEqual([['m1'], []])
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate adapter registration with DUPLICATE_ADAPTER code', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
try {
|
||||
ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect.fail('expected error')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(LlmError)
|
||||
expect((error as LlmError).message).toContain('already registered')
|
||||
expect((error as LlmError).code).toBe('DUPLICATE_ADAPTER')
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back the adapter entry when an adapter-change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
// A change listener that throws on the FIRST emit only.
|
||||
let threw = false
|
||||
ctx.on('llm/adapter-change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
// The throwing emit must roll the mutation back, not leak it.
|
||||
expect(() => ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))).toThrow('boom change listener')
|
||||
expect(ctx.llm.models()).toEqual([]) // entry rolled back, not leaked
|
||||
|
||||
// A subsequent listener-free register of the SAME model succeeds and
|
||||
// contributes exactly once (the duplicate check is not wedged).
|
||||
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.models()).toEqual(['m1'])
|
||||
dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user