diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml index 4eea19c9c0..c9cebc9db9 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-25-scriptable-llm-wire-fault-server.md: 92f7d6aad8e7b4dc8bb08e98bb5847ff27470229 -2026-07-25-scriptable-llm-wire-fault-server.zh.md: 2f5fcc1321b0e4f501f3814e5e96d4b26cf18ec6 +2026-07-25-scriptable-llm-wire-fault-server.md: 0795f71f0eab1a107740aaa8cba6fa04b1fbd306 +2026-07-25-scriptable-llm-wire-fault-server.zh.md: a0e3c98d729adcc74e6d98933ab2beb538f71cb3 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md index 92f7d6aad8..0795f71f0e 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md @@ -18,11 +18,11 @@ Request behaviors cover socket reset, post-header disconnect, partial disconnect The `random` script entry performs a new weighted selection for every request. The server exposes and logs its unsigned 32-bit seed, accepts caller-supplied relative weights, and ships a success-heavy stress profile that mixes transport, protocol, provider, timeout, and semantic-empty outcomes. The profile is configurable test pressure rather than an estimate of production incident frequency; `connection_refused` remains outside the request-level pool. -The server reports wire facts only and does not classify retryability. Real-composition tests route it through `dsh-llm-deepseek`, `dsh-agent-loop`, and `dsh-llm-retry`: connection refusal, hard disconnect, partial reset, and idle timeout recover under the existing default policy; a valid content-less completion succeeds without retry; clean partial EOF remains `STREAM_CLOSED` and is not retried by default. The package does not change those policies. +The server reports wire facts only and does not classify retryability. Real-composition tests route it through `dsh-llm-deepseek`, `dsh-agent-loop`, and `dsh-llm-retry`: connection refusal, hard disconnect, partial reset, idle timeout, and a valid content-less completion recover under the existing default policy; clean partial EOF remains `STREAM_CLOSED` and is not retried by default. The package does not change those policies. ## Verification -Package tests exercise every request behavior, HTTP validation without script consumption, script exhaustion/repetition, stalled-connection teardown, CLI parsing, random seed reproducibility, weight validation, telemetry, lifecycle cleanup, and the invariant companion under the per-file coverage gate. The retry integration suite proves exact request counts, numbered retry steps, request-body identity, failed partial-chunk isolation, empty-success semantics, clean-EOF classification, timeout recovery, true refused-connection recovery after delayed listener startup, and bounded exhaustion through the real HTTP/SSE adapter. +Package tests exercise every request behavior, split UTF-8 request decoding, HTTP validation without script consumption, script exhaustion/repetition, stalled-connection teardown, CLI parsing and delay bounds, IPv6 base URLs, random seed reproducibility, weight validation, single-result telemetry, lifecycle cleanup, and the invariant companion under the per-file coverage gate. The retry integration suite proves exact request counts, numbered retry steps, request-body identity, failed partial-chunk isolation, semantic-empty recovery, clean-EOF classification, timeout recovery, true refused-connection recovery after delayed listener startup, and bounded exhaustion through the real HTTP/SSE adapter. ## Alternatives considered @@ -32,10 +32,10 @@ Package tests exercise every request behavior, HTTP validation without script co **Use only an in-process `LlmAdapter` mock** — rejected because it bypasses fetch, HTTP status/header parsing, SSE framing, socket termination, and the adapter idle watchdog: the exact boundaries this test infrastructure exists to exercise. -**Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Adding `STREAM_CLOSED` or semantic-empty recovery requires a separate decision with its own cost, latency, and duplicate-generation trade-offs. +**Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Extending recovery to `STREAM_CLOSED` requires a separate decision with its own cost, latency, and duplicate-generation trade-offs. ## Consequences -Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and successful empty completions without splicing attempts or modifying model history. +Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and recovered empty completions without splicing attempts or modifying model history. The server adds a support package, executable build entry, and behavior vocabulary that must remain compatible with both direct tests and CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval. diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md index 2f5fcc1321..a0e3c98d72 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md @@ -18,11 +18,11 @@ Status: implemented 脚本项 `random` 会为每个请求重新执行一次加权选择。服务器公开并记录其无符号 32 位 seed,允许调用方提供相对权重,并内置一套偏重成功结果的压力测试配置,将传输、协议、提供方、超时和语义空结果混合在一起。该配置用于提供可调的测试压力,并非对生产事故发生频率的估算;`connection_refused` 仍不进入请求级随机池。 -服务器只报告协议层事实,不判断是否可重试。真实组合测试让请求依次经过 `dsh-llm-deepseek`、`dsh-agent-loop` 和 `dsh-llm-retry`:在现有默认策略下,连接遭拒、硬断开、部分输出后重置以及空闲超时均可恢复;合法的无内容完成无需重试即可成功;正常关闭的部分输出 EOF 仍归类为 `STREAM_CLOSED`,默认不重试。该包不会改变这些策略。 +服务器只报告协议层事实,不判断是否可重试。真实组合测试让请求依次经过 `dsh-llm-deepseek`、`dsh-agent-loop` 和 `dsh-llm-retry`:在现有默认策略下,连接遭拒、硬断开、部分输出后重置、空闲超时以及合法的无内容完成均可恢复;正常关闭的部分输出 EOF 仍归类为 `STREAM_CLOSED`,默认不重试。该包不会改变这些策略。 ## 验证 -包测试覆盖所有请求行为、不消耗脚本的 HTTP 校验、脚本耗尽与重复、停滞连接清理、CLI 解析、随机 seed 可复现性、权重校验、遥测、生命周期清理,以及逐文件覆盖率门禁下的配套不变式插件。重试集成套件通过真实 HTTP/SSE(Server-Sent Events)适配器,验证准确的请求次数、带编号的重试步骤、请求体完全一致、失败的部分分片不会泄漏、空完成成功语义、正常 EOF 分类、超时恢复、监听器延迟启动后从真实连接遭拒中恢复,以及有界重试耗尽。 +包测试覆盖所有请求行为、跨分片 UTF-8 请求解码、不消耗脚本的 HTTP 校验、脚本耗尽与重复、停滞连接清理、CLI 解析及延迟边界、IPv6 base URL、随机 seed 可复现性、权重校验、单结果遥测、生命周期清理,以及逐文件覆盖率门禁下的配套不变式插件。重试集成套件通过真实 HTTP/SSE(Server-Sent Events)适配器,验证准确的请求次数、带编号的重试步骤、请求体完全一致、失败的部分分片不会泄漏、语义空结果恢复、正常 EOF 分类、超时恢复、监听器延迟启动后从真实连接遭拒中恢复,以及有界重试耗尽。 ## 曾考虑的替代方案 @@ -32,10 +32,10 @@ Status: implemented **仅使用进程内的 `LlmAdapter` mock**:不予采纳。它会绕过 fetch、HTTP 状态与 header 解析、SSE 分帧、socket 终止以及适配器的空闲看门狗,而这正是这套测试基础设施要覆盖的边界。 -**随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否为 `STREAM_CLOSED` 或语义空结果增加恢复能力,需要单独决策,并权衡成本、延迟和重复生成风险。 +**随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否将恢复能力扩展到 `STREAM_CLOSED`,需要单独决策,并权衡成本、延迟和重复生成风险。 ## 后果 -开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed,让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与成功空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。 +开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed,让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与恢复后的空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。 服务器新增了一个支持包、可执行构建入口和行为词汇,三者必须同时兼容直接测试与 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。 diff --git a/docs/module-graph.md b/docs/module-graph.md index 9e8b3adf8a..ca1c58c101 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -117,6 +117,7 @@ flowchart TD pkg_acp_snapshot["acp-snapshot"] pkg_agent_loop_testkit["agent-loop-testkit"] pkg_invariants["invariants"] + pkg_llm_mock_server["llm-mock-server"] pkg_llm_replay["llm-replay"] pkg_loader_smoke["loader-smoke"] end @@ -224,6 +225,7 @@ flowchart TD pkg_skill --> pkg_invariants pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants + pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants pkg_client_i18n --> pkg_invariants pkg_client_modules --> pkg_invariants @@ -797,6 +799,7 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | +| [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | | [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 790e3e22f7..a93bbbc4b6 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -140,8 +140,11 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(finalAssistantText(agent)).toBe('recovered response') }) - it('treats a wire-valid content-less completion as success without retrying', async () => { - const server = await start(['empty', 'success'], { apiKey: 'mock-key' }) + it('retries a wire-valid content-less completion without committing an empty message', async () => { + const server = await start(['empty', 'success'], { + apiKey: 'mock-key', + successText: 'recovered from empty', + }) context = await harness(server.baseURL) const agent = context.agentLoop.create(SessionId('wire-empty'), { provider: 'deepseek', @@ -150,16 +153,17 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) - expect(server.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({ - data: { turn: 1, step: 1, content: [] }, - }) + expect(server.requests).toHaveLength(2) + expect(server.requests[0]?.body).toEqual(server.requests[1]?.body) + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + .toEqual(['EMPTY_RESPONSE']) + expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step)) + .toEqual([2]) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) - expect(finalAssistantText(agent)).toBeUndefined() + expect(finalAssistantText(agent)).toBe('recovered from empty') }) it('exposes a clean partial EOF as non-default-retryable STREAM_CLOSED', async () => { diff --git a/packages/support/llm-mock-server/README.md b/packages/support/llm-mock-server/README.md index 20efe731d5..6fca303b47 100644 --- a/packages/support/llm-mock-server/README.md +++ b/packages/support/llm-mock-server/README.md @@ -2,7 +2,7 @@ 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, and a running handle with the bound `baseURL`, generated or configured `randomSeed`, captured requests, and idempotent `close()`. Closing force-terminates stalled connections. +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 @@ -67,7 +67,7 @@ When random weights include `stall`, configure the client under test with a shor ## 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`. The library accepts the same camel-case options. An optional exact `apiKey` validates `Authorization: Bearer `; omission accepts any token. +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 `; omission accepts any token. ## Model Experience diff --git a/packages/support/llm-mock-server/src/cli.ts b/packages/support/llm-mock-server/src/cli.ts index 12d10072f2..786a74c0f4 100644 --- a/packages/support/llm-mock-server/src/cli.ts +++ b/packages/support/llm-mock-server/src/cli.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-llm-mock-server/cli */ -import { MOCK_LLM_BEHAVIORS } from './index.ts' +import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts' import type { ConcreteMockLlmBehavior, MockLlmBehavior, @@ -18,7 +18,7 @@ export const CONNECTION_REFUSED_BEHAVIOR = 'connection_refused' export interface MockLlmCliConfig { /** Server options after removing the lifecycle-only `connection_refused` entry. */ readonly server: MockLlmServerOptions - /** Delay before binding the model port; zero starts immediately. */ + /** 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 @@ -77,6 +77,14 @@ function numberValue(option: string, value: string): 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)) { @@ -154,7 +162,9 @@ export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseRes case '--host': host = value; break case '--port': port = numberValue(option, value); break case '--api-key': apiKey = value; break - case '--listen-delay-ms': listenDelayMs = numberValue(option, 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 diff --git a/packages/support/llm-mock-server/src/index.ts b/packages/support/llm-mock-server/src/index.ts index 45dda839e2..b07a2bb09b 100644 --- a/packages/support/llm-mock-server/src/index.ts +++ b/packages/support/llm-mock-server/src/index.ts @@ -9,7 +9,7 @@ import { createServer } from 'node:http' import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http' import { randomBytes } from 'node:crypto' -import type { AddressInfo } from 'node:net' +import { isIP, type AddressInfo } from 'node:net' import { setTimeout as delay } from 'node:timers/promises' /** Request-scoped behaviors accepted by {@link startMockLlmServer}. */ @@ -69,6 +69,9 @@ export const DEFAULT_MOCK_LLM_RANDOM_WEIGHTS: Readonly = O 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' @@ -186,7 +189,6 @@ interface ResolvedOptions { readonly onEvent?: (event: MockLlmServerEvent) => void } -const MAX_TIMER_DELAY_MS = 2_147_483_647 const DEFAULT_SUCCESS_TEXT = 'mock response recovered' const DEFAULT_PARTIAL_TEXT = 'discarded partial response' const DEFAULT_REASONING_TEXT = 'mock reasoning' @@ -203,14 +205,24 @@ 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_TIMER_DELAY_MS) + const chunkDelayMs = boundedInteger( + 'chunkDelayMs', + options.chunkDelayMs ?? 25, + 0, + MAX_MOCK_LLM_TIMER_DELAY_MS, + ) const disconnectDelayMs = boundedInteger( 'disconnectDelayMs', options.disconnectDelayMs ?? 10, 0, - MAX_TIMER_DELAY_MS, + MAX_MOCK_LLM_TIMER_DELAY_MS, + ) + const retryAfterMs = boundedInteger( + 'retryAfterMs', + options.retryAfterMs ?? 1_000, + 1, + MAX_MOCK_LLM_TIMER_DELAY_MS, ) - const retryAfterMs = boundedInteger('retryAfterMs', options.retryAfterMs ?? 1_000, 1, MAX_TIMER_DELAY_MS) const randomSeed = boundedInteger( 'randomSeed', options.randomSeed ?? randomBytes(4).readUInt32LE(0), @@ -285,8 +297,9 @@ function emit(options: ResolvedOptions, event: MockLlmServerEvent): void { } async function readJsonBody(request: IncomingMessage): Promise { - let body = '' - for await (const chunk of request) body += Buffer.from(chunk).toString('utf8') + 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) } @@ -320,6 +333,7 @@ function finishRecord( record: MockLlmRequestRecord, outcome: MockLlmRequestOutcome, ): void { + if (record.outcome !== undefined) return record.outcome = outcome emit(options, { type: 'result', @@ -713,8 +727,9 @@ export async function startMockLlmServer(options: MockLlmServerOptions): Promise }) const address = server.address() as AddressInfo + const advertisedHost = isIP(resolved.host) === 6 ? `[${resolved.host}]` : resolved.host return { - baseURL: `http://${resolved.host}:${address.port}`, + baseURL: `http://${advertisedHost}:${address.port}`, port: address.port, randomSeed: resolved.randomSeed, requests, diff --git a/packages/support/llm-mock-server/tests/cli.spec.ts b/packages/support/llm-mock-server/tests/cli.spec.ts index 66a3868963..12c5bd6926 100644 --- a/packages/support/llm-mock-server/tests/cli.spec.ts +++ b/packages/support/llm-mock-server/tests/cli.spec.ts @@ -110,6 +110,9 @@ describe('mock LLM server CLI parser', () => { [['--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/], diff --git a/packages/support/llm-mock-server/tests/server.spec.ts b/packages/support/llm-mock-server/tests/server.spec.ts index b84931bc9f..1f15ba1a0b 100644 --- a/packages/support/llm-mock-server/tests/server.spec.ts +++ b/packages/support/llm-mock-server/tests/server.spec.ts @@ -1,3 +1,4 @@ +import { request } from 'node:http' import { afterEach, describe, expect, it } from 'vitest' import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts' import { startMockLlmServer } from '../src/index.ts' @@ -32,6 +33,22 @@ function chat( }) } +function rawChat(server: MockLlmServer, chunks: readonly Buffer[]): Promise { + 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[] = [] @@ -151,10 +168,12 @@ describe('mock LLM server wire behaviors', () => { ['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 }) @@ -163,6 +182,30 @@ describe('mock LLM server wire behaviors', () => { await new Promise((resolve) => { setTimeout(resolve, 5) }) 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 () => {