Merge branch 'codex/simp-unify-agent-session-id' into codex/simp-ui-identity-residue

This commit is contained in:
Tianyi Cui
2026-07-19 02:39:09 +08:00
28 changed files with 93 additions and 92 deletions
+1 -1
View File
@@ -396,7 +396,7 @@ export interface DeepSeekCatalogModel {
}
```
Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:33`](../packages/llm/llm-deepseek/src/index.ts)
## `@deepseek-ai/dsh-llm-pi-ai`
+1 -1
View File
@@ -146,7 +146,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:96`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:94`](../../packages/llm/llm/src/index.ts)
## `ctx.permission` — `PermissionService`
@@ -20,7 +20,7 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe
## Consequences
- Generator quality is the value lever — the generators bias toward small index pools and short strings so collisions and interleavings are common.
- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index overwrote an already-flushed block, so the streamed prefix disagreed with final `blocks()`. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test.
- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index rewrote a completed block. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test.
- A property flake from a timeout is a finding, not something to retry away. The loop properties are deterministic by construction (settle on `agent/status`), so a hang is a real defect.
- Property tests supplement, not replace, the example tests that pin specific branches for the 100%-coverage gate.
@@ -28,7 +28,7 @@ Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk`
```
{ kind: 'chunks', chunks: StreamChunk[] }
| { kind: 'throw', chunks: StreamChunk[], message: string, code: string, status?: number }
| { kind: 'throw', chunks: StreamChunk[], message: string, code: string }
| { kind: 'hang' }
```
@@ -6,7 +6,7 @@ Status: implemented
Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios.
Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario.
Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario.
## Decision
@@ -1,3 +1,3 @@
[
{ "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH", "status": 401 }
{ "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH" }
]
@@ -175,9 +175,13 @@ describe('cordis_mount', () => {
// The registered schema is canonical JSON Schema derived from the DSL:
// the required array survived, integer became number, extra is optional.
const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')!
const parameters = schema.parameters as { properties: Record<string, { type: string; enum?: string[] }>; required?: string[] }
const parameters = schema.parameters as {
properties: Record<string, { type: string; enum?: string[]; default?: unknown }>
required?: string[]
}
expect(parameters.required).toEqual(['text'])
expect(parameters.properties.count!.type).toBe('number')
expect(parameters.properties.count!.default).toBe(1)
expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow'])
// Arg validation enforces the normalized spec: text required, extra not.
expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true)
+1 -1
View File
@@ -154,7 +154,7 @@ The available tools:
- **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own.
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and never applies `default` (`XXX(unused-default)` flags removing that field); raw-registered JSON-Schema tools validate their own input.
- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and preserves `default` as a model-visible JSON Schema annotation without applying it during validation; dynamic Cordis mounts may supply defaults even though first-party definitions do not, while raw-registered JSON-Schema tools validate their own input.
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
- **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[<type> content]` placeholders.
+2 -6
View File
@@ -21,12 +21,8 @@ export interface SchemaProp {
/** Enum of allowed values (strings only). */
enum?: string[]
/**
* Default value, emitted into the JSON Schema only (validation never applies
* it — see the validator note below).
*
* XXX(unused-default): no tool definition in the repo sets `default`; it rides
* into the wire schema for a model that no tool surfaces it to. Drop the field
* and its converter line unless a real tool needs a model-visible default.
* Model-visible JSON Schema default annotation. Validation does not apply it;
* dynamic tool mounts may supply it even though first-party definitions do not.
*/
default?: unknown
/** Nested properties for type: 'object'. */
+2
View File
@@ -4,6 +4,8 @@ DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch`
A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design.
The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire serialization, SSE parsing, and chunk translation helpers are not part of that root contract.
## Config
```yaml
+3 -3
View File
@@ -98,10 +98,10 @@ export class DeepSeekAdapter extends LlmAdapter {
const parsed = await response.json() as WireError
if (parsed.error?.message) message = parsed.error.message
} catch {
// Only swallow error-body parsing: status and code are already captured,
// so malformed gateway JSON must not mask the actionable HTTP failure.
// Only swallow error-body parsing: the stable code and status-line message
// are already captured, so malformed gateway JSON must not mask the failure.
}
throw new LlmError(message, code, response.status)
throw new LlmError(message, code)
}
if (!response.body) {
throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE')
+1 -4
View File
@@ -11,12 +11,9 @@ import type {} from '@deepseek-ai/dsh-llm'
import { DeepSeekAdapter } from './adapter.ts'
import type { DeepSeekCatalogModel } from './adapter.ts'
export { DeepSeekAdapter, httpErrorCode } from './adapter.ts'
export { DeepSeekAdapter } from './adapter.ts'
export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts'
export { serializeMessages, serializeRequest } from './serialize.ts'
export type { RequestDefaults } from './serialize.ts'
export { DONE, parseSse } from './sse.ts'
export { mapFinishReason, mapUsage, translate } from './translate.ts'
export type * from './types.ts'
export const name = 'llm-deepseek'
@@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
import { httpErrorCode } from '../src/adapter.ts'
import { assemble } from './assemble.ts'
/** One scripted behavior for the next request the mock server receives. */
@@ -159,7 +160,7 @@ describe('DeepSeekAdapter against a mock server', () => {
status,
body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }),
}
const server = await mockServer([behavior, behavior, behavior])
const server = await mockServer([behavior, behavior])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(`failed with ${status}`)
@@ -167,11 +168,6 @@ describe('DeepSeekAdapter against a mock server', () => {
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(
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).status),
).resolves.toBe(status)
})
it('keeps the status-line message for JSON error bodies without a message', async () => {
@@ -241,6 +237,19 @@ describe('DeepSeekAdapter against a mock server', () => {
})
describe('plugin registration and config', () => {
it('keeps wire helpers off the package root', () => {
for (const helper of [
'httpErrorCode',
'serializeMessages',
'serializeRequest',
'DONE',
'parseSse',
'mapFinishReason',
'mapUsage',
'translate',
]) expect(LlmDeepSeek).not.toHaveProperty(helper)
})
it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => {
const server = await mockServer([])
const ctx = new Context()
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek'
import { serializeMessages, serializeRequest } from '../src/serialize.ts'
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
return { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], ...overrides }
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { LlmError } from '@deepseek-ai/dsh-llm'
import { DONE, parseSse } from '@deepseek-ai/dsh-llm-deepseek'
import { DONE, parseSse } from '../src/sse.ts'
/** Build a byte stream from string fragments (fragments = network reads). */
async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator<Uint8Array> {
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { DONE, mapFinishReason, mapUsage, translate } from '@deepseek-ai/dsh-llm-deepseek'
import { DONE } from '../src/sse.ts'
import { mapFinishReason, mapUsage, translate } from '../src/translate.ts'
async function* feed(...payloads: (string | object)[]): AsyncGenerator<string> {
for (const payload of payloads) {
+3 -1
View File
@@ -2,6 +2,8 @@
Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns an explicit list of provider profiles; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog.
The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal.
## Config
Configure credentials and deployment-specific transport settings per provider. Omitting `apiKey` delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported.
@@ -76,4 +78,4 @@ Unit tests use pi-ai catalog models redirected to local mock servers and cover p
- **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint.
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
- **`LlmError.status` is unavailable for in-stream failures** — pi-ai error events do not expose a stable HTTP status across providers.
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes.
+1 -5
View File
@@ -27,12 +27,8 @@ import { Config, resolveProfiles } from './config.ts'
export { PiAiAdapter } from './adapter.ts'
export type { PiAiAdapterOptions } from './adapter.ts'
export { Config, resolveProfiles } from './config.ts'
export { Config } from './config.ts'
export type { PiAiProviderProfile } from './config.ts'
export { toPiContext } from './context.ts'
export { toPiReplayState } from './replay.ts'
export type { PiAiReplayState } from './replay.ts'
export { mapStopReason, mapUsage, toStreamChunks } from './stream.ts'
export const name = 'llm-pi-ai'
export const inject = ['llm']
+14 -1
View File
@@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { PiAiAdapter, resolveProfiles } from '@deepseek-ai/dsh-llm-pi-ai'
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { resolveProfiles } from '../src/config.ts'
import { assemble } from './assemble.ts'
interface MockServer {
@@ -180,6 +181,18 @@ describe('PiAiAdapter provider routing', () => {
})
describe('provider profile lifecycle', () => {
it('keeps adapter helpers off the package root', () => {
for (const helper of [
'resolveProfiles',
'toPiContext',
'toPiReplayState',
'toPiAssistant',
'mapStopReason',
'mapUsage',
'toStreamChunks',
]) expect(LlmPiAi).not.toHaveProperty(helper)
})
it('registers every profile atomically and unregisters on dispose', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
+3 -1
View File
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
import { mapStopReason, mapUsage, toPiContext, toPiReplayState, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai'
import { toPiContext } from '../src/context.ts'
import { toPiReplayState } from '../src/replay.ts'
import { mapStopReason, mapUsage, toStreamChunks } from '../src/stream.ts'
function usage(input = 0, output = 0, cacheRead = 0, cacheWrite = 0): Usage {
return {
+1 -1
View File
@@ -45,7 +45,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history.
- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams.
- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response.
- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract.
### Real adapters
+3 -5
View File
@@ -39,12 +39,10 @@ export class BlockAssembler {
private _replayState: unknown = undefined
/**
* Feed one chunk. Returns the completed block when the chunk closes one
* (an explicit `block-end`), otherwise undefined.
* Feed one chunk into the assembly state.
* @param chunk - the next raw chunk, in stream order.
* @returns the authoritative block from the first `block-end` at its index; undefined for every other chunk.
*/
push(chunk: StreamChunk): ContentBlock | undefined {
push(chunk: StreamChunk): void {
switch (chunk.type) {
case 'block-start': {
if (!this.partials.has(chunk.index)) {
@@ -78,7 +76,7 @@ export class BlockAssembler {
// and the final assembled block in agreement.
if (partial.block) return
partial.block = chunk.block
return chunk.block
return
}
case 'usage': {
this._usage = chunk.usage
+2 -4
View File
@@ -43,12 +43,10 @@ declare module 'cordis' {
/**
* Typed error for LLM-related failures. Extends {@link HarnessError}, so the
* `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy;
* `status` carries the HTTP status when the error originated from a non-2xx
* provider response (absent for protocol/usage errors that have no HTTP status).
* `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy.
*/
export class LlmError extends HarnessError {
constructor(message: string, code: string, public status?: number, options?: ErrorOptions) {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'LlmError'
}
+11 -29
View File
@@ -29,12 +29,12 @@ describe('BlockAssembler', () => {
expect(assembler.message().role).toBe('assistant')
})
it('returns the completed block from push() on block-end', () => {
it('records the completed block from 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' })
assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
assembler.push({ type: 'text-delta', index: 0, text: 'hi' })
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } })
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'hi' }])
})
it('tolerates deltas without explicit block-start/end', () => {
@@ -57,8 +57,8 @@ describe('BlockAssembler', () => {
// 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' })
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } })
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'hi' }])
})
it('throws from assemble() when a partial has an unhandled blockType', () => {
@@ -128,7 +128,7 @@ describe('assertNever', () => {
it('BlockAssembler.push rejects chunks outside the closed StreamChunk union', () => {
const assembler = new BlockAssembler()
expect(() => assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk))
expect(() => { assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk) })
.toThrow('unreachable variant in BlockAssembler.push')
})
})
@@ -140,26 +140,8 @@ describe('BlockAssembler duplicate-close contract', () => {
{ 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()
const assembler = new BlockAssembler()
for (const chunk of chunks) assembler.push(chunk)
expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }])
})
})
+3 -2
View File
@@ -255,11 +255,12 @@ describe('LlmService', () => {
it('LlmError extends the shared HarnessError base', async () => {
const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm')
const err = new LlmError('boom', 'AUTH', 401)
const cause = new Error('root cause')
const err = new LlmError('boom', 'AUTH', { cause })
expect(err).toBeInstanceOf(HarnessError)
expect(isHarnessError(err)).toBe(true)
expect(err.code).toBe('AUTH')
expect(err.status).toBe(401)
expect(err.cause).toBe(cause)
})
it('HarnessError carries a code, names itself by subclass, and chains cause', async () => {
+2 -2
View File
@@ -20,7 +20,7 @@ import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm'
*/
export type ReplayEntry =
| { kind: 'chunks'; chunks: StreamChunk[] }
| { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; status?: number }
| { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string }
| { kind: 'hang' }
/** One model exposed by a replay-only provider catalog. */
@@ -283,7 +283,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
if (signal?.aborted) throw new Error('aborted')
yield chunk
}
throw new LlmError(entry.message, entry.code, entry.status)
throw new LlmError(entry.message, entry.code)
case 'hang':
// Replay a stream that stalls until cancelled (mirrors MockAdapter): one
// chunk, then wait for abort and surface it as the consumer expects.
@@ -175,7 +175,7 @@ describe('loadReplayScript', () => {
it('uses the sidecar override when present, ignoring the JSONL', () => {
writeFileSync(file, sessionJsonl([]), 'utf8')
const overrideFile = join(dir, 'replay.override.json')
const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH', status: 401 }]
const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH' }]
writeFileSync(overrideFile, JSON.stringify(override), 'utf8')
expect(loadReplayScript({ file, overrideFile })).toEqual(override)
})
@@ -265,12 +265,12 @@ describe('installLlmReplay (through the real LlmService)', () => {
expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(second)
})
it('replays a sidecar throw-entry as an LlmError with code/status, after its prefix chunks', async () => {
it('replays a sidecar throw-entry as an LlmError with its stable code, after its prefix chunks', async () => {
writeFileSync(file, sessionJsonl([]), 'utf8')
const overrideFile = join(dir, 'replay.override.json')
const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }]
writeFileSync(overrideFile, JSON.stringify([
{ kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 },
{ kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' },
]), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -279,7 +279,7 @@ describe('installLlmReplay (through the real LlmService)', () => {
const seen: StreamChunk[] = []
await expect((async () => {
for await (const c of ctx.llm.stream({ provider: 'm', model: 'm', messages: [] })) seen.push(c)
})()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH', status: 401 })
})()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH' })
expect(seen).toEqual(partial)
})
@@ -384,7 +384,7 @@ describe('installLlmReplay (through the real LlmService)', () => {
const overrideFile = join(dir, 'replay.override.json')
const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }]
writeFileSync(overrideFile, JSON.stringify([
{ kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 },
{ kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' },
]), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
+5 -5
View File
@@ -6,7 +6,7 @@
The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L96)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L94)
### ctx.llm.registerAdapter(providers, adapter)
@@ -21,7 +21,7 @@ Register an adapter for the given provider routes. Throws `LlmError` with code `
**Returns** the disposer that unregisters all of them.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L111)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L109)
### ctx.llm.listProviders()
@@ -33,7 +33,7 @@ Describe provider routes with a registered adapter.
**Returns** detached provider metadata in registration order.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L142)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L140)
### ctx.llm.listModels(provider)
@@ -47,7 +47,7 @@ Discover models advertised by one registered provider. Catalog membership is adv
**Returns** detached model metadata in adapter-preferred order.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L152)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L150)
### ctx.llm.stream(options)
@@ -61,4 +61,4 @@ Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with
**Returns** the chunk stream, possibly wrapped by `llm/stream` listeners.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L210)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L208)