Merge remote-tracking branch 'origin/master' into worktree/llm-reasoning-effort

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/config-catalog.md
This commit is contained in:
Yichen Jiang
2026-07-26 13:13:31 +08:00
338 changed files with 15268 additions and 3386 deletions

View File

@@ -8,6 +8,7 @@ Packages that exist to serve development, testing, and the examples rather than
| `agent-loop-testkit/` | Shared prerequisite mounting for tests that exercise the concrete agent loop | (library — imported by AgentLoop integration tests) |
| `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) |
| `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) |
| `llm-mock-server/` | Scriptable OpenAI-compatible HTTP/SSE fault server + CLI for LLM recovery tests | (standalone server and test library) |
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel real-Loader launch boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate, while `llm-mock-server` drives real provider adapters through deterministic HTTP/SSE faults. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel real-Loader launch boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.

View File

@@ -53,7 +53,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
## Model Experience

View File

@@ -11,11 +11,17 @@ const CWD = '{{cwd}}'
const SYSTEM = '{{system}}'
const TOOLS = '{{tools}}'
const MESSAGE_PREFIX = '{{messagePrefix}}'
const EVENT_TIME = '{{eventTime}}'
const EVENT_OMITTED_BYTES = '{{eventOmittedBytes}}'
/** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */
const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g
const PATH_TAG_RE = /(<path>)([^<]*)(<\/path>)/g
const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g
const EMBEDDED_EVENT_TIME_RE = /^( "time": )\d+(?=,\r?$)/gm
const EVENT_READ_OMITTED_BYTES_RE = /(\r?\n\r?\n\(Omitted )\d+( bytes\.)/g
const EVENT_READ_TARGET_REGION_RE
= /^Session [^\r\n]+ — [^\r\n]+\r?\nTarget event seq \d+:\r?\n```json\r?\n\{\r?\n[\s\S]*?(?=\r?\n```(?:\r?\n|$)|\r?\n\r?\n\(Omitted )/
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
@@ -77,6 +83,16 @@ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathM
}
out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
// Exact event-read results render the target as pretty JSON inside a
// distinctive envelope. Restrict time scrubbing to that fenced target so
// neighbor, model, bash, and unrelated tool text remains regression-visible.
if (EVENT_READ_TARGET_REGION_RE.test(out)) {
out = out.replace(
EVENT_READ_TARGET_REGION_RE,
target => target.replace(EMBEDDED_EVENT_TIME_RE, `$1${EVENT_TIME}`),
)
out = out.replace(EVENT_READ_OMITTED_BYTES_RE, `$1${EVENT_OMITTED_BYTES}$2`)
}
for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID)
out = out.replace(UUID_RE, SESSION_ID)
return out

View File

@@ -123,6 +123,56 @@ Additional instructions from: nested\AGENTS.md`,
expect(out).not.toContain('"id"')
})
it('stabilizes only the top-level event timestamp and spill byte count in event-read text', () => {
const raw = JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
update: {
sessionUpdate: 'tool_call_update',
content: [{
type: 'content',
content: {
type: 'text',
text: 'Session prior — title\nTarget event seq 4:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {\n "time": 31337,\n "note": "model-visible"\n }\n}\n```\n\nAfter:\n "time": 424242,\n neighbor semantic text\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)',
},
}],
},
},
})
const out = normalizeStdout(raw, ctx)
expect(out).toContain('\\"time\\": {{eventTime}}')
expect(out).toContain('\\"time\\": 31337')
expect(out).toContain('\\"time\\": 424242')
expect(out).toContain('Omitted {{eventOmittedBytes}} bytes')
expect(out).not.toContain('1784876275593')
expect(out).not.toContain('39387')
})
it('preserves event-like timestamps in unrelated output text', () => {
const raw = JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
update: {
sessionUpdate: 'tool_call_update',
content: [{
type: 'content',
content: {
type: 'text',
text: 'bash output:\n```json\n{\n "time": 1784876275593,\n "data": {}\n}\n```\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)',
},
}],
},
},
})
const out = normalizeStdout(raw, ctx)
expect(out).toContain('1784876275593')
expect(out).toContain('39387')
expect(out).not.toContain('{{eventTime}}')
expect(out).not.toContain('{{eventOmittedBytes}}')
})
it('throws on a non-JSON stdout line (the purity check)', () => {
const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
expect(() => normalizeStdout(raw, ctx)).toThrow()

View File

@@ -0,0 +1,84 @@
# `@deepseek-ai/dsh-llm-mock-server`
A scriptable OpenAI-compatible HTTP/SSE server for exercising real LLM adapters, the agent loop, and recovery policy without a provider key. It accepts `POST /chat/completions` and `POST /v1/chat/completions`; each accepted request consumes one configured behavior in arrival order. Invalid methods, paths, bearer tokens, and JSON do not consume the script.
The library entry exports `startMockLlmServer(options)`, behavior and telemetry types, the default random stress weights, the accepted Node timer bound, and a running handle with the bound `baseURL`, generated or configured `randomSeed`, captured requests, and idempotent `close()`. Closing force-terminates stalled connections.
## Standalone use
Run the source entry from this repository:
```sh
pnpm run mock:llm -- \
--port 8000 \
--api-key mock-key \
--sequence partial_disconnect,success \
--partial-text "discard this half"
```
Point the shipping DeepSeek adapter at the server; it appends `/chat/completions` to the configured base:
```sh
DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \
DEEPSEEK_API_KEY=mock-key \
pnpm run demo:headless "test provider recovery"
```
The built package also exposes `dsh-llm-mock-server`. Stdout is JSONL: a `ready` record carries the `/v1` base URL and random seed, followed by request/result records that name both the scripted behavior and the concrete selected behavior.
## Behavior script
`--sequence` is a comma-separated FIFO. Exhaustion returns a structured HTTP 500; `--repeat-last` explicitly reuses the last entry.
| Behavior | Wire result |
|---|---|
| `connection_reset` | Destroy the socket before HTTP headers |
| `stream_disconnect` | Send SSE headers, then reset before the first event |
| `partial_disconnect` | Send text deltas, then reset the socket |
| `stall` | Send SSE headers and remain idle until client/server cancellation |
| `empty` | Send a valid content-less stop and `[DONE]` |
| `empty_body` / `stream_eof` / `partial_eof` | End cleanly without the required `[DONE]` boundary |
| `malformed_json` / `malformed_event` | Send invalid SSE JSON or an invalid provider chunk shape |
| `rate_limit` / `server_error` / `service_unavailable` | Return retry-oriented 429/500/503 JSON errors |
| `auth_error` / `invalid_request` / `context_overflow` / `quota_exceeded` | Return terminal or separately recovered provider errors |
| `success` / `slow_success` / `reasoning_success` | Stream a complete text response, optionally delayed or preceded by reasoning |
| `tool_call_success` / `max_tokens` | Complete with a tool call or `length` finish |
| `wrong_content_type` | Send a valid SSE body under `application/json` |
| `random` | Select a concrete request behavior from weighted seeded randomness |
`connection_refused` is CLI-only and must be the first entry. It delays binding a caller-specified nonzero port, so requests during `--listen-delay-ms` receive a real TCP refusal; the remaining entries begin after the listener starts.
## Random mode
Use a repeating `random` entry for an open-ended mixed run:
```sh
pnpm run mock:llm -- \
--port 8000 \
--sequence random \
--repeat-last \
--seed 42 \
--random-weights 'success=60,slow_success=10,connection_reset=5,stream_disconnect=5,partial_disconnect=10,empty=5,server_error=5'
```
Omitting `--seed` generates one and prints it in the `ready` record. `--random-weights` accepts non-negative relative `behavior=weight` entries and requires at least one positive concrete behavior. The exported default is a success-heavy stress profile containing reset, disconnect, partial output, empty completion, stall, 429/5xx, clean truncation, and malformed JSON; it is test pressure, not an estimate of production incident frequency. `connection_refused` is excluded because a bound request handler cannot produce a true refusal.
When random weights include `stall`, configure the client under test with a short stream-idle timeout so the scenario terminates promptly.
## Timing and content controls
The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. Millisecond delays are bounded integers within Node's timer range; `retryAfterMs` must also be positive. The library accepts the same camel-case options. An optional exact `apiKey` validates `Authorization: Bearer <token>`; omission accepts any token.
## Model Experience
None, as this test server substitutes provider wire behavior without invoking a real model.
#### KV Cache effect
None; requests terminate locally and never reach a provider cache.
## Known Limitations and Deferred Work
- **Random weights model test pressure, not production incidence** — callers that want an environment-specific distribution must provide measured weights and record the emitted seed.
- **Request scripts are arrival-ordered** — concurrent callers share one cursor, so deterministic per-session fault assignment requires separate server instances.
- **True connection refusal is a listener lifecycle phase** — the CLI delay must overlap the client attempt; request-level random selection can only reset an accepted connection.

View File

@@ -0,0 +1,45 @@
{
"name": "@deepseek-ai/dsh-llm-mock-server",
"description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"bin": {
"dsh-llm-mock-server": "lib/bin.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./bin": {
"types": "./lib/types/bin.d.ts",
"default": "./lib/bin.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/bin.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Standalone process wrapper for the scriptable mock LLM server.
* @module @deepseek-ai/dsh-llm-mock-server/bin
*/
import { setTimeout as delay } from 'node:timers/promises'
import { MOCK_LLM_CLI_USAGE, parseMockLlmCliArgs } from './cli.ts'
import { startMockLlmServer } from './index.ts'
/* v8 ignore start -- thin process/signal glue; parser and server behavior are covered directly */
try {
const parsed = parseMockLlmCliArgs(process.argv.slice(2))
if (parsed.kind === 'help') {
process.stdout.write(MOCK_LLM_CLI_USAGE)
} else {
const { server: serverOptions, listenDelayMs, startsUnavailable } = parsed.config
const host = serverOptions.host ?? '127.0.0.1'
const port = serverOptions.port ?? 8_000
if (startsUnavailable) {
process.stdout.write(`${JSON.stringify({
type: 'unavailable',
baseURL: `http://${host}:${port}/v1`,
listenDelayMs,
})}\n`)
await delay(listenDelayMs)
}
const server = await startMockLlmServer({
...serverOptions,
onEvent: (event) => { process.stdout.write(`${JSON.stringify(event)}\n`) },
})
process.stdout.write(`${JSON.stringify({
type: 'ready',
baseURL: `${server.baseURL}/v1`,
randomSeed: server.randomSeed,
})}\n`)
let closing = false
const close = (code: number): void => {
if (closing) return
closing = true
void server.close().finally(() => { process.exit(code) })
}
process.on('SIGINT', () => { close(130) })
process.on('SIGTERM', () => { close(143) })
}
} catch (error: unknown) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${MOCK_LLM_CLI_USAGE}`)
process.exitCode = 1
}
/* v8 ignore stop */

View File

@@ -0,0 +1,222 @@
/**
* Dependency-free CLI parsing for the standalone mock LLM server.
* @module @deepseek-ai/dsh-llm-mock-server/cli
*/
import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts'
import type {
ConcreteMockLlmBehavior,
MockLlmBehavior,
MockLlmRandomWeights,
MockLlmServerOptions,
} from './index.ts'
/** Listener lifecycle behavior understood only by the standalone CLI. */
export const CONNECTION_REFUSED_BEHAVIOR = 'connection_refused'
/** Parsed CLI configuration, including a pre-listen unavailable interval. */
export interface MockLlmCliConfig {
/** Server options after removing the lifecycle-only `connection_refused` entry. */
readonly server: MockLlmServerOptions
/** Delay before binding the model port; an integer from zero through the Node timer maximum. */
readonly listenDelayMs: number
/** Whether the original sequence requested a true pre-listen refusal phase. */
readonly startsUnavailable: boolean
}
/** Result of parsing `dsh-llm-mock-server` arguments. */
export type MockLlmCliParseResult =
| { readonly kind: 'help' }
| { readonly kind: 'run'; readonly config: MockLlmCliConfig }
const BEHAVIORS = new Set<string>(MOCK_LLM_BEHAVIORS)
const DEFAULT_LISTEN_DELAY_MS = 750
/** Command usage written for `--help` and invalid arguments. */
export const MOCK_LLM_CLI_USAGE = `Usage: dsh-llm-mock-server [options]
Required:
--sequence <a,b,...> Ordered behaviors; connection_refused is allowed first
Listener:
--host <host> Default 127.0.0.1
--port <port> Default 8000; required and nonzero for connection_refused
--api-key <token> Validate exact Bearer token when present
--listen-delay-ms <ms> Unavailable interval (default 750 with connection_refused)
--repeat-last Repeat the final request behavior after exhaustion
--seed <uint32> Reproduce random selections
--random-weights <a=n,...> Relative weights for concrete behaviors
Response:
--success-text <text>
--partial-text <text>
--reasoning-text <text>
--chunk-size <count>
--chunk-delay-ms <ms>
--disconnect-delay-ms <ms>
--retry-after-ms <ms>
--request-id <id>
--tool-name <name>
--tool-arguments <json>
Other:
--help
`
function optionValue(argv: readonly string[], index: number, option: string): string {
const value = argv[index + 1]
if (value === undefined || value.startsWith('--')) {
throw new Error(`dsh-llm-mock-server: ${option} requires a value`)
}
return value
}
function numberValue(option: string, value: string): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`)
return parsed
}
function boundedIntegerValue(option: string, value: string, min: number, max: number): number {
const parsed = numberValue(option, value)
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new Error(`dsh-llm-mock-server: ${option} must be an integer between ${min} and ${max}`)
}
return parsed
}
function parseSequence(raw: string): { startsUnavailable: boolean; sequence: MockLlmBehavior[] } {
const entries = raw.split(',').map(entry => entry.trim())
if (entries.some(entry => entry.length === 0)) {
throw new Error('dsh-llm-mock-server: --sequence must contain non-empty comma-separated behaviors')
}
const startsUnavailable = entries[0] === CONNECTION_REFUSED_BEHAVIOR
if (entries.slice(1).includes(CONNECTION_REFUSED_BEHAVIOR)) {
throw new Error('dsh-llm-mock-server: connection_refused is allowed only as the first behavior')
}
const requestEntries = startsUnavailable ? entries.slice(1) : entries
if (requestEntries.length === 0) {
throw new Error('dsh-llm-mock-server: connection_refused must be followed by a request behavior')
}
for (const entry of requestEntries) {
if (!BEHAVIORS.has(entry)) throw new Error(`dsh-llm-mock-server: unknown behavior ${JSON.stringify(entry)}`)
}
return { startsUnavailable, sequence: requestEntries as MockLlmBehavior[] }
}
function parseRandomWeights(raw: string): MockLlmRandomWeights {
const weights: MockLlmRandomWeights = {}
for (const entry of raw.split(',')) {
const [behavior, rawWeight, ...extra] = entry.split('=')
if (behavior === undefined || behavior === '' || rawWeight === undefined || rawWeight === '' || extra.length > 0) {
throw new Error('dsh-llm-mock-server: --random-weights expects behavior=weight comma-separated entries')
}
if (!BEHAVIORS.has(behavior) || behavior === 'random') {
throw new Error(`dsh-llm-mock-server: random weight requires a concrete behavior, got ${JSON.stringify(behavior)}`)
}
if (Object.hasOwn(weights, behavior)) {
throw new Error(`dsh-llm-mock-server: duplicate random weight for ${JSON.stringify(behavior)}`)
}
weights[behavior as ConcreteMockLlmBehavior] = numberValue('--random-weights', rawWeight)
}
return weights
}
/**
* Parse standalone server arguments without starting a process or listener.
* @param argv - arguments after the executable name.
* @returns help or validated run configuration.
*/
export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult {
if (argv.includes('--help')) return { kind: 'help' }
let sequenceRaw: string | undefined
let host: string | undefined
let port = 8_000
let apiKey: string | undefined
let listenDelayMs: number | undefined
let repeatLast = false
let randomSeed: number | undefined
let randomWeights: MockLlmRandomWeights | undefined
let successText: string | undefined
let partialText: string | undefined
let reasoningText: string | undefined
let chunkSize: number | undefined
let chunkDelayMs: number | undefined
let disconnectDelayMs: number | undefined
let retryAfterMs: number | undefined
let requestId: string | undefined
let toolName: string | undefined
let toolArguments: string | undefined
for (let index = 0; index < argv.length; index += 1) {
const option = argv[index] as string
if (option === '--repeat-last') {
repeatLast = true
continue
}
const value = optionValue(argv, index, option)
index += 1
switch (option) {
case '--sequence': sequenceRaw = value; break
case '--host': host = value; break
case '--port': port = numberValue(option, value); break
case '--api-key': apiKey = value; break
case '--listen-delay-ms':
listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
break
case '--seed': randomSeed = numberValue(option, value); break
case '--random-weights': randomWeights = parseRandomWeights(value); break
case '--success-text': successText = value; break
case '--partial-text': partialText = value; break
case '--reasoning-text': reasoningText = value; break
case '--chunk-size': chunkSize = numberValue(option, value); break
case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break
case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break
case '--retry-after-ms': retryAfterMs = numberValue(option, value); break
case '--request-id': requestId = value; break
case '--tool-name': toolName = value; break
case '--tool-arguments': toolArguments = value; break
default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`)
}
}
if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
const parsedSequence = parseSequence(sequenceRaw)
if (parsedSequence.startsUnavailable && port === 0) {
throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port')
}
if (!parsedSequence.startsUnavailable && listenDelayMs !== undefined) {
throw new Error('dsh-llm-mock-server: --listen-delay-ms requires connection_refused first in --sequence')
}
if (!parsedSequence.sequence.includes('random') && (randomSeed !== undefined || randomWeights !== undefined)) {
throw new Error('dsh-llm-mock-server: --seed and --random-weights require random in --sequence')
}
return {
kind: 'run',
config: {
server: {
sequence: parsedSequence.sequence,
port,
repeatLast,
...randomSeed === undefined ? {} : { randomSeed },
...randomWeights === undefined ? {} : { randomWeights },
...host === undefined ? {} : { host },
...apiKey === undefined ? {} : { apiKey },
...successText === undefined ? {} : { successText },
...partialText === undefined ? {} : { partialText },
...reasoningText === undefined ? {} : { reasoningText },
...chunkSize === undefined ? {} : { chunkSize },
...chunkDelayMs === undefined ? {} : { chunkDelayMs },
...disconnectDelayMs === undefined ? {} : { disconnectDelayMs },
...retryAfterMs === undefined ? {} : { retryAfterMs },
...requestId === undefined ? {} : { requestId },
...toolName === undefined ? {} : { toolName },
...toolArguments === undefined ? {} : { toolArguments },
},
listenDelayMs: parsedSequence.startsUnavailable ? listenDelayMs ?? DEFAULT_LISTEN_DELAY_MS : 0,
startsUnavailable: parsedSequence.startsUnavailable,
},
}
}

View File

@@ -0,0 +1,738 @@
/**
* Scriptable OpenAI-compatible HTTP/SSE server for transport, protocol, and
* semantic-empty LLM recovery tests. Each accepted chat-completions request
* consumes one behavior; the server never retries or interprets harness policy.
*
* @module @deepseek-ai/dsh-llm-mock-server
*/
import { createServer } from 'node:http'
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'
import { randomBytes } from 'node:crypto'
import { isIP, type AddressInfo } from 'node:net'
import { setTimeout as delay } from 'node:timers/promises'
/** Request-scoped behaviors accepted by {@link startMockLlmServer}. */
export const MOCK_LLM_BEHAVIORS = [
'connection_reset',
'stream_disconnect',
'empty',
'empty_body',
'stream_eof',
'partial_eof',
'partial_disconnect',
'stall',
'malformed_json',
'malformed_event',
'wrong_content_type',
'rate_limit',
'server_error',
'service_unavailable',
'auth_error',
'invalid_request',
'context_overflow',
'quota_exceeded',
'success',
'reasoning_success',
'tool_call_success',
'max_tokens',
'slow_success',
'random',
] as const
/** One scripted mock behavior name; `random` selects a concrete behavior per request. */
export type MockLlmBehavior = typeof MOCK_LLM_BEHAVIORS[number]
/** One concrete request behavior after resolving a `random` script entry. */
export type ConcreteMockLlmBehavior = Exclude<MockLlmBehavior, 'random'>
/** Relative non-negative weights for random request behavior selection. */
export type MockLlmRandomWeights = Partial<Record<ConcreteMockLlmBehavior, number>>
/**
* Default stress profile for `random`. Weights are configurable test pressure,
* not a claim about production incident frequency.
*/
export const DEFAULT_MOCK_LLM_RANDOM_WEIGHTS: Readonly<MockLlmRandomWeights> = Object.freeze({
success: 48,
slow_success: 10,
max_tokens: 2,
connection_reset: 5,
stream_disconnect: 5,
partial_disconnect: 10,
empty: 5,
stall: 2,
rate_limit: 5,
server_error: 4,
service_unavailable: 2,
partial_eof: 1,
malformed_json: 1,
})
/** Largest millisecond delay accepted by Node timers without truncation. */
export const MAX_MOCK_LLM_TIMER_DELAY_MS = 2_147_483_647
/** How one accepted request ended at the mock boundary. */
export type MockLlmRequestOutcome = 'completed' | 'reset' | 'stalled' | 'client_closed' | 'server_error'
/** Immutable telemetry emitted when a request starts or reaches an outcome. */
export type MockLlmServerEvent =
| {
readonly type: 'request'
readonly attempt: number
readonly scriptBehavior: MockLlmBehavior | 'script_exhausted'
readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted'
readonly path: string
}
| {
readonly type: 'result'
readonly attempt: number
readonly scriptBehavior: MockLlmBehavior | 'script_exhausted'
readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted'
readonly outcome: MockLlmRequestOutcome
readonly chunksSent: number
}
/** Captured wire request and its final server-side outcome. */
export interface MockLlmRequestRecord {
/** One-based accepted chat-completions request number. */
readonly attempt: number
/** Script entry consumed for this request before random resolution. */
readonly scriptBehavior: MockLlmBehavior | 'script_exhausted'
/** Concrete behavior selected for this request, or exhaustion after the configured script. */
readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted'
/** Original request path, including a `/v1` prefix when the client supplied one. */
readonly path: string
/** Detached request headers. */
readonly headers: Readonly<IncomingHttpHeaders>
/** Parsed JSON request body. */
readonly body: unknown
/** Number of SSE `data:` events handed to Node before the outcome. */
chunksSent: number
/** Final server-side outcome; absent while a stalled request remains open. */
outcome?: MockLlmRequestOutcome
}
/** Configuration for one mock server instance. */
export interface MockLlmServerOptions {
/** Loopback host by default. */
readonly host?: string
/** TCP port; zero requests an OS-assigned port. */
readonly port?: number
/** Optional exact bearer token; omission accepts any authorization header. */
readonly apiKey?: string
/** Ordered request behaviors; exhaustion fails loud unless `repeatLast` is true. */
readonly sequence: readonly MockLlmBehavior[]
/** Reuse the final behavior after the sequence is consumed. */
readonly repeatLast?: boolean
/** Optional deterministic unsigned 32-bit seed; omission generates and exposes one. */
readonly randomSeed?: number
/** Relative weights used whenever a script entry is `random`. */
readonly randomWeights?: Readonly<MockLlmRandomWeights>
/** Complete text returned by success-shaped behaviors. */
readonly successText?: string
/** Text emitted before partial EOF/reset behaviors terminate. */
readonly partialText?: string
/** Reasoning text emitted by `reasoning_success`. */
readonly reasoningText?: string
/** Unicode code-point count per text or reasoning SSE delta. */
readonly chunkSize?: number
/** Inter-chunk delay for `slow_success`, in milliseconds. */
readonly chunkDelayMs?: number
/** Delay after headers/deltas before a forced disconnect, in milliseconds. */
readonly disconnectDelayMs?: number
/** Provider retry delay; the wire `Retry-After` value rounds up to whole seconds. */
readonly retryAfterMs?: number
/** Optional provider request id returned on HTTP failures. */
readonly requestId?: string
/** Tool name emitted by `tool_call_success`. */
readonly toolName?: string
/** Raw JSON arguments emitted by `tool_call_success`. */
readonly toolArguments?: string
/** Optional observer for JSONL CLI telemetry; observer failures never affect wire behavior. */
readonly onEvent?: (event: MockLlmServerEvent) => void
}
/** Running mock server and captured request state. */
export interface MockLlmServer {
/** Base URL without `/v1`; both root and `/v1` chat-completions paths are accepted. */
readonly baseURL: string
/** Actual bound port, including an OS-assigned value. */
readonly port: number
/** Seed used for random behavior selection, including the generated default. */
readonly randomSeed: number
/** Live request records in arrival order. */
readonly requests: readonly MockLlmRequestRecord[]
/** Stop accepting requests and force-close stalled/streaming connections; idempotent. */
close(): Promise<void>
}
interface ResolvedOptions {
readonly host: string
readonly port: number
readonly apiKey?: string
readonly sequence: readonly MockLlmBehavior[]
readonly lastBehavior: MockLlmBehavior
readonly repeatLast: boolean
readonly randomSeed: number
readonly randomWeights: readonly (readonly [ConcreteMockLlmBehavior, number])[]
readonly successText: string
readonly partialText: string
readonly reasoningText: string
readonly chunkSize: number
readonly chunkDelayMs: number
readonly disconnectDelayMs: number
readonly retryAfterMs: number
readonly requestId?: string
readonly toolName: string
readonly toolArguments: string
readonly onEvent?: (event: MockLlmServerEvent) => void
}
const DEFAULT_SUCCESS_TEXT = 'mock response recovered'
const DEFAULT_PARTIAL_TEXT = 'discarded partial response'
const DEFAULT_REASONING_TEXT = 'mock reasoning'
const CONCRETE_BEHAVIORS = new Set<string>(MOCK_LLM_BEHAVIORS.filter(behavior => behavior !== 'random'))
function boundedInteger(name: string, value: number, min: number, max: number): number {
if (!Number.isInteger(value) || value < min || value > max) {
throw new Error(`llm-mock-server: ${name} must be an integer between ${min} and ${max}`)
}
return value
}
function resolveOptions(options: MockLlmServerOptions): ResolvedOptions {
const host = options.host ?? '127.0.0.1'
const port = boundedInteger('port', options.port ?? 0, 0, 65_535)
const chunkSize = boundedInteger('chunkSize', options.chunkSize ?? 8, 1, Number.MAX_SAFE_INTEGER)
const chunkDelayMs = boundedInteger(
'chunkDelayMs',
options.chunkDelayMs ?? 25,
0,
MAX_MOCK_LLM_TIMER_DELAY_MS,
)
const disconnectDelayMs = boundedInteger(
'disconnectDelayMs',
options.disconnectDelayMs ?? 10,
0,
MAX_MOCK_LLM_TIMER_DELAY_MS,
)
const retryAfterMs = boundedInteger(
'retryAfterMs',
options.retryAfterMs ?? 1_000,
1,
MAX_MOCK_LLM_TIMER_DELAY_MS,
)
const randomSeed = boundedInteger(
'randomSeed',
options.randomSeed ?? randomBytes(4).readUInt32LE(0),
0,
0xffff_ffff,
)
const successText = options.successText ?? DEFAULT_SUCCESS_TEXT
const partialText = options.partialText ?? DEFAULT_PARTIAL_TEXT
const reasoningText = options.reasoningText ?? DEFAULT_REASONING_TEXT
const toolName = options.toolName ?? 'mock_tool'
const toolArguments = options.toolArguments ?? '{"value":"mock"}'
if (host.length === 0) throw new Error('llm-mock-server: host must not be empty')
if (options.sequence.length === 0) throw new Error('llm-mock-server: sequence must not be empty')
const lastBehavior = options.sequence.reduce((_previous, behavior) => behavior)
if (options.apiKey === '') throw new Error('llm-mock-server: apiKey must not be empty')
if (successText.length === 0) throw new Error('llm-mock-server: successText must not be empty')
if (partialText.length === 0) throw new Error('llm-mock-server: partialText must not be empty')
if (reasoningText.length === 0) throw new Error('llm-mock-server: reasoningText must not be empty')
if (toolName.length === 0) throw new Error('llm-mock-server: toolName must not be empty')
if (options.requestId === '') throw new Error('llm-mock-server: requestId must not be empty')
try {
JSON.parse(toolArguments)
} catch {
throw new Error('llm-mock-server: toolArguments must be valid JSON')
}
const configuredWeights = options.randomWeights ?? DEFAULT_MOCK_LLM_RANDOM_WEIGHTS
const randomWeights: Array<readonly [ConcreteMockLlmBehavior, number]> = []
for (const [behavior, weight] of Object.entries(configuredWeights)) {
if (!CONCRETE_BEHAVIORS.has(behavior)) {
throw new Error(`llm-mock-server: randomWeights contains unknown concrete behavior ${JSON.stringify(behavior)}`)
}
if (!Number.isFinite(weight) || weight < 0) {
throw new Error(`llm-mock-server: random weight for ${behavior} must be a non-negative finite number`)
}
if (weight > 0) randomWeights.push([behavior as ConcreteMockLlmBehavior, weight])
}
if (randomWeights.length === 0) {
throw new Error('llm-mock-server: randomWeights must contain at least one positive weight')
}
return {
host,
port,
...options.apiKey === undefined ? {} : { apiKey: options.apiKey },
sequence: [...options.sequence],
lastBehavior,
repeatLast: options.repeatLast ?? false,
randomSeed,
randomWeights,
successText,
partialText,
reasoningText,
chunkSize,
chunkDelayMs,
disconnectDelayMs,
retryAfterMs,
...options.requestId === undefined ? {} : { requestId: options.requestId },
toolName,
toolArguments,
...options.onEvent === undefined ? {} : { onEvent: options.onEvent },
}
}
function emit(options: ResolvedOptions, event: MockLlmServerEvent): void {
try {
options.onEvent?.(Object.freeze(event))
} catch (_telemetryObserverFailure) {
// Test telemetry is observational; a broken observer cannot change provider wire behavior.
}
}
async function readJsonBody(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = []
for await (const chunk of request) chunks.push(Buffer.from(chunk as Uint8Array))
const body = Buffer.concat(chunks).toString('utf8')
return body.length === 0 ? undefined : JSON.parse(body)
}
function splitText(text: string, size: number): string[] {
const points = Array.from(text)
const chunks: string[] = []
for (let index = 0; index < points.length; index += size) chunks.push(points.slice(index, index + size).join(''))
return chunks
}
function openSse(response: ServerResponse, contentType = 'text/event-stream; charset=utf-8'): void {
response.writeHead(200, {
'content-type': contentType,
'cache-control': 'no-cache',
'connection': 'keep-alive',
})
response.flushHeaders()
}
function writeSse(record: MockLlmRequestRecord, response: ServerResponse, payload: unknown): void {
response.write(`data: ${typeof payload === 'string' ? payload : JSON.stringify(payload)}\n\n`)
record.chunksSent += 1
}
function writeDone(record: MockLlmRequestRecord, response: ServerResponse): void {
writeSse(record, response, '[DONE]')
}
function finishRecord(
options: ResolvedOptions,
record: MockLlmRequestRecord,
outcome: MockLlmRequestOutcome,
): void {
if (record.outcome !== undefined) return
record.outcome = outcome
emit(options, {
type: 'result',
attempt: record.attempt,
scriptBehavior: record.scriptBehavior,
behavior: record.behavior,
outcome,
chunksSent: record.chunksSent,
})
}
function httpError(
options: ResolvedOptions,
record: MockLlmRequestRecord,
response: ServerResponse,
status: number,
message: string,
code: string,
type = 'mock_error',
): void {
const headers: Record<string, string> = { 'content-type': 'application/json' }
if (record.behavior === 'rate_limit') {
headers['retry-after'] = String(Math.ceil(options.retryAfterMs / 1_000))
}
if (options.requestId !== undefined) headers['x-request-id'] = options.requestId
response.writeHead(status, headers)
response.end(JSON.stringify({ error: { message, type, code } }))
finishRecord(options, record, 'completed')
}
function terminalChunk(reason: string, outputTokens: number): unknown {
return {
choices: [{ index: 0, delta: { content: '' }, finish_reason: reason }],
usage: { prompt_tokens: 3, completion_tokens: outputTokens },
}
}
async function pause(milliseconds: number, response: ServerResponse): Promise<boolean> {
if (milliseconds === 0) return !response.destroyed
const controller = new AbortController()
const stop = (): void => { controller.abort() }
response.once('close', stop)
try {
await delay(milliseconds, undefined, { signal: controller.signal })
return true
} catch (_responseClosed) {
// The timer only receives this response-owned abort signal; closing the response cancels its wait.
return false
} finally {
response.off('close', stop)
}
}
async function streamText(
options: ResolvedOptions,
record: MockLlmRequestRecord,
response: ServerResponse,
text: string,
delayMs: number,
): Promise<boolean> {
for (const chunk of splitText(text, options.chunkSize)) {
writeSse(record, response, { choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }] })
if (!await pause(delayMs, response)) return false
}
return true
}
async function completeText(
options: ResolvedOptions,
record: MockLlmRequestRecord,
response: ServerResponse,
reason: 'stop' | 'length',
delayMs: number,
): Promise<void> {
if (!await streamText(options, record, response, options.successText, delayMs)) {
finishRecord(options, record, 'client_closed')
return
}
writeSse(record, response, terminalChunk(reason, Array.from(options.successText).length))
writeDone(record, response)
response.end()
finishRecord(options, record, 'completed')
}
async function disconnect(
options: ResolvedOptions,
record: MockLlmRequestRecord,
response: ServerResponse,
): Promise<void> {
if (!await pause(options.disconnectDelayMs, response)) {
finishRecord(options, record, 'client_closed')
return
}
finishRecord(options, record, 'reset')
response.destroy()
}
function toolCallChunks(options: ResolvedOptions): readonly unknown[] {
const midpoint = Math.max(1, Math.floor(options.toolArguments.length / 2))
return [
{
choices: [{
index: 0,
delta: {
tool_calls: [{
index: 0,
id: 'mock-call-1',
type: 'function',
function: { name: options.toolName, arguments: options.toolArguments.slice(0, midpoint) },
}],
},
finish_reason: null,
}],
},
{
choices: [{
index: 0,
delta: { tool_calls: [{ index: 0, function: { arguments: options.toolArguments.slice(midpoint) } }] },
finish_reason: null,
}],
},
]
}
async function runBehavior(
options: ResolvedOptions,
record: MockLlmRequestRecord,
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
switch (record.behavior) {
case 'script_exhausted':
httpError(options, record, response, 500, 'mock script exhausted', 'MOCK_SCRIPT_EXHAUSTED')
return
case 'connection_reset':
finishRecord(options, record, 'reset')
request.socket.destroy()
return
case 'stream_disconnect':
openSse(response)
await disconnect(options, record, response)
return
case 'empty':
openSse(response)
writeSse(record, response, terminalChunk('stop', 0))
writeDone(record, response)
response.end()
finishRecord(options, record, 'completed')
return
case 'empty_body':
openSse(response)
response.end()
finishRecord(options, record, 'completed')
return
case 'stream_eof':
openSse(response)
writeSse(record, response, { choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })
response.end()
finishRecord(options, record, 'completed')
return
case 'partial_eof':
openSse(response)
await streamText(options, record, response, options.partialText, 0)
response.end()
finishRecord(options, record, 'completed')
return
case 'partial_disconnect':
openSse(response)
if (!await streamText(options, record, response, options.partialText, options.chunkDelayMs)) return
await disconnect(options, record, response)
return
case 'stall':
openSse(response)
finishRecord(options, record, 'stalled')
return
case 'malformed_json':
openSse(response)
writeSse(record, response, '{not-json')
writeDone(record, response)
response.end()
finishRecord(options, record, 'completed')
return
case 'malformed_event':
openSse(response)
writeSse(record, response, { choices: [null] })
writeDone(record, response)
response.end()
finishRecord(options, record, 'completed')
return
case 'wrong_content_type':
openSse(response, 'application/json')
await completeText(options, record, response, 'stop', 0)
return
case 'rate_limit':
httpError(options, record, response, 429, 'mock rate limit', 'rate_limit')
return
case 'server_error':
httpError(options, record, response, 500, 'mock server error', 'server_error')
return
case 'service_unavailable':
httpError(options, record, response, 503, 'mock service unavailable', 'service_unavailable')
return
case 'auth_error':
httpError(options, record, response, 401, 'mock authentication failed', 'invalid_api_key')
return
case 'invalid_request':
httpError(options, record, response, 400, 'mock invalid request', 'invalid_request')
return
case 'context_overflow':
httpError(
options,
record,
response,
400,
'mock input exceeds the model context window',
'context_length_exceeded',
'invalid_request_error',
)
return
case 'quota_exceeded':
httpError(options, record, response, 429, 'mock insufficient quota', 'insufficient_quota')
return
case 'success':
openSse(response)
await completeText(options, record, response, 'stop', 0)
return
case 'reasoning_success':
openSse(response)
for (const chunk of splitText(options.reasoningText, options.chunkSize)) {
writeSse(record, response, {
choices: [{ index: 0, delta: { reasoning_content: chunk }, finish_reason: null }],
})
}
await completeText(options, record, response, 'stop', 0)
return
case 'tool_call_success':
openSse(response)
for (const chunk of toolCallChunks(options)) writeSse(record, response, chunk)
writeSse(record, response, terminalChunk('tool_calls', 2))
writeDone(record, response)
response.end()
finishRecord(options, record, 'completed')
return
case 'max_tokens':
openSse(response)
await completeText(options, record, response, 'length', 0)
return
case 'slow_success':
openSse(response)
await completeText(options, record, response, 'stop', options.chunkDelayMs)
return
}
}
function seededRandom(seed: number): () => number {
let state = seed
return () => {
state = (state + 0x6d2b_79f5) >>> 0
let mixed = state
mixed = Math.imul(mixed ^ mixed >>> 15, mixed | 1)
mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61)
return ((mixed ^ mixed >>> 14) >>> 0) / 0x1_0000_0000
}
}
function chooseRandomBehavior(
weights: readonly (readonly [ConcreteMockLlmBehavior, number])[],
random: () => number,
): ConcreteMockLlmBehavior {
const total = weights.reduce((sum, entry) => sum + entry[1], 0)
let draw = random() * total
for (const [behavior, weight] of weights) {
if (draw < weight) return behavior
draw -= weight
}
// Floating-point subtraction can only leave a rounding residue at the upper boundary.
/* v8 ignore next -- seededRandom is strictly less than one; this guards floating-point residue only */
return (weights.at(-1) as readonly [ConcreteMockLlmBehavior, number])[0]
}
/**
* Start a local chat-completions server that consumes one configured behavior
* per accepted request. Only a `POST` path ending in `/chat/completions` consumes the script;
* invalid routes, methods, authorization, and JSON receive ordinary 4xx
* responses. Closing the handle terminates stalled connections.
*
* @param options - listener, script, response content, timing, and telemetry options.
* @returns the listening handle after the port is bound.
*/
export async function startMockLlmServer(options: MockLlmServerOptions): Promise<MockLlmServer> {
const resolved = resolveOptions(options)
const requests: MockLlmRequestRecord[] = []
const random = seededRandom(resolved.randomSeed)
let cursor = 0
const selectBehavior = (): {
scriptBehavior: MockLlmBehavior | 'script_exhausted'
behavior: ConcreteMockLlmBehavior | 'script_exhausted'
} => {
const selected = resolved.sequence[cursor]
cursor += 1
const scriptBehavior = selected
?? (resolved.repeatLast ? resolved.lastBehavior : 'script_exhausted')
return {
scriptBehavior,
behavior: scriptBehavior === 'random'
? chooseRandomBehavior(resolved.randomWeights, random)
: scriptBehavior,
}
}
const handle = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
/* v8 ignore next -- node:http server requests always carry a URL despite the shared optional type */
const path = new URL(request.url ?? '/', 'http://mock.invalid').pathname
if (request.method !== 'POST') {
response.writeHead(405, { allow: 'POST' }).end()
return
}
if (!path.endsWith('/chat/completions')) {
response.writeHead(404).end()
return
}
if (resolved.apiKey !== undefined && request.headers.authorization !== `Bearer ${resolved.apiKey}`) {
response.writeHead(401, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'invalid mock bearer token', code: 'invalid_api_key' } }))
return
}
let body: unknown
try {
body = await readJsonBody(request)
} catch {
response.writeHead(400, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'request body must be valid JSON', code: 'invalid_json' } }))
return
}
const selected = selectBehavior()
const record: MockLlmRequestRecord = {
attempt: requests.length + 1,
scriptBehavior: selected.scriptBehavior,
behavior: selected.behavior,
path,
headers: { ...request.headers },
body,
chunksSent: 0,
}
requests.push(record)
response.once('close', () => {
if (!response.writableFinished && record.outcome === undefined) {
finishRecord(resolved, record, 'client_closed')
}
})
emit(resolved, {
type: 'request',
attempt: record.attempt,
scriptBehavior: record.scriptBehavior,
behavior: record.behavior,
path,
})
await runBehavior(resolved, record, request, response)
}
const server = createServer((request, response) => {
/* v8 ignore start -- last-resort containment for Node response failures after validated test inputs */
handle(request, response).catch((error: unknown) => {
const record = requests.at(-1)
if (record !== undefined) finishRecord(resolved, record, 'server_error')
if (response.headersSent) {
response.destroy(error instanceof Error ? error : new Error(String(error)))
return
}
response.writeHead(500, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'mock server handler failed', code: 'MOCK_HANDLER_FAILED' } }))
})
/* v8 ignore stop */
})
let closing: Promise<void> | undefined
const close = (): Promise<void> => (closing ??= new Promise((resolveClose) => {
server.close(() => { resolveClose() })
server.closeAllConnections()
}))
await new Promise<void>((resolveListen, rejectListen) => {
server.once('error', rejectListen)
server.listen(resolved.port, resolved.host, () => {
server.off('error', rejectListen)
resolveListen()
})
})
const address = server.address() as AddressInfo
const advertisedHost = isIP(resolved.host) === 6 ? `[${resolved.host}]` : resolved.host
return {
baseURL: `http://${advertisedHost}:${address.port}`,
port: address.port,
randomSeed: resolved.randomSeed,
requests,
close,
}
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-llm-mock-server`.
* @module @deepseek-ai/dsh-llm-mock-server/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-llm-mock-server'
/** Cordis companion plugin name. */
export const name = 'llm-mock-server-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this standalone test server owns no Cordis event stream or shared data;
* its wire behavior and lifecycle are exercised through direct HTTP and assembled-loop tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import {
MOCK_LLM_CLI_USAGE,
parseMockLlmCliArgs,
} from '../src/cli.ts'
describe('mock LLM server CLI parser', () => {
it('returns help without requiring a sequence', () => {
expect(parseMockLlmCliArgs(['--help'])).toEqual({ kind: 'help' })
expect(MOCK_LLM_CLI_USAGE).toContain('--sequence')
})
it('parses every request and listener option', () => {
expect(parseMockLlmCliArgs([
'--sequence', 'connection_refused,partial_disconnect,success',
'--host', 'localhost',
'--port', '9010',
'--api-key', 'mock-key',
'--listen-delay-ms', '100',
'--repeat-last',
'--success-text', 'done',
'--partial-text', 'half',
'--reasoning-text', 'think',
'--chunk-size', '2',
'--chunk-delay-ms', '3',
'--disconnect-delay-ms', '4',
'--retry-after-ms', '5000',
'--request-id', 'request-1',
'--tool-name', 'lookup',
'--tool-arguments', '{"id":1}',
])).toEqual({
kind: 'run',
config: {
startsUnavailable: true,
listenDelayMs: 100,
server: {
sequence: ['partial_disconnect', 'success'],
host: 'localhost',
port: 9010,
apiKey: 'mock-key',
repeatLast: true,
successText: 'done',
partialText: 'half',
reasoningText: 'think',
chunkSize: 2,
chunkDelayMs: 3,
disconnectDelayMs: 4,
retryAfterMs: 5000,
requestId: 'request-1',
toolName: 'lookup',
toolArguments: '{"id":1}',
},
},
})
})
it('uses standalone defaults for an ordinary sequence', () => {
expect(parseMockLlmCliArgs(['--sequence', 'success'])).toEqual({
kind: 'run',
config: {
startsUnavailable: false,
listenDelayMs: 0,
server: {
sequence: ['success'],
port: 8000,
repeatLast: false,
},
},
})
})
it('uses the default unavailable interval', () => {
const result = parseMockLlmCliArgs(['--sequence', 'connection_refused,success', '--port', '8001'])
expect(result).toMatchObject({
kind: 'run',
config: { startsUnavailable: true, listenDelayMs: 750 },
})
})
it('parses a reproducible weighted random profile', () => {
expect(parseMockLlmCliArgs([
'--sequence', 'random',
'--repeat-last',
'--seed', '42',
'--random-weights', 'success=8,partial_disconnect=2',
])).toEqual({
kind: 'run',
config: {
startsUnavailable: false,
listenDelayMs: 0,
server: {
sequence: ['random'],
port: 8000,
repeatLast: true,
randomSeed: 42,
randomWeights: { success: 8, partial_disconnect: 2 },
},
},
})
})
it.each([
[[], /--sequence is required/],
[['--wat'], /requires a value/],
[['--wat', 'x'], /unknown option/],
[['--port', 'NaN', '--sequence', 'success'], /finite number/],
[['--sequence', 'success,'], /non-empty/],
[['--sequence', 'success,connection_refused'], /only as the first/],
[['--sequence', 'connection_refused'], /must be followed/],
[['--sequence', 'unknown'], /unknown behavior/],
[['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/],
[['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/],
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/],
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/],
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/],
[['--sequence', 'success', '--seed', '1'], /require random/],
[['--sequence', 'random', '--random-weights', 'success'], /expects behavior=weight/],
[['--sequence', 'random', '--random-weights', 'random=1'], /concrete behavior/],
[['--sequence', 'random', '--random-weights', 'success=1,success=2'], /duplicate/],
[['--sequence', 'random', '--random-weights', 'success=nope'], /finite number/],
])('rejects invalid argv %#', (argv, expected) => {
expect(() => parseMockLlmCliArgs(argv)).toThrow(expected)
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as MockServerInvariant from '../src/invariant.ts'
describe('mock LLM server invariant companion', () => {
it('registers its explained empty runtime invariant', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService)
const fiber = await ctx.plugin(MockServerInvariant)
expect(() => {
ctx.invariants.register('@deepseek-ai/dsh-llm-mock-server', () => {})
}).toThrow(/already registered/)
await fiber.dispose()
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,357 @@
import { request } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts'
import { startMockLlmServer } from '../src/index.ts'
const running: MockLlmServer[] = []
afterEach(async () => {
await Promise.all(running.splice(0).map(server => server.close()))
})
async function start(
sequence: readonly MockLlmBehavior[],
options: Omit<Parameters<typeof startMockLlmServer>[0], 'sequence'> = {},
): Promise<MockLlmServer> {
const server = await startMockLlmServer({ sequence, ...options })
running.push(server)
return server
}
function chat(
server: MockLlmServer,
options: { path?: string; key?: string; body?: string; signal?: AbortSignal } = {},
): Promise<Response> {
return fetch(`${server.baseURL}${options.path ?? '/v1/chat/completions'}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...options.key === undefined ? {} : { authorization: `Bearer ${options.key}` },
},
body: options.body ?? JSON.stringify({ model: 'mock', messages: [], stream: true }),
...options.signal === undefined ? {} : { signal: options.signal },
})
}
function rawChat(server: MockLlmServer, chunks: readonly Buffer[]): Promise<void> {
return new Promise((resolve, reject) => {
const outgoing = request(`${server.baseURL}/v1/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
}, (response) => {
response.once('error', reject)
response.once('end', resolve)
response.resume()
})
outgoing.once('error', reject)
for (const chunk of chunks) outgoing.write(chunk)
outgoing.end()
})
}
describe('mock LLM server wire behaviors', () => {
it('streams a complete text response and captures the request', async () => {
const events: MockLlmServerEvent[] = []
const server = await start(['success'], {
apiKey: 'mock-key',
successText: 'recovered',
chunkSize: 3,
onEvent: (event) => { events.push(event) },
})
const response = await chat(server, { key: 'mock-key' })
const body = await response.text()
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toContain('text/event-stream')
expect(body).toContain('"content":"rec"')
expect(body).toContain('"content":"ove"')
expect(body).toContain('"content":"red"')
expect(body).toContain('"finish_reason":"stop"')
expect(body).toContain('data: [DONE]')
expect(server.requests).toEqual([expect.objectContaining({
attempt: 1,
behavior: 'success',
path: '/v1/chat/completions',
body: { model: 'mock', messages: [], stream: true },
chunksSent: 5,
outcome: 'completed',
})])
expect(events).toEqual([
{
type: 'request',
attempt: 1,
scriptBehavior: 'success',
behavior: 'success',
path: '/v1/chat/completions',
},
{
type: 'result',
attempt: 1,
scriptBehavior: 'success',
behavior: 'success',
outcome: 'completed',
chunksSent: 5,
},
])
})
it('supports root paths and intentionally ignores telemetry observer failures', async () => {
const server = await start(['empty'], {
onEvent() {
throw new Error('observer failed')
},
})
const response = await chat(server, { path: '/chat/completions' })
expect(response.status).toBe(200)
expect(await response.text()).toContain('data: [DONE]')
expect(server.requests[0]).toMatchObject({ path: '/chat/completions', outcome: 'completed' })
})
it.each([
['empty_body', 0, ''] as const,
['stream_eof', 1, '"role":"assistant"'] as const,
['partial_eof', 1, 'discarded partial response'] as const,
['malformed_json', 2, 'data: {not-json'] as const,
['malformed_event', 2, '"choices":[null]'] as const,
])('serves %s without inventing a terminal completion', async (behavior, chunks, marker) => {
const server = await start([behavior], { chunkSize: 100 })
const response = await chat(server)
const body = await response.text()
expect(response.status).toBe(200)
expect(body).toContain(marker)
if (behavior !== 'malformed_json' && behavior !== 'malformed_event') {
expect(body).not.toContain('[DONE]')
}
expect(server.requests[0]).toMatchObject({ behavior, chunksSent: chunks, outcome: 'completed' })
})
it.each([
['connection_reset', false] as const,
['stream_disconnect', true] as const,
['partial_disconnect', true] as const,
])('forces the %s transport boundary', async (behavior, receivesHeaders) => {
const server = await start([behavior], { disconnectDelayMs: 20, partialText: 'half' })
let headersReceived = false
await expect((async () => {
const response = await chat(server)
headersReceived = true
await response.text()
})()).rejects.toThrow()
expect(headersReceived).toBe(receivesHeaders)
expect(server.requests[0]).toMatchObject({
behavior,
chunksSent: behavior === 'partial_disconnect' ? 1 : 0,
outcome: 'reset',
})
})
it('holds a stalled stream until the client aborts and server close remains idempotent', async () => {
const server = await start(['stall'])
const controller = new AbortController()
const response = await chat(server, { signal: controller.signal })
expect(response.status).toBe(200)
expect(server.requests[0]).toMatchObject({ behavior: 'stall', outcome: 'stalled' })
controller.abort()
await expect(response.text()).rejects.toThrow()
await server.close()
await server.close()
})
it.each([
['slow_success', 100] as const,
['stream_disconnect', 100] as const,
['partial_disconnect', 100] as const,
])('records a client that closes during %s', async (behavior, delayMs) => {
const events: MockLlmServerEvent[] = []
const server = await start([behavior], {
chunkDelayMs: delayMs,
disconnectDelayMs: delayMs,
chunkSize: 1,
onEvent: (event) => { events.push(event) },
})
const controller = new AbortController()
const response = await chat(server, { signal: controller.signal })
controller.abort()
await expect(response.text()).rejects.toThrow()
// The server observes the socket close asynchronously; a fixed sleep
// raced slow runners, so poll until the outcome lands.
await vi.waitFor(() => {
expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' })
})
expect(events.filter(event => event.type === 'result')).toEqual([
expect.objectContaining({ behavior, outcome: 'client_closed' }),
])
})
it('preserves UTF-8 code points split across request chunks', async () => {
const server = await start(['success'])
const encoded = Buffer.from(JSON.stringify({ messages: [{ role: 'user', content: '你好' }] }))
const characterOffset = encoded.indexOf(Buffer.from('你'))
expect(characterOffset).toBeGreaterThanOrEqual(0)
await rawChat(server, [
encoded.subarray(0, characterOffset + 1),
encoded.subarray(characterOffset + 1),
])
expect(server.requests[0]?.body).toEqual({ messages: [{ role: 'user', content: '你好' }] })
})
it('formats an IPv6 listener as a valid base URL', async () => {
const server = await start(['success'], { host: '::1' })
expect(server.baseURL).toMatch(/^http:\/\/\[::1\]:\d+$/)
expect((await chat(server)).status).toBe(200)
})
it('emits reasoning, tool calls, max-token finishes, slow chunks, and a wrong content type', async () => {
const server = await start([
'reasoning_success',
'tool_call_success',
'max_tokens',
'slow_success',
'wrong_content_type',
], {
successText: 'answer',
reasoningText: 'think',
toolName: 'lookup',
toolArguments: '{"id":7}',
chunkDelayMs: 1,
chunkSize: 2,
})
const bodies: string[] = []
const contentTypes: Array<string | null> = []
for (let index = 0; index < 5; index += 1) {
const response = await chat(server)
contentTypes.push(response.headers.get('content-type'))
bodies.push(await response.text())
}
expect(bodies[0]).toContain('"reasoning_content":"th"')
expect(bodies[1]).toContain('"name":"lookup"')
expect(bodies[1]).toContain('"arguments":"{\\"id"')
expect(bodies[1]).toContain('"finish_reason":"tool_calls"')
expect(bodies[2]).toContain('"finish_reason":"length"')
expect(bodies[3]).toContain('"finish_reason":"stop"')
expect(contentTypes[4]).toBe('application/json')
expect(server.requests).toHaveLength(5)
expect(server.requests.every(record => record.outcome === 'completed')).toBe(true)
})
it.each([
['rate_limit', 429, 'mock rate limit'] as const,
['server_error', 500, 'mock server error'] as const,
['service_unavailable', 503, 'mock service unavailable'] as const,
['auth_error', 401, 'mock authentication failed'] as const,
['invalid_request', 400, 'mock invalid request'] as const,
['context_overflow', 400, 'context_length_exceeded'] as const,
['quota_exceeded', 429, 'insufficient_quota'] as const,
])('serves %s as a structured HTTP error', async (behavior, status, marker) => {
const server = await start([behavior], { retryAfterMs: 1_001, requestId: 'mock-request-1' })
const response = await chat(server)
const body = await response.text()
expect(response.status).toBe(status)
expect(body).toContain(marker)
expect(response.headers.get('x-request-id')).toBe('mock-request-1')
if (behavior === 'rate_limit') expect(response.headers.get('retry-after')).toBe('2')
else expect(response.headers.get('retry-after')).toBeNull()
expect(server.requests[0]?.outcome).toBe('completed')
})
it('fails loud on script exhaustion and can explicitly repeat the final behavior', async () => {
const exhausted = await start(['success'], { successText: 'once' })
await (await chat(exhausted)).text()
const exhaustedResponse = await chat(exhausted)
expect(exhaustedResponse.status).toBe(500)
expect(await exhaustedResponse.text()).toContain('mock script exhausted')
expect(exhausted.requests.map(record => record.behavior)).toEqual(['success', 'script_exhausted'])
const repeating = await start(['empty'], { repeatLast: true })
await (await chat(repeating)).text()
await (await chat(repeating)).text()
expect(repeating.requests.map(record => record.behavior)).toEqual(['empty', 'empty'])
})
it('selects weighted random behaviors reproducibly and reports the concrete choice', async () => {
const options = {
sequence: ['random'] as const,
repeatLast: true,
randomSeed: 42,
randomWeights: { success: 1, empty: 1 },
successText: 'random success',
}
const first = await startMockLlmServer(options)
const second = await startMockLlmServer(options)
running.push(first, second)
for (let attempt = 0; attempt < 12; attempt += 1) {
await (await chat(first)).text()
await (await chat(second)).text()
}
const firstChoices = first.requests.map(record => record.behavior)
expect(first.randomSeed).toBe(42)
expect(second.randomSeed).toBe(42)
expect(firstChoices).toEqual(second.requests.map(record => record.behavior))
expect(new Set(firstChoices)).toEqual(new Set(['success', 'empty']))
expect(first.requests.every(record => record.scriptBehavior === 'random')).toBe(true)
})
it('rejects invalid method, route, bearer token, and JSON without consuming the script', async () => {
const server = await start(['success'], { apiKey: 'expected' })
const method = await fetch(`${server.baseURL}/v1/chat/completions`)
const route = await fetch(`${server.baseURL}/v1/other`, { method: 'POST', body: '{}' })
const auth = await chat(server, { key: 'wrong' })
const json = await chat(server, { key: 'expected', body: '{' })
expect(method.status).toBe(405)
expect(method.headers.get('allow')).toBe('POST')
expect(route.status).toBe(404)
expect(auth.status).toBe(401)
expect(json.status).toBe(400)
expect(server.requests).toHaveLength(0)
const emptyRequest = await fetch(`${server.baseURL}/v1/chat/completions`, {
method: 'POST',
headers: { authorization: 'Bearer expected' },
})
expect(emptyRequest.status).toBe(200)
expect(server.requests[0]?.behavior).toBe('success')
expect(server.requests[0]?.body).toBeUndefined()
})
})
describe('mock LLM server option validation', () => {
it.each([
[{ sequence: [] }, /sequence/],
[{ sequence: ['success'], host: '' }, /host/],
[{ sequence: ['success'], port: -1 }, /port/],
[{ sequence: ['success'], port: 65_536 }, /port/],
[{ sequence: ['success'], apiKey: '' }, /apiKey/],
[{ sequence: ['success'], successText: '' }, /successText/],
[{ sequence: ['success'], partialText: '' }, /partialText/],
[{ sequence: ['success'], reasoningText: '' }, /reasoningText/],
[{ sequence: ['success'], chunkSize: 0 }, /chunkSize/],
[{ sequence: ['success'], chunkDelayMs: -1 }, /chunkDelayMs/],
[{ sequence: ['success'], disconnectDelayMs: Number.POSITIVE_INFINITY }, /disconnectDelayMs/],
[{ sequence: ['success'], retryAfterMs: 0 }, /retryAfterMs/],
[{ sequence: ['success'], requestId: '' }, /requestId/],
[{ sequence: ['success'], toolName: '' }, /toolName/],
[{ sequence: ['success'], toolArguments: '{' }, /toolArguments/],
[{ sequence: ['random'], randomSeed: -1 }, /randomSeed/],
[{ sequence: ['random'], randomWeights: { random: 1 } }, /unknown concrete behavior/],
[{ sequence: ['random'], randomWeights: { success: -1 } }, /non-negative/],
[{ sequence: ['random'], randomWeights: { success: 0 } }, /positive weight/],
] as const)('rejects invalid options %#', async (options, expected) => {
await expect(startMockLlmServer(options as Parameters<typeof startMockLlmServer>[0]))
.rejects.toThrow(expected)
})
})

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'tsdown'
/** Builds each public entry as a self-contained file admitted by the package whitelist. */
export default defineConfig([
{
entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
{
entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
{
entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
])

View File

@@ -2,7 +2,7 @@
A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is available to scenarios that exercise model discovery; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery.
Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stream-json` snapshot in `examples/headless-agent`; each loads this plugin in place of a real LLM adapter. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`.
Its consumers are the ACP, headless `stream-json`, and TUI snapshot suites plus the web browser e2e lane. Loader-driven suites mount this plugin in place of a real LLM adapter; the web lane installs it directly to retain the teardown consumption handle. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`.
## How the fixture works
@@ -24,6 +24,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. |
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. |
| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. |
| `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. |
```yaml
- id: llm-replay
@@ -43,11 +44,11 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
## Exports
- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns the disposer (HMR safety). Use this in tests to drive replay without the Loader or env vars.
- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars.
- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order.
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the PRIMARY session only (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing).
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `Config`.
- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`.
## Plugin export shape

View File

@@ -78,6 +78,32 @@ export interface ReplayConfig {
* by tests that do not need discovery.
*/
providers?: ReplayProviderConfig[]
/**
* Optional per-chunk pacing delay in milliseconds: each replayed chunk waits
* this long before yielding, so a downstream transport (e.g. the web SSE
* mux observed by a browser) sees genuinely incremental delivery. A realism
* knob only — correctness must never depend on it. Absent or `0` keeps
* today's synchronous burst yield. Must be a non-negative finite integer;
* aborting mid-wait cancels the stream like any other abort.
*/
paceMs?: number
}
/**
* Handle returned by {@link installLlmReplay}: removal plus the end-of-run
* consumption check that turns silent fixture underruns (a scenario that
* issued fewer calls than recorded, or never bound a recorded child script)
* into a crisp diagnostic at teardown.
*/
export interface ReplayHandle {
/** Remove the registered adapter or waterfall listener (HMR safety). Freestanding closure — safe to destructure. */
dispose(this: void): void
/**
* Throw unless every recorded script was bound to a live session and every
* bound cursor consumed its full entry list. Call at scenario teardown.
* Freestanding closure — safe to destructure.
*/
assertConsumed(this: void): void
}
/**
@@ -289,12 +315,32 @@ class ReplayAdapter extends LlmAdapter {
}
}
/**
* Wait `paceMs` between chunk yields, aborting the wait (and the stream) the
* moment the signal fires — a paced replay must cancel as promptly as a burst
* one.
*/
function paceDelay(paceMs: number, signal: AbortSignal | undefined): Promise<void> {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, paceMs)
const onAbort = (): void => {
clearTimeout(timer)
reject(new Error('aborted'))
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
/** Yield a recorded stream back, honoring abort like a real adapter. */
async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined): AsyncIterable<StreamChunk> {
async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined, paceMs: number): AsyncIterable<StreamChunk> {
switch (entry.kind) {
case 'chunks':
for (const chunk of entry.chunks) {
if (signal?.aborted) throw new Error('aborted')
if (paceMs > 0) await paceDelay(paceMs, signal)
yield chunk
}
return
@@ -305,6 +351,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
// mid-stream STREAM_CLOSED after partial chunks).
for (const chunk of entry.chunks) {
if (signal?.aborted) throw new Error('aborted')
if (paceMs > 0) await paceDelay(paceMs, signal)
yield chunk
}
throw new LlmError(entry.message, entry.code)
@@ -332,14 +379,17 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
* next ordered recorded script, then advances its own cursor synchronously at
* invocation time; calls without `sessionId` share one anonymous session. A
* non-empty provider catalog registers a routed replay adapter; otherwise a
* catch-all waterfall intercepts requests. Returns the effect disposer for
* HMR-safe removal.
* catch-all waterfall intercepts requests.
*
* @param ctx - the context whose LLM service receives the replay route or waterfall.
* @param config - the resolved fixture paths (env-var defaulting is `apply`'s job).
* @returns the disposer that removes the registered adapter or listener.
* @returns the {@link ReplayHandle} carrying the disposer and the teardown consumption check.
*/
export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHandle {
const paceMs = config.paceMs ?? 0
if (!Number.isInteger(paceMs) || paceMs < 0) {
throw new Error(`llm-replay: paceMs must be a non-negative integer, got ${String(config.paceMs)}`)
}
const scripts = loadSessionScripts(config)
// Live-session → its bound script + cursor. A new live session id claims the
// next not-yet-bound script (scripts are in bind order); `nextScript` is the
@@ -383,14 +433,31 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void
+ `but its script has only ${boundState.entries.length}; re-record the scenario`,
)
}
yield* replayEntry(entry, options.signal)
yield* replayEntry(entry, options.signal, paceMs)
})()
}
const providers = config.providers ?? []
if (providers.length > 0) {
return ctx.llm.registerAdapter(providers.map(provider => provider.id), new ReplayAdapter(providers, replay))
const dispose = providers.length > 0
? ctx.llm.registerAdapter(providers.map(provider => provider.id), new ReplayAdapter(providers, replay))
: ctx.on('llm/stream', (options: GenerateOptions, _next) => replay(options))
return {
dispose,
assertConsumed(): void {
const problems: string[] = []
if (nextScript < scripts.length) {
problems.push(`${scripts.length - nextScript} recorded script(s) never bound to a live session`)
}
for (const [key, state] of bound) {
if (state.cursor < state.entries.length) {
const who = key === ANON ? 'the anonymous session' : `session ${key}`
problems.push(`${who} consumed ${state.cursor}/${state.entries.length} recorded call(s)`)
}
}
if (problems.length > 0) {
throw new Error(`llm-replay: fixture not fully consumed — ${problems.join('; ')}; the scenario drove fewer model calls than recorded`)
}
},
}
return ctx.on('llm/stream', (options: GenerateOptions, _next) => replay(options))
}
export const name = 'llm-replay'
@@ -410,6 +477,8 @@ export interface Config {
childFiles?: string[]
/** Optional replay-only provider catalog; absent or empty selects catch-all waterfall replay. */
providers?: ReplayProviderConfig[]
/** Optional per-chunk pacing delay in ms (see {@link ReplayConfig.paceMs}); absent keeps burst yield. */
paceMs?: number
}
export function apply(ctx: Context, config: Config = {}): void {
@@ -426,5 +495,6 @@ export function apply(ctx: Context, config: Config = {}): void {
...overrideFile !== undefined && overrideFile.length > 0 ? { overrideFile } : {},
...childFiles.length > 0 ? { childFiles } : {},
...config.providers !== undefined ? { providers: config.providers } : {},
...config.paceMs !== undefined ? { paceMs: config.paceMs } : {},
})
}

View File

@@ -234,7 +234,7 @@ describe('installLlmReplay (through the real LlmService)', () => {
writeLog(TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
const dispose = installLlmReplay(ctx, {
const { dispose } = installLlmReplay(ctx, {
file,
providers: [
{
@@ -433,6 +433,92 @@ describe('installLlmReplay (through the real LlmService)', () => {
await iterator.next()
await expect(iterator.next()).rejects.toThrow('aborted')
})
it('rejects a paceMs that is not a non-negative integer', async () => {
writeLog(TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
expect(() => installLlmReplay(ctx, { file, paceMs: -1 })).toThrow(/paceMs/)
expect(() => installLlmReplay(ctx, { file, paceMs: 1.5 })).toThrow(/paceMs/)
})
it('paces chunk yields when paceMs is set (each chunk waits at least the pace)', async () => {
writeLog(TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
installLlmReplay(ctx, { file, paceMs: 10 })
const started = performance.now()
const chunks = await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))
expect(chunks).toEqual(TEXT_CHUNKS)
// N chunks × 10ms; allow generous scheduling slack, assert the floor only.
expect(performance.now() - started).toBeGreaterThanOrEqual(TEXT_CHUNKS.length * 10 - 5)
})
it('aborting DURING a pace wait cancels the stream promptly', async () => {
writeLog(TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
installLlmReplay(ctx, { file, paceMs: 60_000 })
const controller = new AbortController()
const pending = drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [], signal: controller.signal }))
// Let the generator park inside the pace timer, then abort — the reject
// must come from the abort listener, not the (distant) timer.
await new Promise(r => setImmediate(r))
controller.abort()
await expect(pending).rejects.toThrow('aborted')
})
it('assertConsumed passes only after every recorded call replayed', async () => {
writeLog(TEXT_CHUNKS, TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
const handle = installLlmReplay(ctx, { file })
await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))
// One of two recorded calls consumed — the underrun must name the gap.
expect(() => { handle.assertConsumed() }).toThrow(/consumed 1\/2 recorded call/)
await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))
expect(() => { handle.assertConsumed() }).not.toThrow()
})
it('paces a throw-entry prefix too (the recorded partial streams at the same cadence)', 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: 'boom', code: 'STREAM_CLOSED' },
]), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
installLlmReplay(ctx, { file, overrideFile, paceMs: 10 })
const started = performance.now()
await expect(drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).rejects.toThrow('boom')
expect(performance.now() - started).toBeGreaterThanOrEqual(5)
})
it('assertConsumed names an underrunning identified session by its id', async () => {
writeLog(TEXT_CHUNKS, TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
const handle = installLlmReplay(ctx, { file })
const sessionId = 'live-underrun' as NonNullable<GenerateOptions['sessionId']>
await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [], sessionId }))
expect(() => { handle.assertConsumed() }).toThrow(/session live-underrun consumed 1\/2/)
})
it('assertConsumed reports recorded scripts no live session ever bound', async () => {
writeLog(TEXT_CHUNKS)
const childFile = join(dir, 'session.1.jsonl')
writeFileSync(childFile, sessionJsonl(
TEXT_CHUNKS.map((chunk, i) => chunkEvent(i + 1, 1, 1, chunk)),
{ id: 'child', createdAt: 10 },
), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
const handle = installLlmReplay(ctx, { file, childFiles: [childFile] })
await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [], sessionId: 'live-parent' as NonNullable<GenerateOptions['sessionId']> }))
// The child script never bound: the scenario drove fewer sessions than recorded.
expect(() => { handle.assertConsumed() }).toThrow(/1 recorded script\(s\) never bound/)
})
})
describe('parseSessionHeader', () => {
@@ -633,7 +719,7 @@ describe('apply (the plugin entry)', () => {
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }] })
apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }], paceMs: 1 })
expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }])
expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
})

View File

@@ -5,7 +5,7 @@ import {
resolveExampleMode,
} from '@deepseek-ai/dsh-loader-smoke'
const SRC_BIN = '/repo/packages/examples/tui-demo/src/bin.ts'
const SRC_BIN = '/repo/packages/examples/cli-demo/src/bin.ts'
const TSCONFIG = '/repo/tsconfig.json'
const originalMode = process.env[EXAMPLE_MODE_ENV]
@@ -65,7 +65,7 @@ describe('resolveExampleLaunch', () => {
env: { DSH_HOME: '/tmp/home' },
})
expect(args).not.toContain('--import')
expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js')
expect(args).toContain('/repo/packages/examples/cli-demo/lib/bin.js')
expect(args.slice(-2)).toEqual(['--config', './cordis.yml'])
expect(env.TSX_TSCONFIG_PATH).toBeUndefined()
expect(env.DSH_HOME).toBe('/tmp/home')
@@ -100,6 +100,6 @@ describe('resolveExampleLaunch', () => {
it('defaults the mode from the environment', () => {
process.env[EXAMPLE_MODE_ENV] = 'lib'
const { args } = resolveExampleLaunch({ srcBin: SRC_BIN })
expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js')
expect(args).toContain('/repo/packages/examples/cli-demo/lib/bin.js')
})
})