diff --git a/AGENTS.md b/AGENTS.md index 2860b8701f..d3403b0768 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,13 @@ only needed for publishing/consumption outside the repo. - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits. This is the veto mechanism — use deliberately. +- **Switch exhaustiveness**: switches over CLOSED unions (e.g. `StreamChunk`) + end with `default: assertNever(value, 'context')` (from dsh-llm) so adding a + variant breaks compilation at every switch that must handle it. Switches + over MERGE-EXTENSIBLE unions (`SessionEventMap`, `ContentBlockMap`, …) must + NOT use assertNever — plugin-added variants are valid unknown values; handle + known cases and fall through with a comment (the lint rule + `switch-exhaustiveness-check` makes the choice explicit either way). - **Plugins, not loop changes**: new behavior goes into a plugin on the documented extension seams (see the plugin sanity checklist in docs/architecture.md). Changing `agent-loop` requires updating that doc. diff --git a/packages/llm/src/assembler.ts b/packages/llm/src/assembler.ts index a75b9a7418..e18071d7e9 100644 --- a/packages/llm/src/assembler.ts +++ b/packages/llm/src/assembler.ts @@ -6,6 +6,7 @@ */ import { CallId } from './brand.ts' +import { assertNever } from './never.ts' import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types.ts' interface PartialBlock { @@ -82,6 +83,7 @@ export class BlockAssembler { this._finish = chunk.reason return } + default: return assertNever(chunk, 'BlockAssembler.push') } } diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 4158853d84..2606ae239c 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -11,6 +11,7 @@ import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from import { BlockAssembler } from './assembler.ts' export * from './brand.ts' +export * from './never.ts' export * from './types.ts' export { BlockAssembler } from './assembler.ts' diff --git a/packages/llm/src/never.ts b/packages/llm/src/never.ts new file mode 100644 index 0000000000..8eed137f15 --- /dev/null +++ b/packages/llm/src/never.ts @@ -0,0 +1,35 @@ +/** + * Exhaustiveness helper for switches over core unions. + * + * # When to use which pattern + * + * **Closed unions** (every variant is known at compile time in the consuming + * code — e.g. `StreamChunk` inside the assembler, `FiberState`-like enums): + * end the switch with `default: assertNever(value)`. Adding a variant then + * fails compilation at every switch that must handle it — the error appears + * exactly where work is needed. + * + * **Merge-extensible unions** (plugins add variants via declaration merging — + * `SessionEventMap`, `ContentBlockMap`, `MessageSourceMap`, …): do NOT use + * assertNever. From the core's view the union is open; plugin-added variants + * are valid values the core has never heard of. Handle the known cases and + * fall through intentionally, with a comment saying the switch is + * deliberately non-exhaustive (see `Session.deriveMessages`). The lint rule + * `switch-exhaustiveness-check` enforces that the choice is explicit either + * way. + * + * @module @deepseek-ai/dsh-llm/never + */ + +/** + * Marks unreachable code on a closed union. If this is reachable, either a + * variant was added without updating the switch (compile error at the call + * site — the desired outcome) or a value escaped its type (runtime throw + * with diagnostics — the safety net). + */ +export function assertNever(value: never, context?: string): never { + // JSON.stringify is typed string but returns undefined for undefined input; + // String() covers that and other non-serializable escapes. + const rendered = (JSON.stringify(value) as string | undefined) ?? String(value) + throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`) +} diff --git a/packages/llm/tests/assembler.spec.ts b/packages/llm/tests/assembler.spec.ts index db8cb32820..e02e547e99 100644 --- a/packages/llm/tests/assembler.spec.ts +++ b/packages/llm/tests/assembler.spec.ts @@ -151,3 +151,18 @@ describe('BlockAssembler', () => { 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') + }) +})