simplify(llm): drop unconsumed adapter-change event and assembled call surfaces

The LLM service exposed three call surfaces (stream/streamBlocks/generate) but
the only production consumer — the agent loop — uses stream() exclusively,
feeding raw chunks through its own BlockAssembler for replay fidelity. Drop the
speculative convenience surfaces and the registry-change event that no listener
consumed, leaving stream() as the single model-call contract for both
production and tests.

- Remove LlmService.streamBlocks() and generate(), the llm/generate waterfall,
  and GenerateResult.
- Remove the llm/adapter-change event (declaration + emits) and the
  listener-throw rollback ordering that existed only to protect it; keep the
  HMR rollback disposer.
- Remove BlockAssembler.flushReady()/flushRemaining()/result() and the flushed
  cursor — the streaming-flush slice existed only for streamBlocks().
- Adapter tests drive a stream()+BlockAssembler helper (tests/assemble.ts)
  instead of generate(), exercising the same path production uses.
- Land the AGENTS.md "RFCs are proposals, not golden truth" principle and move
  both RFCs proposed -> implemented.

Implements:
- docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md
- docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md
This commit is contained in:
Tianyi Cui
2026-06-21 01:27:41 +08:00
parent 18f1c010ca
commit 30cd67b8a1
26 changed files with 164 additions and 428 deletions

View File

@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import type { GenerateResult, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
import { assemble, type AssembledResult } from './assemble.ts'
/**
* Real-API e2e for the hand-rolled adapter: V4 Flash + V4 Pro across
@@ -31,7 +32,7 @@ function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
}
function textOf(result: GenerateResult): string {
function textOf(result: AssembledResult): string {
return result.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
@@ -51,7 +52,7 @@ const weatherTool: ToolSchema = {
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
it('flash + thinking disabled: plain text generation', async () => {
const ctx = await harness(FLASH, { thinking: 'disabled' })
const result = await ctx.llm.generate({
const result = await assemble(ctx,{
model: FLASH,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
@@ -65,7 +66,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
it('flash + thinking enabled (effort high): reasoning blocks + reasoning tokens', async () => {
const ctx = await harness(FLASH, { thinking: 'enabled', reasoningEffort: 'high' })
const result = await ctx.llm.generate({
const result = await assemble(ctx,{
model: FLASH,
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
maxTokens: 2000,
@@ -82,7 +83,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
const ctx = await harness(PRO, { thinking: 'enabled', reasoningEffort: effort })
// Turn 1: the model must call the tool (and think before it).
const first = await ctx.llm.generate({
const first = await assemble(ctx,{
model: PRO,
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
tools: [weatherTool],
@@ -96,7 +97,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
// Turn 2: send the tool result back WITH the assistant's reasoning
// block in history (the official thinking+tools passback rule).
const second = await ctx.llm.generate({
const second = await assemble(ctx,{
model: PRO,
messages: [
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
@@ -120,7 +121,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
it('pro + thinking disabled: plain generation without reasoning blocks', async () => {
const ctx = await harness(PRO, { thinking: 'disabled' })
const result = await ctx.llm.generate({
const result = await assemble(ctx,{
model: PRO,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,

View File

@@ -5,6 +5,7 @@ import { Context } from 'cordis'
import LlmService, { LlmError } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
import { assemble } from './assemble.ts'
/** One scripted behavior for the next request the mock server receives. */
type Behavior =
@@ -90,11 +91,11 @@ async function harness(baseURL: string, config: object = {}) {
}
describe('DeepSeekAdapter against a mock server', () => {
it('streams a text generation end to end through ctx.llm.generate', async () => {
it('streams a text generation end to end through the assembler', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url)
const result = await ctx.llm.generate({
const result = await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
@@ -130,7 +131,7 @@ describe('DeepSeekAdapter against a mock server', () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })
await ctx.llm.generate({
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
@@ -155,15 +156,15 @@ describe('DeepSeekAdapter against a mock server', () => {
}
const server = await mockServer([behavior, behavior, behavior])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(`failed with ${status}`)
await expect(
ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).code),
).resolves.toBe(code)
// The numeric HTTP status is carried on the error for explicit handling.
await expect(
ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).status),
).resolves.toBe(status)
})
@@ -171,14 +172,14 @@ describe('DeepSeekAdapter against a mock server', () => {
it('keeps the status-line message for JSON error bodies without a message', async () => {
const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 500/)
})
it('keeps the status-line message for non-JSON error bodies', async () => {
const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 502/)
})
@@ -207,7 +208,7 @@ describe('DeepSeekAdapter against a mock server', () => {
events: ['{"choices":[{"delta":{"content":"par"}}]}'],
}])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/terminated|socket|without \[DONE\]/)
})
@@ -278,7 +279,7 @@ describe('plugin registration and config', () => {
vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url) // harness passes explicit config
await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1) // hit the explicit URL, not env
})
@@ -288,7 +289,7 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', models: ['deepseek-v4-flash'] })
await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1)
})

View File

@@ -0,0 +1,26 @@
/**
* Test helper: drive `ctx.llm.stream()` through a `BlockAssembler` and return
* the assembled message + usage + finish reason. This exercises the same
* streaming path production uses (the loop), rather than a service-level
* one-shot convenience method.
*/
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
export interface AssembledResult {
message: Message
usage?: TokenUsage
finish: FinishReason
}
export async function assemble(ctx: Context, options: GenerateOptions): Promise<AssembledResult> {
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
return {
message: assembler.message(),
...assembler.usage !== undefined ? { usage: assembler.usage } : {},
finish: assembler.finish,
}
}

View File

@@ -47,7 +47,7 @@ describe('translate: text', () => {
))) {
assembler.push(chunk)
}
const result = assembler.result()
const result = { message: assembler.message(), finish: assembler.finish }
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
expect(result.finish).toEqual({ kind: 'stop' })
})