Merge commit 'refs/codex-unblock/20260727/pr660-master' into worktree/pr660-merge-20260727

# Conflicts:
#	scripts/doc-budgets.manifest.json
This commit is contained in:
Tianyi Cui
2026-07-27 19:34:55 +08:00
54 changed files with 962 additions and 796 deletions

View File

@@ -1,7 +1,7 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
/**
@@ -36,12 +36,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
console.log(JSON.stringify(result))
process.exit(0)
`
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
cwd: pkgDir,
stdin: 'ignore',
timeout: 55_000,
killSignal: 'SIGKILL',
reject: false,
})
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
const lastLine = stdout.trim().split('\n').at(-1) ?? ''

View File

@@ -17,6 +17,7 @@ import {
import { Readable, Writable } from 'node:stream'
import { promisify } from 'node:util'
import { zstdDecompress } from 'node:zlib'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
/**
@@ -211,25 +212,19 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
}, 30_000)
})
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, [acpBin, '--config', configArg], {
cwd,
env: {
...process.env,
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
})
child = proc
let stderr = ''
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (c: string) => { stderr += c })
const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000)
proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) })
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
proc.stdin.end()
/** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */
async function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
const result = await execa(process.execPath, [acpBin, '--config', configArg], {
cwd,
env: {
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
input: '',
timeout: 25_000,
killSignal: 'SIGKILL',
reject: false,
})
if (result.timedOut) throw new Error(`bin did not exit within 25s. stderr:\n${result.stderr}`)
return { code: result.exitCode ?? -1, stderr: result.stderr }
}

View File

@@ -1,4 +1,3 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -6,6 +5,7 @@ import { dirname, join } from 'node:path'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { zstdDecompress } from 'node:zlib'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
/**
@@ -116,36 +116,34 @@ interface BinResult {
readonly stderr: string
}
function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, [cliBin, ...args], {
cwd,
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
const subprocess = execa(process.execPath, [cliBin, ...args], {
cwd,
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdin: 'ignore',
timeout: 25_000,
killSignal: 'SIGKILL',
reject: false,
stripFinalNewline: false,
})
// Genuinely custom mid-stream logic: the signal cases deliver `interrupt`
// once the first streamed chunk proves the turn is in flight.
if (interrupt !== undefined) {
let streamed = ''
let interrupted = false
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
stdout += chunk
if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) {
subprocess.stdout.on('data', (chunk: Buffer) => {
streamed += chunk.toString('utf8')
if (!interrupted && streamed.includes('assistant/chunk')) {
interrupted = true
child.kill(interrupt)
subprocess.kill(interrupt)
}
})
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 25_000)
child.once('error', (error) => { clearTimeout(timer); reject(error) })
child.once('exit', (code, signal) => {
clearTimeout(timer)
resolveResult({ code: code ?? -1, signal, stdout, stderr })
})
})
}
const result = await subprocess
if (result.timedOut) {
throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr }
}
let consumer: string | undefined

View File

@@ -1,9 +1,9 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
/**
@@ -60,12 +60,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
console.log(JSON.stringify(result))
await ctx.fiber.dispose()
`
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
cwd: pkgDir,
stdin: 'ignore',
timeout: 55_000,
killSignal: 'SIGKILL',
reject: false,
})
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
const lastLine = stdout.trim().split('\n').at(-1) ?? ''

View File

@@ -90,6 +90,8 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', ()
XDG_DATA_HOME: join(cacheRoot, 'data'),
npm_config_cache: join(cacheRoot, 'npm'),
...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore },
// A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable.
...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {},
}
await execFileAsync(name, manager.installCommand(), {
cwd: root,

View File

@@ -1,10 +1,10 @@
import { spawn } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import SessionStore, {
SessionId, TOOL_OUTCOME_UNKNOWN,
type SessionEvent,
@@ -19,22 +19,21 @@ const roots: string[] = []
const CHILD_FAILPOINT_TIMEOUT_MS = 30_000
async function waitForMarker(path: string, expected: string): Promise<string> {
const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS
for (;;) {
try {
const content = await readFile(path, 'utf8')
if (content === expected) return content
if (!expected.startsWith(content)) {
throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
}
} catch (error: unknown) {
// vi.waitFor retries every callback throw, so terminal states RESOLVE out
// of the retry loop (complete marker, or content that can no longer become
// the expected marker) and only the still-in-progress states throw-to-retry.
const content = await vi.waitFor(async () => {
const current = await readFile(path, 'utf8').catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
if (Date.now() >= deadline) {
throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`)
}
await new Promise(resolve => setTimeout(resolve, 10))
throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error })
})
if (current === expected || !expected.startsWith(current)) return current
throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`)
}, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS })
if (content !== expected) {
throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
}
return content
}
async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> {
@@ -44,26 +43,24 @@ async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; marker
// Keep the open-before-write window deterministic: readiness is marker content, not path existence.
await writeFile(marker, '')
const expectedMarker = mode === 'request' ? 'request-dispatched' : 'tool-side-effect'
const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
// The SIGKILL-at-failpoint choreography stays custom: the child must die
// mid-write, so no timeout or graceful termination may reach it first.
const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
cwd: repoRoot,
env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
stdio: ['ignore', 'ignore', 'pipe'],
env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
stdin: 'ignore',
stdout: 'ignore',
reject: false,
})
let stderr = ''
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
try {
const markerText = await waitForMarker(marker, expectedMarker)
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once('close', (code, signal) => { resolve({ code, signal }) })
})
child.kill('SIGKILL')
const exit = await closed
expect(exit).toEqual({ code: null, signal: 'SIGKILL' })
const exit = await child
expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' })
return { root, markerText }
} catch (error: unknown) {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
throw new Error(`crash child failed: ${stderr}`, { cause: error })
child.kill('SIGKILL')
throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error })
}
}

View File

@@ -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
README.md: d35872e5bb06be88dc5999bfa1800083b2fbbf3c
README.zh.md: a706b6db5408538c578cb2a1cfc3aa99804a930d
README.md: d22e6e2d95a1ed930a7f4876daf4b06e2f761f7a
README.zh.md: 514b7ebfe02cb34ed559633a0fd82cf6194fa4b3

View File

@@ -57,7 +57,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/archived/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). 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.
Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher 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

@@ -57,7 +57,7 @@ defineAcpSnapshotSuite({
示例还发布 `cordis.snapshot.yml` 回放 overlay位于 `cordis.yml` 旁边bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM并重写已记录场景的模型 fixture`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay并从已提交模型脚本重写 stdout、可比较会话日志预期输出以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。
约束:`suite.ts` 导入 vitest,因此包入口只能在 vitest 运行中导入(启动器、harness 和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once``reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
约束:`suite.ts` `harness.ts` 导入 vitestharness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once``reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
## 模型体验

View File

@@ -21,7 +21,7 @@ import { existsSync, realpathSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { basename, dirname, join, delimiter } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { vi } from 'vitest'
import {
ClientSideConnection,
PROTOCOL_VERSION,
@@ -457,17 +457,25 @@ async function waitForPersistedTurnStart(
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
minimumTurn?: number,
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (true) {
let invalidRecord: { error: unknown } | undefined
await vi.waitFor(async () => {
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
const openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
if (openTurn !== undefined && (minimumTurn === undefined || openTurn >= minimumTurn)) return
if (Date.now() >= deadline) {
let openTurn: number | undefined
try {
openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
} catch (error) {
// A malformed persisted record is a scenario bug, not a not-yet state:
// vi.waitFor retries every callback throw, so capture the validation
// failure, resolve the wait, and rethrow immediately below.
invalidRecord = { error }
return
}
if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) {
const detail = minimumTurn === undefined ? 'turn/start' : `turn/start at or beyond turn ${minimumTurn}`
throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`)
}
await delay(WAIT_POLL_INTERVAL_MS)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
if (invalidRecord !== undefined) throw invalidRecord.error
}
/**
@@ -481,15 +489,12 @@ async function waitForPersistedTurnEnd(
sessionId: string,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (true) {
await vi.waitFor(async () => {
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
if (log !== undefined && latestTurnIsClosed(log.content)) return
if (Date.now() >= deadline) {
if (log === undefined || !latestTurnIsClosed(log.content)) {
throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`)
}
await delay(WAIT_POLL_INTERVAL_MS)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Wait for a cwd-relative marker proving an external action reached readiness. */
@@ -499,13 +504,11 @@ async function waitForWorkspaceFile(
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
): Promise<void> {
const target = join(cwd, path)
const deadline = Date.now() + timeoutMs
while (!existsSync(target)) {
if (Date.now() >= deadline) {
await vi.waitFor(() => {
if (!existsSync(target)) {
throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`)
}
await delay(WAIT_POLL_INTERVAL_MS)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Return whether the last complete raw-JSONL turn boundary closes its turn. */

View File

@@ -3,6 +3,7 @@
* @module @deepseek-ai/dsh-llm-mock-server/cli
*/
import { parseArgs } from 'node:util'
import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts'
import type {
ConcreteMockLlmBehavior,
@@ -63,14 +64,6 @@ 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`)
@@ -122,66 +115,64 @@ function parseRandomWeights(raw: string): MockLlmRandomWeights {
return weights
}
/** parseArgs vocabulary: every documented flag; only `--repeat-last` and `--help` are boolean. */
const CLI_OPTIONS = {
'sequence': { type: 'string' },
'host': { type: 'string' },
'port': { type: 'string' },
'api-key': { type: 'string' },
'listen-delay-ms': { type: 'string' },
'repeat-last': { type: 'boolean' },
'seed': { type: 'string' },
'random-weights': { type: 'string' },
'success-text': { type: 'string' },
'partial-text': { type: 'string' },
'reasoning-text': { type: 'string' },
'chunk-size': { type: 'string' },
'chunk-delay-ms': { type: 'string' },
'disconnect-delay-ms': { type: 'string' },
'retry-after-ms': { type: 'string' },
'request-id': { type: 'string' },
'tool-name': { type: 'string' },
'tool-arguments': { type: 'string' },
} as const
/**
* Parse standalone server arguments without starting a process or listener.
* Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric
* coercion, bounds, and cross-option constraints remain manual below it.
* @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
const { values } = parseArgs({ args: [...argv], options: CLI_OPTIONS, strict: true, allowPositionals: false })
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)}`)
}
}
const host = values.host
const port = values.port === undefined ? 8_000 : numberValue('--port', values.port)
const apiKey = values['api-key']
const listenDelayMs = values['listen-delay-ms'] === undefined
? undefined
: boundedIntegerValue('--listen-delay-ms', values['listen-delay-ms'], 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
const repeatLast = values['repeat-last'] ?? false
const randomSeed = values.seed === undefined ? undefined : numberValue('--seed', values.seed)
const randomWeights = values['random-weights'] === undefined ? undefined : parseRandomWeights(values['random-weights'])
const successText = values['success-text']
const partialText = values['partial-text']
const reasoningText = values['reasoning-text']
const chunkSize = values['chunk-size'] === undefined ? undefined : numberValue('--chunk-size', values['chunk-size'])
const chunkDelayMs = values['chunk-delay-ms'] === undefined ? undefined : numberValue('--chunk-delay-ms', values['chunk-delay-ms'])
const disconnectDelayMs = values['disconnect-delay-ms'] === undefined
? undefined
: numberValue('--disconnect-delay-ms', values['disconnect-delay-ms'])
const retryAfterMs = values['retry-after-ms'] === undefined ? undefined : numberValue('--retry-after-ms', values['retry-after-ms'])
const requestId = values['request-id']
const toolName = values['tool-name']
const toolArguments = values['tool-arguments']
if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
if (values.sequence === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
const sequenceRaw = values.sequence
const parsedSequence = parseSequence(sequenceRaw)
if (parsedSequence.startsUnavailable && port === 0) {
throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port')

View File

@@ -101,8 +101,11 @@ describe('mock LLM server CLI parser', () => {
it.each([
[[], /--sequence is required/],
[['--wat'], /requires a value/],
[['--wat', 'x'], /unknown option/],
// Tokenizer-level failures carry node:util parseArgs's own messages.
[['--wat'], /Unknown option '--wat'/],
[['--wat', 'x'], /Unknown option '--wat'/],
[['--port'], /Option '--port <value>' argument missing/],
[['--sequence', 'success', 'stray'], /Unexpected argument 'stray'/],
[['--port', 'NaN', '--sequence', 'success'], /finite number/],
[['--sequence', 'success,'], /non-empty/],
[['--sequence', 'success,connection_refused'], /only as the first/],
@@ -110,7 +113,8 @@ 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/],
// `=` syntax: a space-separated leading-dash value is a tokenizer error, not a bounds probe.
[['--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/],

View File

@@ -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
README.md: 8e53550608037a3c9a272db825933b7224ab24db
README.zh.md: 5310429ab59cf3cd04ac024746f5ed557e003637
README.md: 73610ce50ebac4c6fc7bb9135f7b41b347c60685
README.zh.md: 17f8481220136e8edf9fccd23fabfca5ccf41dfc

View File

@@ -19,5 +19,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`.
- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it.
- **Captured stdout and stderr are bounded only by execa's default 100 MB `maxBuffer`** — a runaway child is terminated at that ceiling rather than at a smoke-chosen budget.
- **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup.

View File

@@ -19,5 +19,5 @@
## 已知限制与待完成工作
- **构建 mode 需要事先构建**:配置还必须能够通过 `examples/node_modules` 向上解析每个命名包。
- **捕获的 stdout 和 stderr 无界**:失控子进程可以消耗内存,直到 deadline 将其终止
- **捕获的 stdout 和 stderr 仅受 execa 默认 100 MB `maxBuffer` 约束**:失控子进程会在该上限处被终止,而不是在冒烟测试自选的预算处
- **超时只终止直接子进程**:故障 fixture 生成的进程树可以比冒烟测试存活更久,需要外部清理。

View File

@@ -27,6 +27,7 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"execa": "^10.0.0",
"tsx": "^4.22.4"
},
"peerDependencies": {

View File

@@ -11,10 +11,10 @@
* @module @deepseek-ai/dsh-loader-smoke
*/
import { spawn } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execa } from 'execa'
const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
@@ -171,53 +171,27 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<Loade
tsconfigPath: options.tsconfigPath,
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env },
})
const result = await new Promise<LoaderSmokeResult>((resolve, reject) => {
const child = spawn(launch.command, launch.args, {
cwd,
env: { ...process.env, ...launch.env },
stdio: ['pipe', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let deferredFailure: Error | undefined
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => { stdout += chunk })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)
child.kill('SIGKILL')
}, processTimeoutMs)
child.once('exit', (code) => {
clearTimeout(timer)
if (deferredFailure !== undefined) {
reject(deferredFailure)
} else if (code === 0) {
resolve({ stdout, stderr })
} else {
reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
}
})
// process.execPath and a just-created pipe make these OS-error paths
// impractical to induce without replacing the boundary under test.
/* v8 ignore start */
child.once('error', (error) => {
clearTimeout(timer)
reject(new Error(`${options.label} failed to start: ${error.message}`))
})
child.stdin.once('error', (error) => {
deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`)
child.kill('SIGKILL')
})
/* v8 ignore stop */
child.stdin.end()
// `input: ''` writes nothing and closes stdin — the fixture-visible
// stdin-close contract. `reject: false` folds spawn errors, the SIGKILL
// deadline, and nonzero exits into independent result fields, so the
// diagnostics below embed both streams on every failure.
const result = await execa(launch.command, launch.args, {
cwd,
env: launch.env,
input: '',
timeout: processTimeoutMs,
killSignal: 'SIGKILL',
reject: false,
stripFinalNewline: false,
})
if (result.timedOut) {
throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
if (result.failed) {
throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
await options.inspect?.(cwd)
return result
return { stdout: result.stdout, stderr: result.stderr }
} finally {
await rm(cwd, { recursive: true, force: true })
}