Merge remote-tracking branch 'origin/worktree/ci-native-windows-20260808' into worktree/ci-native-windows-coverage-20260808

This commit is contained in:
Tianyi Cui
2026-08-09 02:15:46 +08:00
1062 changed files with 17432 additions and 9494 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 packages/scaffold/client/README.md
README.md: b33457875f81d11d09bab2e5aa5ce730e233c78a
README.zh.md: 271f07ffb0f97abe005971962beb517acfdc05a4

View File

@@ -0,0 +1,49 @@
# @deepseek-ai/dsh-sdk-client
English | [中文](README.zh.md)
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level owned-run API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — including the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend and automation — that know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern.
## DeepSeekHarness
```ts
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
await using harness = new DeepSeekHarness({
launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
maxTokens: 49_152,
})
const result = await harness.run('say hi')
console.log(result.finalResponse)
```
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle.
`run(input, { sessionId?, onNotification? })` owns one activity interval: it queues the prompt, waits until its `MessageId` appears in a durable `agent/inbox/spliced` receipt, then collects through the next whole-agent `idle`. It returns `RunResult { sessionId, finalResponse, events, notifications }`. `finalResponse` is the last committed root-session assistant text in that interval, not a response causally assigned to the prompt; steering, injected context, and other queued work may contribute before idle. `events` contains root-session events, while `notifications` also contains descendants discovered from `subagent.started`, all in wire order. The result carries no prompt-level status or turn reason. Transport loss, timeout, and protocol violations reject; model outcomes remain observable in the event stream without being attributed to one input.
## HarnessClient
The protocol client under the owned-run API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `prompt()` returns the queued message id as soon as the runtime accepts it; it never waits for agent activity. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
`close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse.
`HarnessClientOptions.env` replaces the child environment entirely when given (`undefined` inherits the parent's); callers own credential policy — `scrubbedParentEnv` from `dsh-subprocess` is the shared scrub base for isolation-minded launches.
## Model Experience
None, as this is a client-process library; the model runs in the spawned runtime, whose experience is owned by the plugins its `cordis.yml` composes.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No bundled-runtime resolution** — callers name the runtime executable explicitly; packaged-executable discovery stays Python-side until a TypeScript distribution consumer exists.
- **No mid-turn cancel** — the wire has no prompt-cancel method; abandoning a turn means closing the runtime (see the protocol's [Known Limitations](../protocol/README.md)).
- **No per-prompt result or cancel** — low-level `prompt()` returns only an enqueue receipt; high-level `run()` owns receipt-to-idle collection, and abandoning it means closing the runtime.
- **Client→server notifications and server→client requests are unimplemented** on both wire ends; the transport carries them for future approval flows.

View File

@@ -0,0 +1,49 @@
# @deepseek-ai/dsh-sdk-client
[English](README.md) | 中文
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层自有运行 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方,包括 [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端和自动化;它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。
## DeepSeekHarness
```ts
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
await using harness = new DeepSeekHarness({
launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
maxTokens: 49_152,
})
const result = await harness.run('say hi')
console.log(result.finalResponse)
```
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被回收。`start()` 记忆化 `initialize` 握手(工作区 cwd——在通过协议传输之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会回收运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄。
`run(input, { sessionId?, onNotification? })` 拥有一个活动区间:它将提示词排入队列,等待其 `MessageId` 出现在持久的 `agent/inbox/spliced` 回执中,然后持续收集到整个 agent 下一次进入 `idle`。它返回 `RunResult { sessionId, finalResponse, events, notifications }`。`finalResponse` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的响应;steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。`events` 包含根会话事件,`notifications` 还包含通过 `subagent.started` 发现的后代,均按协议传输顺序排列。结果不携带提示词级状态或轮次原因。传输丢失、超时和协议违例会导致 Promise 被拒绝;模型结果仍可在事件流中观察,但不会归属于某一输入。
## HarnessClient
自有运行 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 ID,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该 seam 所记录的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。
`HarnessClientOptions.env` 给定时整体替换子进程环境(`undefined` 原样继承父进程环境);凭据策略归调用方——`dsh-subprocess` 的 `scrubbedParentEnv` 是面向隔离启动的共享擦除基底。
## 模型体验
无,因为这是一个客户端进程库;模型运行在 spawn 出的运行时中,其体验由该运行时的 `cordis.yml` 所组合的插件决定。
#### KV Cache 影响
无;本包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **无捆绑运行时解析**——调用方显式指定运行时可执行文件;打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。
- **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../protocol/README.md))。
- **没有逐提示词结果或取消**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执收集到 idle,放弃该过程意味着关闭运行时。
- **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流保留了承载能力。

View File

@@ -0,0 +1,40 @@
{
"name": "@deepseek-ai/dsh-sdk-client",
"description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sdk-protocol": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,246 @@
/**
* High-level run API over {@link HarnessClient}: `DeepSeekHarness` owns one
* runtime subprocess across many sessions; `HarnessSession.run` sends a
* prompt and settles when the whole agent next becomes idle.
* Mirrors the Python SDK's `DeepSeekHarness`/`Session` pair.
*
* @module @deepseek-ai/dsh-sdk-client/api
*/
import { randomUUID } from 'node:crypto'
import { resolve } from 'node:path'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { HarnessClient, isRecord, SdkProtocolError } from './client.ts'
import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, RunResult } from './types.ts'
/**
* Reusable SDK for running DeepSeek Harness agent turns in a runtime
* subprocess. The subprocess starts lazily on first use and stays owned by
* this instance until {@link close}; always close (or `await using`) so the
* child is reaped.
*/
export class DeepSeekHarness implements AsyncDisposable {
private clientInstance: HarnessClient
private readonly launch: HarnessClientOptions
private readonly cwd: string
private readonly provider: string
private readonly model: string
private readonly maxTokens: number | undefined
private initialized: Promise<void> | undefined
private closed = false
/** @param options - runtime launch spec plus the session route (cwd/provider/model). */
constructor(options: DeepSeekHarnessOptions) {
this.launch = options.launch
this.clientInstance = new HarnessClient(options.launch)
// Absolute before the handshake: the child spawns relative to THIS
// process's cwd, but the wire cwd is resolved again inside the child — a
// relative value would double-resolve (e.g. `worker` → `worker/worker`).
this.cwd = resolve(options.cwd ?? options.launch.cwd ?? process.cwd())
this.provider = options.provider ?? 'deepseek-official'
this.model = options.model ?? 'deepseek-v4-flash'
this.maxTokens = options.maxTokens
}
/**
* The underlying JSON-RPC client (exposed for low-level access). A failed
* handshake reaps its runtime and swaps in a fresh instance, so do not
* cache this across a failed {@link start}.
* @returns the client currently owning the runtime subprocess.
*/
get client(): HarnessClient {
return this.clientInstance
}
/**
* Start the subprocess and perform the `initialize` handshake once. On
* failure the runtime is reaped and a fresh client replaces it
* (`HarnessClient.close` is permanent), so a later call retries with a new
* subprocess — unless {@link close} already ended this harness.
* @returns settlement of the (memoized) handshake.
*/
start(): Promise<void> {
this.initialized ??= (async () => {
try {
this.clientInstance.start()
await this.clientInstance.initialize({
cwd: this.cwd,
provider: this.provider,
model: this.model,
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
})
} catch (error) {
this.initialized = undefined
await this.clientInstance.close()
if (!this.closed) this.clientInstance = new HarnessClient(this.launch)
throw error
}
})()
return this.initialized
}
/**
* Open a session handle (no wire traffic; the runtime creates the session
* on its first prompt).
* @param sessionId - explicit id to reuse; omitted mints a fresh one.
* @returns the session handle.
*/
session(sessionId?: string): HarnessSession {
return new HarnessSession(this, sessionId ?? `session-${randomUUID().replaceAll('-', '')}`)
}
/**
* Run one prompt on a fresh (or named) session.
* @param input - prompt text, or content blocks sent verbatim.
* @param options - optional session id and per-notification observer.
* @returns the owned activity interval.
*/
run(input: string | ContentBlock[], options?: RunOptions): Promise<RunResult> {
return this.session(options?.sessionId).run(input, options)
}
/**
* Shut down and reap the runtime subprocess. Idempotent and terminal —
* a closed harness no longer retries a failed handshake.
* @returns settlement of the complete teardown.
*/
close(): Promise<void> {
this.closed = true
return this.clientInstance.close()
}
/**
* `await using` support: {@link close}.
* @returns settlement of the teardown.
*/
[Symbol.asyncDispose](): Promise<void> {
return this.close()
}
}
/** Per-run options: target session and streaming observer. */
export interface RunOptions {
/** Session id to run on; omitted mints a fresh session per call. */
sessionId?: string
/** Observer invoked with every notification for this session tree, in wire order. */
onNotification?: (notification: HarnessNotification) => void
}
/**
* One SDK session: a stable id plus owned activity intervals.
*/
export class HarnessSession {
/**
* @param harness - the owning harness (supplies the client and handshake).
* @param id - the wire session id this handle runs on.
*/
constructor(readonly harness: DeepSeekHarness, readonly id: string) {}
/**
* Queue one prompt, then observe the whole session through its next idle.
* @param input - prompt text, or content blocks sent verbatim.
* @param options - optional per-notification observer.
* @returns the owned activity interval; rejects on transport loss, timeout,
* or a protocol error.
*/
async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<RunResult> {
await this.harness.start()
const client = this.harness.client
const contentBlocks = normalizeInput(input)
const events: SessionEvent[] = []
const notifications: HarnessNotification[] = []
const subscription = client.subscribeSessionTree(this.id)
const collect = (notification: HarnessNotification): void => {
if (notification.method === 'session.event' && notification.params.sessionId === this.id) {
// Wire boundary: the envelope feeds the typed RunResult, so a
// malformed runtime surfaces as a protocol error, not as type-invalid
// data (or a TypeError out of finalResponse).
const event = validatedSessionEvent(notification.params.event)
notifications.push(notification)
options?.onNotification?.(notification)
events.push(event)
return
}
notifications.push(notification)
options?.onNotification?.(notification)
}
try {
const messageId = await client.prompt(this.id, contentBlocks)
let received = false
while (true) {
const notification = await subscription.next()
if (!received) {
if (notification.method !== 'session.event'
|| notification.params.sessionId !== this.id
|| !isInboxReceipt(notification.params.event, messageId)) continue
received = true
}
collect(notification)
if (notification.method === 'session.status'
&& notification.params.sessionId === this.id
&& notification.params.status === 'idle') break
}
} finally {
subscription.close()
}
return {
sessionId: this.id,
finalResponse: finalResponse(events),
events,
notifications,
}
}
}
/**
* Normalize run input: a string becomes one text block; blocks pass verbatim.
* @param input - prompt text or content blocks.
* @returns the content blocks to send.
*/
export function normalizeInput(input: string | ContentBlock[]): ContentBlock[] {
return typeof input === 'string' ? [{ type: 'text', text: input }] : input
}
/** Validate a wire `session.event` envelope to the shape the typed result exposes. */
function validatedSessionEvent(value: unknown): SessionEvent {
if (!isRecord(value) || typeof value.type !== 'string') {
throw new SdkProtocolError(`session.event carried no event envelope: ${JSON.stringify(value)}`)
}
// The one variant this module reads into (finalResponse) must carry
// kind-tagged content blocks; other variants pass through under their
// envelope shape.
if (value.type === 'assistant/message') {
const message = isRecord(value.data) ? value.data.message : undefined
const content = isRecord(message) ? message.content : undefined
if (!Array.isArray(content) || !content.every(block => isRecord(block) && typeof block.type === 'string')) {
throw new SdkProtocolError(`assistant/message event carried malformed content: ${JSON.stringify(value)}`)
}
}
return value as unknown as SessionEvent
}
/** Whether a raw session event is the durable enqueue receipt for `messageId`. */
function isInboxReceipt(value: unknown, messageId: string): boolean {
if (!isRecord(value) || value.type !== 'agent/inbox/spliced' || !isRecord(value.data)) return false
const inserted = value.data.inserted
return Array.isArray(inserted) && inserted.some(message => isRecord(message) && message.id === messageId)
}
/**
* Extract the concatenated text of the last assistant message.
* @param events - the activity interval's `session.event` payloads in wire order.
* @returns the final response text, or `''` when no assistant message exists.
*/
export function finalResponse(events: SessionEvent[]): string {
for (let index = events.length - 1; index >= 0; index--) {
const event = events[index]
if (event?.type !== 'assistant/message') continue
return event.data.message.content
.filter((block): block is ContentBlock & { type: 'text' } => block.type === 'text')
.map(block => block.text)
.join('')
}
return ''
}

View File

@@ -0,0 +1,473 @@
/**
* Low-level JSON-RPC client for a DeepSeek Harness SDK runtime subprocess.
* {@link HarnessClient} owns the child process: it spawns the runtime, speaks
* the `@deepseek-ai/dsh-sdk-protocol` wire over the child's stdio, fans
* server notifications out to subscriptions, and tears the child down to
* quiescence through a private EOF → SIGTERM → SIGKILL ladder. The design
* twin is the Python SDK's `HarnessClient` (`python/sdk`); both drive the
* same runtime protocol. This client runs OUTSIDE any harness context, so it
* spawns directly rather than through the `dsh-subprocess` service — the
* seam's documented exception for SDK-managed transports.
*
* @module @deepseek-ai/dsh-sdk-client/client
*/
import { spawn, type ChildProcess } from 'node:child_process'
import {
JsonRpcLineTransport,
JsonRpcResponseError,
type InitializeParams,
type InitializeResult,
type SessionPromptParams,
} from '@deepseek-ai/dsh-sdk-protocol'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { disposeRuntimeProcess } from './dispose.ts'
import type { HarnessClientOptions, HarnessNotification, NotificationFilter } from './types.ts'
/** Retained stderr lines used to diagnose an unexpected runtime death. */
const STDERR_TAIL_LIMIT = 400
/** Grace for the runtime's stdio streams to settle after its exit edge. */
const STREAM_SETTLE_MS = 100
/**
* The runtime subprocess is gone or unusable: it exited, its stdio closed, or
* it was never launchable. The message carries the exit code and a stderr
* tail when available.
*/
export class TransportClosedError extends Error {
/** @param message - the failure description, including any stderr tail. */
constructor(message: string) {
super(message)
this.name = 'TransportClosedError'
}
}
/** A request exceeded {@link HarnessClientOptions.requestTimeoutMs}. */
export class RequestTimeoutError extends Error {
/** @param message - which method timed out. */
constructor(message: string) {
super(message)
this.name = 'RequestTimeoutError'
}
}
/**
* The runtime answered outside its documented protocol (for example a
* `session/prompt` response without `accepted: true`).
*/
export class SdkProtocolError extends Error {
/** @param message - the protocol violation description. */
constructor(message: string) {
super(message)
this.name = 'SdkProtocolError'
}
}
interface SubscriptionState {
readonly queue: HarnessNotification[]
readonly waiters: { resolve: (item: HarnessNotification) => void; reject: (error: Error) => void }[]
readonly filter: NotificationFilter | undefined
failure: Error | undefined
}
/** One client-side notification stream returned by {@link HarnessClient.subscribe}. */
export interface NotificationSubscription extends AsyncIterable<HarnessNotification> {
/**
* Await the next matching notification.
* @returns the notification; after the runtime died, drains what was
* already delivered and then rejects; after {@link close}, rejects
* immediately (the queue is dropped).
*/
next(): Promise<HarnessNotification>
/**
* Drain one already-delivered notification without waiting.
* @returns the next queued notification, or `undefined` when none is queued.
*/
tryNext(): HarnessNotification | undefined
/** Detach from the client; queued items drop and pending waiters reject. */
close(): void
}
/** Internal producer side of a public notification subscription. */
class NotificationSubscriptionImpl implements NotificationSubscription {
constructor(
private readonly state: SubscriptionState,
private readonly unsubscribe: () => void,
) {}
/**
* Await the next matching notification.
* @returns the notification; after the runtime died, drains what was
* already delivered and then rejects; after {@link close}, rejects
* immediately (the queue is dropped).
*/
next(): Promise<HarnessNotification> {
const queued = this.state.queue.shift()
if (queued !== undefined) return Promise.resolve(queued)
if (this.state.failure !== undefined) return Promise.reject(this.state.failure)
return new Promise((resolve, reject) => {
this.state.waiters.push({ resolve, reject })
})
}
/**
* Drain one already-delivered notification without waiting.
* @returns the next queued notification, or `undefined` when none is queued.
*/
tryNext(): HarnessNotification | undefined {
return this.state.queue.shift()
}
/** Detach from the client; queued items drop and pending waiters reject. */
close(): void {
this.unsubscribe()
// The drop is part of this method's contract; a runtime-death fail() keeps
// the queue so already-delivered notifications remain drainable.
this.state.queue.length = 0
this.fail(new TransportClosedError('notification subscription closed'))
}
/**
* Reject pending and future waits (delivery stops; the first failure wins).
* Already-queued notifications remain drainable via {@link next}/{@link tryNext}.
* @param error - the terminal failure delivered to waiters.
*/
fail(error: Error): void {
this.state.failure ??= error
for (const waiter of this.state.waiters.splice(0)) waiter.reject(this.state.failure)
}
/**
* Deliver one notification to a waiter or the queue when the filter
* matches. A throwing filter fails only THIS subscription (detached, the
* throw becomes its terminal error) — it never disturbs sibling
* subscriptions or the transport's read loop, mirroring the Python client.
* @param notification - the wire notification to deliver.
*/
push(notification: HarnessNotification): void {
let matches: boolean
try {
matches = this.state.filter === undefined || this.state.filter(notification)
} catch (error) {
this.unsubscribe()
this.fail(error instanceof Error ? error : new Error(String(error)))
return
}
if (!matches) return
const waiter = this.state.waiters.shift()
if (waiter !== undefined) waiter.resolve(notification)
else this.state.queue.push(notification)
}
/**
* Iterate notifications until the subscription or runtime closes (the
* terminating rejection propagates).
* @returns an async iterator over {@link next} results.
*/
async * [Symbol.asyncIterator](): AsyncIterator<HarnessNotification> {
for (;;) yield await this.next()
}
}
/**
* JSON-RPC client for the DeepSeek Harness SDK runtime over subprocess stdio.
*
* The subprocess starts lazily on {@link start} and is owned by this instance
* until {@link close}, which requests protocol `shutdown` and then walks the
* shared EOF → SIGTERM → SIGKILL dispose ladder to quiescence. There is no
* wire-level cancel: a timed-out request stays running server-side until the
* runtime is closed.
*/
export class HarnessClient {
private child: ChildProcess | undefined
private transport: JsonRpcLineTransport | undefined
private readonly stderrTail: string[] = []
private readonly subscriptions = new Map<string, NotificationSubscriptionImpl>()
private readonly sessionParents = new Map<string, string>()
private subscriptionSerial = 0
private exitCode: number | null | undefined
private spawnError: Error | undefined
private streamsSettled: Promise<void> = Promise.resolve()
private closeTask: Promise<void> | undefined
/** @param options - launch spec, complete child environment, and timeouts. */
constructor(readonly options: HarnessClientOptions) {}
/**
* Spawn the runtime subprocess and start reading frames. Idempotent while
* the process is live; rejects reuse after {@link close}.
*/
start(): void {
if (this.closeTask !== undefined) throw new TransportClosedError('DeepSeek Harness runtime client is closed')
if (this.child !== undefined) return
const child = spawn(this.options.command, this.options.args ?? [], {
cwd: this.options.cwd,
env: this.options.env ?? process.env,
stdio: ['pipe', 'pipe', 'pipe'],
})
this.child = child
child.once('error', (error) => {
this.spawnError = error
// A spawn failure destroys the pipes without an input 'end' edge, so the
// transport's pending requests must be failed here.
this.transport?.close()
this.failSubscriptions(this.closedError('DeepSeek Harness runtime failed to start'))
})
// Writes racing the runtime's death EPIPE on stdin; the exit edge below is
// the real signal, so the stream-level error only needs to be non-fatal.
// The timing of that race is not deterministically reproducible.
/* v8 ignore next */
child.stdin.on('error', () => {})
let stderrBuffer = ''
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => {
stderrBuffer += chunk
const newline = stderrBuffer.lastIndexOf('\n')
if (newline >= 0) {
this.appendStderr(stderrBuffer.slice(0, newline).split('\n'))
stderrBuffer = stderrBuffer.slice(newline + 1)
}
})
let signalStreamsSettled!: () => void
this.streamsSettled = new Promise((resolve) => { signalStreamsSettled = resolve })
const settled = { stderr: false, exited: false }
const maybeSettle = (): void => {
if (settled.stderr && settled.exited) signalStreamsSettled()
}
child.stderr.once('close', () => {
if (stderrBuffer.length > 0) this.appendStderr([stderrBuffer])
settled.stderr = true
maybeSettle()
})
child.once('exit', (code) => {
this.exitCode = code
settled.exited = true
maybeSettle()
this.failSubscriptions(this.closedError('DeepSeek Harness runtime exited'))
})
child.once('close', () => {
// All stdio has settled: stdout 'end' already drained every tail frame,
// so closing now cannot drop responses — it only fails requests that
// will never be answered.
this.transport?.close()
})
const transport = new JsonRpcLineTransport(child.stdout, child.stdin)
transport.onNotification((method, params) => { this.dispatchNotification({ method, params }) })
transport.start()
this.transport = transport
}
/**
* Perform the process-wide handshake.
* @param params - workspace cwd plus the provider/model route.
* @returns the runtime's wire identity.
*/
async initialize(params: InitializeParams): Promise<InitializeResult> {
const result = await this.request('initialize', { ...params })
if (!isRecord(result) || !isRecord(result.serverInfo)
|| typeof result.serverInfo.name !== 'string' || typeof result.serverInfo.version !== 'string') {
throw new SdkProtocolError(`initialize returned no server identity: ${JSON.stringify(result)}`)
}
return { serverInfo: { name: result.serverInfo.name, version: result.serverInfo.version } }
}
/**
* Queue one prompt and return its durable inbox identity.
* @param sessionId - target session; an unknown id creates it.
* @param contentBlocks - the user message, sent verbatim.
* @returns the queued message id.
*/
async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise<string> {
const params: SessionPromptParams = { sessionId, contentBlocks }
const result = await this.request('session/prompt', { ...params })
if (!isRecord(result) || typeof result.messageId !== 'string') {
throw new SdkProtocolError(`session/prompt returned no message id: ${JSON.stringify(result)}`)
}
return result.messageId
}
/**
* Send one JSON-RPC request and await its result.
* @param method - the wire method name.
* @param params - the params object; omitted params send `{}`.
* @param timeoutMs - per-call override of {@link HarnessClientOptions.requestTimeoutMs}.
* @returns the raw result; rejects with {@link JsonRpcResponseError} on a
* protocol error response, {@link RequestTimeoutError} on timeout, and
* {@link TransportClosedError} when the runtime is gone.
*/
async request(method: string, params?: object, timeoutMs?: number): Promise<unknown> {
this.start()
// A dead runtime cannot answer; fail with process context instead of
// writing into a destroyed pipe and hanging until the timeout.
if (this.exitCode !== undefined || this.spawnError !== undefined) {
await this.settleStreams()
throw this.closedError('DeepSeek Harness runtime is not running')
}
const transport = this.transport
/* v8 ignore next -- start() either sets the transport or throws */
if (transport === undefined) throw new TransportClosedError('DeepSeek Harness runtime is not running')
const timeout = timeoutMs ?? this.options.requestTimeoutMs
try {
if (timeout === undefined) return await transport.request(method, params ?? {})
// The abort signal makes the timeout an abandonment: the transport drops
// its pending entry, so repeated bounded requests against a hung method
// retain no per-call state (the server-side work still runs to close).
const abandon = new AbortController()
const timer = setTimeout(() => {
abandon.abort(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for the DeepSeek Harness runtime`))
}, timeout)
try {
return await transport.request(method, params ?? {}, abandon.signal)
} finally {
clearTimeout(timer)
}
} catch (error) {
if (error instanceof JsonRpcResponseError || error instanceof RequestTimeoutError) throw error
// Transport-level failures gain process context: exit code + stderr tail.
await this.settleStreams()
throw this.closedError(errorMessage(error))
}
}
/**
* Subscribe to server notifications.
* @param filter - optional predicate; omitted means every notification.
* @returns the subscription handle; close it to stop delivery. After
* {@link close} or runtime death the handle is born failed — there is no
* producer left, so `next()` rejects instead of waiting forever.
*/
subscribe(filter?: NotificationFilter): NotificationSubscription {
const id = String(this.subscriptionSerial++)
const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined }
const subscription = new NotificationSubscriptionImpl(state, () => { this.subscriptions.delete(id) })
if (this.closeTask !== undefined || this.exitCode !== undefined || this.spawnError !== undefined) {
subscription.fail(this.closedError('DeepSeek Harness runtime closed'))
return subscription
}
this.subscriptions.set(id, subscription)
return subscription
}
/**
* Subscribe to one session and the descendants discovered from
* `subagent.started` lineage edges (the runtime notifies for every session
* in its context; scoping is client-side, mirroring the Python SDK).
* @param sessionId - the root session id.
* @returns the filtered subscription handle.
*/
subscribeSessionTree(sessionId: string): NotificationSubscription {
return this.subscribe((notification) => {
const params = notification.params
if (notification.method === 'subagent.started' || notification.method === 'subagent.finished') {
const parentId = params.parentSessionId
if (typeof parentId === 'string' && this.isDescendantOf(parentId, sessionId)) return true
return params.childSessionId === sessionId
}
const relatedId = params.sessionId
return typeof relatedId === 'string' && this.isDescendantOf(relatedId, sessionId)
})
}
/**
* Shut the runtime down and reap it: a best-effort protocol `shutdown`
* bounded by `shutdownTimeoutMs`, then the shared stdin-EOF → SIGTERM →
* SIGKILL ladder until the process actually exited. Idempotent.
* @returns settlement of the complete teardown.
*/
close(): Promise<void> {
this.closeTask ??= this.performClose()
return this.closeTask
}
private async performClose(): Promise<void> {
const child = this.child
if (child === undefined) return
try {
await this.request('shutdown', undefined, this.options.shutdownTimeoutMs ?? 1_000)
} catch (error) {
// Diagnostic only: the dispose ladder below is the authoritative teardown
// for a runtime that cannot answer shutdown anymore.
this.appendStderr([`shutdown request failed: ${errorMessage(error)}`])
}
await disposeRuntimeProcess(child, {
disposeEofGraceMs: this.options.disposeEofGraceMs ?? 6_000,
disposeGraceMs: this.options.disposeGraceMs ?? 3_000,
})
this.transport?.close()
this.failSubscriptions(this.closedError('DeepSeek Harness runtime closed'))
}
private dispatchNotification(notification: HarnessNotification): void {
this.recordSessionRelationship(notification)
for (const subscription of this.subscriptions.values()) subscription.push(notification)
}
private recordSessionRelationship(notification: HarnessNotification): void {
if (notification.method !== 'subagent.started') return
const parentId = notification.params.parentSessionId
const childId = notification.params.childSessionId
if (typeof parentId === 'string' && parentId !== '' && typeof childId === 'string' && childId !== '' && parentId !== childId) {
this.sessionParents.set(childId, parentId)
}
}
private isDescendantOf(sessionId: string, rootSessionId: string): boolean {
const visited = new Set<string>()
let current = sessionId
while (!visited.has(current)) {
if (current === rootSessionId) return true
visited.add(current)
const parent = this.sessionParents.get(current)
if (parent === undefined) return false
current = parent
}
// The parent map only ever extends chains upward, so a cycle cannot form.
/* v8 ignore next */
return false
}
private failSubscriptions(error: Error): void {
for (const subscription of this.subscriptions.values()) subscription.fail(error)
}
private appendStderr(lines: string[]): void {
const kept = lines.filter(line => line.length > 0)
this.stderrTail.push(...kept)
if (this.stderrTail.length > STDERR_TAIL_LIMIT) {
this.stderrTail.splice(0, this.stderrTail.length - STDERR_TAIL_LIMIT)
}
}
private settleStreams(): Promise<void> {
return Promise.race([
this.streamsSettled,
new Promise<void>((resolve) => { setTimeout(resolve, STREAM_SETTLE_MS) }),
])
}
private closedError(reason: string): TransportClosedError {
const parts = [reason]
if (this.spawnError !== undefined) parts.push(`spawn error: ${this.spawnError.message}`)
if (this.exitCode !== undefined) parts.push(`exit code: ${String(this.exitCode)}`)
if (this.stderrTail.length > 0) parts.push(`stderr tail:\n${this.stderrTail.join('\n')}`)
return new TransportClosedError(parts.join('\n'))
}
}
/**
* Whether `value` is a plain JSON object (the wire-boundary shape probe).
* @param value - the wire value to probe.
* @returns `true` iff `value` is a non-null, non-array object.
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** The message of a thrown value (the transport only throws `Error`s; `String` covers the rest). */
function errorMessage(error: unknown): string {
/* v8 ignore next -- the transport and dispose ladder reject only with Errors */
return error instanceof Error ? error.message : String(error)
}

View File

@@ -0,0 +1,99 @@
/**
* Private teardown ladder for the runtime subprocess: stdin EOF (cooperative
* quiesce), then SIGTERM, then SIGKILL, resolving only after the process has
* actually exited. The SDK client runs OUTSIDE any harness context, so it
* cannot ride the `dsh-subprocess` service — this module is the seam's
* documented exception for SDK-managed transports.
*
* @module @deepseek-ai/dsh-sdk-client/dispose
*/
import type { ChildProcess } from 'node:child_process'
/**
* Race the child's exit against a timer. Neither outcome leaves anything
* behind on the child: the exit listener is removed on timeout and the timer
* is cleared on exit, so the ladder's tiers never accumulate listeners.
*/
function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
return new Promise<boolean>((resolve) => {
const onExit = (): void => {
clearTimeout(timer)
resolve(true)
}
// `.unref()` so a pending grace timer never keeps the parent's loop alive.
const timer = setTimeout(() => {
child.removeListener('exit', onExit)
resolve(false)
}, ms).unref()
child.once('exit', onExit)
})
}
/** Force-terminate the runtime and reject if no exit edge arrives within the grace. */
function forceTerminateWithin(child: ChildProcess, ms: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
let accepted = false
let settled = false
const cleanup = (): void => {
clearTimeout(timer)
child.off('exit', onExit)
child.off('error', onError)
}
const settle = (complete: () => void): void => {
if (settled) return
settled = true
cleanup()
complete()
}
const onExit = (): void => { settle(resolve) }
const onError = (error: Error): void => { settle(() => { reject(error) }) }
child.once('exit', onExit)
child.once('error', onError)
const timer = setTimeout(() => {
const disposition = accepted ? 'accepted' : 'refused'
settle(() => {
reject(new Error(`runtime process did not exit within ${ms}ms after SIGKILL was ${disposition}`))
})
}, ms).unref()
try {
accepted = child.kill('SIGKILL')
if (child.exitCode !== null || child.signalCode !== null) settle(resolve)
} catch (error: unknown) {
settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })
}
})
}
/**
* Tear the runtime down to quiescence, resolving only after exit: close stdin
* and allow cooperative flush, then use the host's graceful and forced
* termination semantics. POSIX sends `SIGTERM` before `SIGKILL`; Windows
* skips directly to forced termination because Node maps both signals to
* `TerminateProcess`.
* @param child - the runtime child process to tear down.
* @param graces - the EOF and termination-confirmation windows (ms).
* @param platform - the host platform, injectable for unit coverage.
* @throws When forced termination errors or the child does not report exit
* within `disposeGraceMs`.
*/
export async function disposeRuntimeProcess(
child: ChildProcess,
graces: { disposeEofGraceMs: number; disposeGraceMs: number },
platform: NodeJS.Platform = process.platform,
): Promise<void> {
// Already gone: nothing to reap.
if (child.exitCode !== null || child.signalCode !== null) return
// 1. Close stdin and allow cooperative teardown and durable-state flush.
child.stdin?.end()
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
// 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate.
if (platform !== 'win32') {
child.kill('SIGTERM')
if (await exitsWithin(child, graces.disposeGraceMs)) return
}
// 3. Force-kill and await a bounded exit edge.
await forceTerminateWithin(child, graces.disposeGraceMs)
}

View File

@@ -0,0 +1,29 @@
/**
* TypeScript client SDK for the DeepSeek Harness runtime: spawn the
* `dsh-jsonrpc-agent` runtime as a subprocess and drive agent turns over
* stdio JSON-RPC. `DeepSeekHarness` is the high-level run API;
* `HarnessClient` is the lower-level protocol client. A pure library — it
* registers nothing on a Cordis context; the runtime process it spawns is a
* complete harness configured by its own `cordis.yml`.
*
* @module @deepseek-ai/dsh-sdk-client
*/
export { DeepSeekHarness, HarnessSession } from './api.ts'
export type { RunOptions } from './api.ts'
export {
HarnessClient,
RequestTimeoutError,
SdkProtocolError,
TransportClosedError,
} from './client.ts'
export type { NotificationSubscription } from './client.ts'
export { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol'
export type {
ContentBlock,
DeepSeekHarnessOptions,
HarnessClientOptions,
HarnessNotification,
NotificationFilter,
RunResult,
} from './types.ts'

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-sdk-client`.
* @module @deepseek-ai/dsh-sdk-client/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-client'
/** Cordis companion plugin name. */
export const name = 'sdk-client-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this client library runs outside any harness context
* (its peer is a separate runtime process); the runtime's own packages own
* the event-stream relations.
*/
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,74 @@
/**
* Types for the TypeScript SDK client: launch options, notification shapes,
* and owned activity results.
*
* @module @deepseek-ai/dsh-sdk-client/types
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/** One server-to-client notification as received off the wire. */
export interface HarnessNotification {
/** The JSON-RPC notification method name. */
method: string
/** The raw params object; see `HarnessSdkNotificationMap` for the shapes per method. */
params: Record<string, unknown>
}
/** Predicate deciding whether a subscription receives a notification. */
export type NotificationFilter = (notification: HarnessNotification) => boolean
/** Launch and timeout options for {@link HarnessClient}. */
export interface HarnessClientOptions {
/** The runtime executable (the `dsh-jsonrpc-agent` bin, a packaged exe, or `node`). */
command: string
/** Arguments passed to {@link command}. */
args?: string[]
/** Working directory for the runtime process itself. */
cwd?: string
/**
* The complete child environment. `undefined` inherits the parent env
* verbatim; passing an object replaces it entirely, so callers own
* credential policy (see `scrubbedParentEnv` in `@deepseek-ai/dsh-subprocess`
* for the shared scrub-then-merge base).
*/
env?: NodeJS.ProcessEnv
/** Per-request timeout (ms); `undefined` waits indefinitely (a turn can legitimately run long). */
requestTimeoutMs?: number
/** Bound (ms) on the protocol `shutdown` exchange inside `close()` (default 1000). */
shutdownTimeoutMs?: number
/** Grace (ms) for the runtime's stdin-EOF quiesce during `close()` (default 6000). */
disposeEofGraceMs?: number
/** Termination confirmation window (ms) after SIGTERM/SIGKILL during `close()` (default 3000). */
disposeGraceMs?: number
}
/** Options for the high-level {@link DeepSeekHarness} wrapper. */
export interface DeepSeekHarnessOptions {
/** Launch spec for the runtime subprocess (command, args, cwd, env, timeouts). */
launch: HarnessClientOptions
/** Workspace cwd recorded on every SDK-created session (default: the launch cwd, else `process.cwd()`). */
cwd?: string
/** Provider route for SDK-created agents (default `deepseek-official`). */
provider?: string
/** Model for SDK-created agents (default `deepseek-v4-flash`). */
model?: string
/** Maximum output tokens for each conversation-model request. */
maxTokens?: number
}
/** One owned session activity interval, from enqueue receipt through idle. */
export interface RunResult {
/** The session the activity ran on. */
sessionId: string
/** Concatenated text of the interval's last assistant message (empty when none). */
finalResponse: string
/** Every `session.event` payload for the root session, in wire order. */
events: SessionEvent[]
/** Every notification for the root session and discovered descendants, in wire order. */
notifications: HarnessNotification[]
}
/** Re-exported content-block alias so SDK callers need no extra import. */
export type { ContentBlock }

View File

@@ -0,0 +1,231 @@
/**
* Deterministic ladder coverage against a scriptable fake child: each
* escalation tier's timing is driven exactly (the client suite exercises the
* same ladder against real subprocesses end to end).
*/
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { describe, expect, it, vi } from 'vitest'
import { disposeRuntimeProcess } from '../src/dispose.ts'
/** What fells a scripted {@link FakeChild}. */
type LethalTrigger = 'eof' | NodeJS.Signals
/** Per-scenario script for a {@link FakeChild}. */
interface FakeChildScript {
/**
* The one trigger that makes the child exit (SIGKILL always does,
* uncatchable, like a real process). Omitted: only SIGKILL fells it.
*/
diesOn?: LethalTrigger
/** Delay (ms) between the lethal trigger and the exit event. */
delayMs?: number
/** Complete the scripted exit inside the triggering call. */
synchronousExit?: boolean
/** `false` models a child spawned without a stdin pipe. */
stdin?: boolean
}
/**
* A scriptable stand-in for a ChildProcess carrying exactly the surface the
* ladder reads: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
* `exit` event.
*/
class FakeChild extends EventEmitter {
exitCode: number | null = null
signalCode: NodeJS.Signals | null = null
readonly kills: NodeJS.Signals[] = []
stdinEnded = false
readonly stdin: { end: () => void } | null
constructor(private readonly script: FakeChildScript = {}) {
super()
this.stdin = script.stdin === false
? null
: { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
}
kill(signal: NodeJS.Signals): boolean {
this.kills.push(signal)
this.maybeDie(signal)
return true
}
private maybeDie(trigger: LethalTrigger): void {
// SIGKILL is uncatchable — it always fells the child; any other trigger
// only when the scenario scripts it as the lethal one.
if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
const exit = (): void => {
if (trigger === 'eof') this.exitCode = 0
else this.signalCode = trigger
this.emit('exit', this.exitCode, this.signalCode)
}
if (this.script.synchronousExit === true) exit()
else setTimeout(exit, this.script.delayMs ?? 0)
}
}
/** The ladder takes a real ChildProcess; the fake carries the read surface. */
function asChild(fake: FakeChild): ChildProcess {
return fake as unknown as ChildProcess
}
describe('disposeRuntimeProcess', () => {
it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
const fake = new FakeChild()
fake.exitCode = 0
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(false)
expect(fake.kills).toEqual([])
})
it('returns immediately for a child already dead by signal', async () => {
const fake = new FakeChild()
fake.signalCode = 'SIGKILL'
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(false)
expect(fake.kills).toEqual([])
})
it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(true)
expect(fake.kills).toEqual([])
expect(fake.exitCode).toBe(0)
})
it('recognizes a child that exits synchronously on stdin EOF', async () => {
const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.exitCode).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.stdinEnded).toBe(true)
expect(fake.kills).toEqual(['SIGTERM'])
expect(fake.signalCode).toBe('SIGTERM')
expect(fake.listenerCount('exit')).toBe(0)
})
it('recognizes a child that exits synchronously on SIGTERM', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
expect(fake.signalCode).toBe('SIGTERM')
expect(fake.listenerCount('exit')).toBe(0)
})
it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
// Quiescence, not a request: at resolution the child has ACTUALLY exited
// (the exit event landed, despite the scripted post-SIGKILL delay).
expect(fake.signalCode).toBe('SIGKILL')
})
it('recognizes a child already gone when the final exit wait begins', async () => {
const fake = new FakeChild({ synchronousExit: true })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
expect(fake.signalCode).toBe('SIGKILL')
})
it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
queueMicrotask(() => {
if (marker === 'exitCode') fake.exitCode = 0
else fake.signalCode = 'SIGTERM'
})
return true
})
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
})
it('walks the ladder for a child spawned without a stdin pipe', async () => {
const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
})
it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
expect(fake.kills).toEqual(['SIGKILL'])
expect(fake.signalCode).toBe('SIGKILL')
})
it('propagates a forced-termination error without waiting for the grace', async () => {
const fake = new FakeChild()
const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
fake.emit('error', failure)
return false
})
await expect(disposeRuntimeProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
'win32',
)).rejects.toBe(failure)
expect(fake.kills).toEqual(['SIGKILL'])
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
const fake = new FakeChild()
const failure = new Error('invalid signal state')
vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
await expect(disposeRuntimeProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
'win32',
)).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('bounds a refused forced termination that produces no error or exit', async () => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
return false
})
await expect(disposeRuntimeProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
'win32',
)).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was refused')
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('bounds an accepted forced termination that never reports exit', async () => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
return true
})
await expect(disposeRuntimeProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
'win32',
)).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was accepted')
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
})

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env node
/**
* Scripted stand-in for the DeepSeek Harness SDK runtime, driven entirely by
* env vars — no model, no network, no harness imports. Speaks the runtime's
* newline-delimited JSON-RPC protocol on stdio: answers `initialize`,
* `session/prompt` (streaming scripted `session.event` notifications, then
* `session.finished`, then the response), and `shutdown`.
*
* Script vocabulary (all optional):
* - `FAKE_TEXT`: assistant text for each turn (default `hello from fake runtime`).
* - `FAKE_STATUS`: the `session.finished` status (default `ok`).
* - `FAKE_REASON_KIND`: the `session.finished` reason kind (default `completed`; `none` omits the reason).
* - `FAKE_SUBAGENT`: also emit a child session (subagent.started + child event + subagent.finished).
* - `FAKE_ECHO_CWD`: prefix the assistant text with the process cwd.
* - `FAKE_ECHO_ENV`: comma-separated env names to echo as `name=value` lines in the assistant text.
* - `FAKE_MALFORMED`: `initialize` returns `{}` (no serverInfo); `prompt` returns `{}` (no accepted).
* - `FAKE_MALFORMED_PROMPT`: `initialize` is normal; only `prompt` returns `{}` (no accepted).
* - `FAKE_INIT_ERROR`: `initialize` answers a JSON-RPC error response with code 7.
* - `FAKE_INIT_ERROR_ONCE_FILE`: fail `initialize` (code 7) only when this
* marker file does NOT exist yet, creating it — so the first runtime
* process fails the handshake and a respawned one succeeds (retry probe).
* - `FAKE_ECHO_CWD_IN_INIT`: reply `serverInfo.version` = this process's cwd
* (wire-visible spawn-cwd probe).
* - `FAKE_MALFORMED_EVENT`: the turn's `session.event` carries a number as
* the event; `FAKE_MALFORMED_MESSAGE`: assistant/message content is not an
* array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
* member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
* string (wire-validation probes).
* - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
* arrives, then poll for the GO file before answering (deterministic
* cancel-during-handshake window).
* - `FAKE_HANG_PROMPT`: never answer `session/prompt` (for timeout/dispose tests).
* - `FAKE_STREAM_THEN_MALFORMED`: stream a text chunk for the prompt, then
* answer `{}` (no accepted) — same-pipe ordering makes the chunk arrive
* before the protocol failure (partial-output retention probe).
* - `FAKE_IGNORE_EOF` + `FAKE_SIGTERM_FILE`: keep running after stdin EOF; touch the file on SIGTERM (ladder probe).
* - `FAKE_TRAP_SIGTERM`: with `FAKE_IGNORE_EOF`, survive SIGTERM too (SIGKILL-rung probe).
* - `FAKE_EXIT_BEFORE_INIT`: exit 3 immediately (spawn-then-die probe).
* - `FAKE_STDERR`: write this line to stderr at boot (diagnostics-tail probe).
* - `FAKE_STDERR_NO_NEWLINE`: write this to stderr WITHOUT a newline (buffer-flush probe).
* - `FAKE_RECORD_INIT`: append each `initialize` params JSON to this file (handshake probe).
*/
import { appendFileSync, existsSync, writeFileSync } from 'node:fs'
import process from 'node:process'
import { createInterface } from 'node:readline'
const env = process.env
if (env.FAKE_STDERR !== undefined) process.stderr.write(`${env.FAKE_STDERR}\n`)
if (env.FAKE_STDERR_NO_NEWLINE !== undefined) process.stderr.write(env.FAKE_STDERR_NO_NEWLINE)
if (env.FAKE_EXIT_BEFORE_INIT !== undefined) process.exit(3)
if (env.FAKE_IGNORE_EOF !== undefined) {
// Simulate a runtime that never quiesces from EOF so the dispose ladder
// must escalate; record which rung fired.
process.stdin.resume()
process.stdin.on('end', () => { setInterval(() => {}, 1_000) })
process.on('SIGTERM', () => {
if (env.FAKE_SIGTERM_FILE !== undefined) writeFileSync(env.FAKE_SIGTERM_FILE, 'sigterm\n')
if (env.FAKE_TRAP_SIGTERM === undefined) process.exit(0)
})
}
function write(message: object): void {
process.stdout.write(`${JSON.stringify(message)}\n`)
}
function notify(method: string, params: object): void {
write({ jsonrpc: '2.0', method, params })
}
let seq = 0
function event(sessionId: string, type: string, data: object): void {
notify('session.event', { sessionId, event: { type, seq: seq++, time: 0, data } })
}
function assistantText(): string {
const parts: string[] = []
if (env.FAKE_ECHO_CWD !== undefined) parts.push(`cwd=${process.cwd()}`)
for (const name of (env.FAKE_ECHO_ENV ?? '').split(',').filter(entry => entry.length > 0)) {
parts.push(`${name}=${env[name] ?? ''}`)
}
parts.push(env.FAKE_TEXT ?? 'hello from fake runtime')
return parts.join('\n')
}
function runTurn(sessionId: string): void {
const text = assistantText()
if (env.FAKE_MALFORMED_EVENT !== undefined) {
notify('session.event', { sessionId, event: 42 })
return
}
event(sessionId, 'turn/start', { turn: 0 })
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text } })
if (env.FAKE_MALFORMED_MESSAGE !== undefined) {
event(sessionId, 'assistant/message', {
turn: 0,
step: 0,
message: {
id: 'fake-malformed-message',
role: 'assistant',
content: 'not-an-array',
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
})
return
}
if (env.FAKE_MESSAGE_WITHOUT_DATA !== undefined) {
notify('session.event', { sessionId, event: { type: 'assistant/message', seq: seq++, time: 0 } })
return
}
event(sessionId, 'assistant/message', {
turn: 0,
step: 0,
message: {
id: `fake-assistant-${seq}`,
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
})
const reasonKind = env.FAKE_REASON_KIND ?? 'completed'
event(sessionId, 'turn/end', { turn: 0, reason: { kind: reasonKind } })
if (env.FAKE_SUBAGENT !== undefined) {
const childId = `${sessionId}-child`
notify('subagent.started', { parentSessionId: sessionId, childSessionId: childId })
event(childId, 'assistant/message', {
turn: 0,
step: 0,
content: [{ type: 'text', text: 'child says hi' }],
provenance: { provider: 'fake', model: 'fake' },
})
notify('subagent.finished', {
provider: 'spawn',
agentId: childId,
parentSessionId: sessionId,
childSessionId: childId,
status: 'ok',
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'child says hi' }],
})
}
}
function sessionIdOf(params: Record<string, unknown> | undefined): string {
const value = params?.sessionId
return typeof value === 'string' ? value : ''
}
const reader = createInterface({ input: process.stdin })
reader.on('line', (line) => {
if (line.trim().length === 0) return
const frame = JSON.parse(line) as { id?: string | number; method?: string; params?: Record<string, unknown> }
if (frame.method === undefined || frame.id === undefined) return
const respond = (result: object): void => { write({ jsonrpc: '2.0', id: frame.id, result }) }
switch (frame.method) {
case 'initialize':
if (env.FAKE_RECORD_INIT !== undefined) appendFileSync(env.FAKE_RECORD_INIT, `${JSON.stringify(frame.params)}\n`)
if (env.FAKE_HANG_INIT !== undefined) return
if (env.FAKE_INIT_READY !== undefined && env.FAKE_INIT_GO !== undefined) {
writeFileSync(env.FAKE_INIT_READY, 'ready\n')
const go = env.FAKE_INIT_GO
const id = frame.id
const poll = setInterval(() => {
if (!existsSync(go)) return
clearInterval(poll)
write({ jsonrpc: '2.0', id, result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } })
}, 5)
return
}
if (env.FAKE_INIT_ERROR !== undefined) {
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted init failure', data: { hint: 'fake' } } })
return
}
if (env.FAKE_INIT_ERROR_ONCE_FILE !== undefined && !existsSync(env.FAKE_INIT_ERROR_ONCE_FILE)) {
writeFileSync(env.FAKE_INIT_ERROR_ONCE_FILE, 'failed-once\n')
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted first-boot failure' } })
return
}
if (env.FAKE_MALFORMED !== undefined) {
respond({})
return
}
if (env.FAKE_ECHO_CWD_IN_INIT !== undefined) {
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: process.cwd() } })
return
}
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
return
case 'session/prompt': {
const sessionId = sessionIdOf(frame.params)
const messageId = `fake-user-${seq}`
event(sessionId, 'agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [{
id: messageId,
role: 'user',
content: [],
source: { kind: 'user' },
}],
})
notify('session.status', { sessionId, status: 'running' })
if (env.FAKE_STREAM_THEN_MALFORMED !== undefined) {
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } })
respond({})
return
}
if (env.FAKE_HANG_PROMPT !== undefined) return
if (env.FAKE_MALFORMED !== undefined || env.FAKE_MALFORMED_PROMPT !== undefined) {
respond({})
return
}
runTurn(sessionId)
notify('session.status', { sessionId, status: 'idle' })
respond({ messageId })
return
}
case 'shutdown':
respond({})
// An EOF-ignoring fake also refuses the protocol exit, so the client's
// dispose ladder (not this cooperative path) must reap it.
if (env.FAKE_IGNORE_EOF === undefined) setImmediate(() => process.exit(0))
return
default:
write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: `unknown method: ${frame.method}` } })
}
})

View File

@@ -0,0 +1,527 @@
/**
* SDK client against a real scripted runtime subprocess
* (`tests/fake-runtime.ts`, protocol-only — the only faked boundary is the
* model-owning runtime itself). Covers the turn loop, notification routing
* and session-tree scoping, error surfaces, timeouts, and the dispose ladder.
*/
import { mkdir, mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import {
DeepSeekHarness,
HarnessClient,
HarnessSession,
JsonRpcResponseError,
RequestTimeoutError,
SdkProtocolError,
TransportClosedError,
type HarnessNotification,
} from '../src/index.ts'
import { finalResponse, normalizeInput } from '../src/api.ts'
const fakeRuntime = fileURLToPath(new URL('./fake-runtime.ts', import.meta.url))
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) await cleanup()
})
type LaunchOverrides = Partial<ConstructorParameters<typeof HarnessClient>[0]>
/** Launch options running the fake runtime on the current node (type stripping). */
function fakeLaunch(env: Record<string, string> = {}, extra: LaunchOverrides = {}) {
return {
command: process.execPath,
args: [fakeRuntime],
env: { ...process.env as Record<string, string>, ...env },
...extra,
}
}
function harnessWith(env: Record<string, string> = {}, extra: LaunchOverrides = {}): DeepSeekHarness {
const harness = new DeepSeekHarness({ launch: fakeLaunch(env, extra) })
cleanups.push(() => harness.close())
return harness
}
async function tempDir(prefix: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), prefix))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
describe('DeepSeekHarness', () => {
it('ignores notifications that precede the submitted message receipt', async () => {
const notifications = [
{ method: 'session.status', params: { sessionId: 'owned', status: 'running' } },
{
method: 'session.event',
params: { sessionId: 'owned', event: { type: 'turn/start', data: { turn: 1 } } },
},
{
method: 'session.event',
params: {
sessionId: 'owned',
event: { type: 'agent/inbox/spliced', data: { inserted: null } },
},
},
{
method: 'session.event',
params: {
sessionId: 'owned',
event: {
type: 'agent/inbox/spliced',
seq: 0,
time: 0,
data: {
target: 'next-turn',
start: 0,
inserted: [{ id: 'accepted-message', role: 'user', content: [], source: { kind: 'user' } }],
},
},
},
},
{ method: 'session.status', params: { sessionId: 'owned', status: 'idle' } },
] as HarnessNotification[]
let closed = false
const harness = {
start: () => Promise.resolve(),
client: {
prompt: () => Promise.resolve('accepted-message'),
subscribeSessionTree: () => ({
next: async () => {
const notification = notifications.shift()
if (notification === undefined) throw new Error('scripted notification queue exhausted')
return notification
},
tryNext: () => notifications.shift(),
close: () => { closed = true },
async * [Symbol.asyncIterator]() {},
}),
},
} as unknown as DeepSeekHarness
const result = await new HarnessSession(harness, 'owned').run('go')
expect(result.notifications.map(notification => notification.method))
.toEqual(['session.event', 'session.status'])
expect(result.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
expect(closed).toBe(true)
})
it('runs a turn end to end and reuses the runtime across sessions', async () => {
const harness = harnessWith({ FAKE_TEXT: 'turn answer' })
const first = await harness.run('say hi')
expect(first.finalResponse).toBe('turn answer')
expect(first.events.map(event => event.type)).toEqual([
'agent/inbox/spliced', 'turn/start', 'assistant/chunk', 'assistant/message', 'turn/end',
])
// Same subprocess, second session: ids differ, protocol state is reusable.
const second = await harness.run([{ type: 'text', text: 'again' }])
expect(second.sessionId).not.toBe(first.sessionId)
await harness.close()
})
it('keeps events root-scoped while streaming notifications for the session tree', async () => {
const harness = harnessWith({ FAKE_SUBAGENT: '1' })
const seen: HarnessNotification[] = []
const result = await harness.run('delegate', {
sessionId: 'parent-1',
onNotification: (n) => { seen.push(n) },
})
// The child session's events arrive through subagent.started lineage.
expect(seen.map(n => n.method)).toContain('subagent.started')
expect(seen.map(n => n.method)).toContain('subagent.finished')
const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child')
expect(childEvents.length).toBeGreaterThan(0)
// RunResult.events is the root session's typed stream; descendants retain
// their session ids in the raw notification stream above.
expect(result.events.every(event => event.type !== 'assistant/message'
|| event.data.message.content[0]?.type !== 'text'
|| event.data.message.content[0].text !== 'child says hi')).toBe(true)
await harness.close()
})
it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => {
const dir = await tempDir('sdk-client-init-')
const recordFile = join(dir, 'init.jsonl')
const harness = new DeepSeekHarness({
launch: fakeLaunch({ FAKE_RECORD_INIT: recordFile }),
cwd: dir,
provider: 'custom-provider',
model: 'custom-model',
maxTokens: 4096,
})
cleanups.push(() => harness.close())
await harness.run('one')
await harness.run('two')
await harness.close()
const records = (await readFile(recordFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as object)
expect(records).toEqual([{
cwd: dir,
provider: 'custom-provider',
model: 'custom-model',
maxTokens: 4096,
}])
})
it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => {
// vitest workers forbid chdir, so derive a RELATIVE path from the real
// process cwd to a temp worker dir; resolution is lexical either way.
const dir = await mkdtemp(join(process.cwd(), '.dsh-sdk-client-relcwd-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
const recordFile = join(dir, 'init.jsonl')
const inner = join(dir, 'worker')
await mkdir(inner)
const relativeCwd = relative(process.cwd(), inner)
expect(isAbsolute(relativeCwd)).toBe(false)
const harness = new DeepSeekHarness({
launch: fakeLaunch({ FAKE_RECORD_INIT: recordFile, FAKE_ECHO_CWD_IN_INIT: '1' }, { cwd: relativeCwd }),
})
cleanups.push(() => harness.close())
await harness.start()
const identity = await harness.client.initialize({ cwd: inner, provider: 'p', model: 'm' })
await harness.close()
// The child spawned under the temp worker dir (its physical cwd)...
expect(identity.serverInfo.version).toBe(await realpath(inner))
// ...and the handshake wire cwd went out ABSOLUTE, so the child cannot
// re-resolve a relative string into dir/worker/worker.
const records = (await readFile(recordFile, 'utf8')).trim().split('\n')
.map(line => (JSON.parse(line) as { cwd: string }).cwd)
expect(records).toEqual([resolvePath(relativeCwd), inner])
})
it('propagates a JSON-RPC error response from initialize and closes the runtime', async () => {
const harness = harnessWith({ FAKE_INIT_ERROR: '1' })
const failure = await harness.run('boom').then(
() => { throw new Error('run unexpectedly succeeded') },
(error: unknown) => error,
)
expect(failure).toBeInstanceOf(JsonRpcResponseError)
expect(failure).toMatchObject({ code: 7, message: 'scripted init failure', data: { hint: 'fake' } })
// The failed handshake reset lets a later start retry instead of wedging.
await expect(harness.run('later')).rejects.toThrow()
})
it('retries a failed handshake with a fresh runtime process', async () => {
const dir = await tempDir('sdk-client-retry-')
const marker = join(dir, 'first-boot-failed')
const harness = harnessWith({ FAKE_INIT_ERROR_ONCE_FILE: marker, FAKE_TEXT: 'second boot answer' })
const firstClient = harness.client
// First start: the scripted runtime fails the handshake and is reaped.
await expect(harness.start()).rejects.toThrow('scripted first-boot failure')
// Retry spawns a NEW subprocess through a fresh client (close is permanent).
const result = await harness.run('again')
expect(harness.client).not.toBe(firstClient)
expect(result.finalResponse).toBe('second boot answer')
await harness.close()
// close() is terminal: a handshake failure after it must not respawn.
await expect(harness.run('after-close')).rejects.toThrow(TransportClosedError)
})
it('rejects a malformed initialize result as a protocol error', async () => {
const harness = harnessWith({ FAKE_MALFORMED: '1' })
await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError)
})
it('supports await using disposal', async () => {
let captured: DeepSeekHarness
{
await using harness = new DeepSeekHarness({ launch: fakeLaunch() })
captured = harness
const result = await harness.run('scoped')
expect(result.finalResponse).toBe('hello from fake runtime')
}
// After scope exit the runtime is closed: reuse fails loudly.
await expect(captured.run('after')).rejects.toThrow(TransportClosedError)
})
})
describe('HarnessClient', () => {
it('times out a hung request at the per-call bound', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }))
cleanups.push(() => client.close())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('hi') }, 200))
.rejects.toThrow(RequestTimeoutError)
await client.close()
})
it('a timed-out request leaves no pending transport state', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }))
cleanups.push(() => client.close())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
for (let round = 0; round < 3; round++) {
await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('x') }, 50))
.rejects.toThrow(RequestTimeoutError)
}
// Abandonment removed each pending entry at its timeout; a hung method
// retains nothing per call. (Private map read is the observable here —
// no wire surface reports transport bookkeeping.)
const transport = (client as unknown as { transport: { pending: Map<string, unknown> } }).transport
expect(transport.pending.size).toBe(0)
await client.close()
})
it('applies the client-wide request timeout when no per-call bound is given', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }, { requestTimeoutMs: 400 }))
cleanups.push(() => client.close())
// The bound applies from send, so it holds regardless of runtime boot time.
await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(RequestTimeoutError)
await client.close()
})
it('rejects a malformed prompt acceptance as a protocol error', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_MALFORMED: '1' }))
cleanups.push(() => client.close())
await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(SdkProtocolError)
await client.close()
})
it('fails pending requests with exit code and stderr tail when the runtime dies', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'fatal: scripted death' }))
cleanups.push(() => client.close())
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
() => { throw new Error('initialize unexpectedly succeeded') },
(error: unknown) => error,
)
expect(failure).toBeInstanceOf(TransportClosedError)
expect(String(failure)).toContain('exit code: 3')
expect(String(failure)).toContain('fatal: scripted death')
// Requests after death fail immediately with the same context.
await expect(client.request('initialize', {})).rejects.toThrow('exit code: 3')
})
it('flushes an unterminated stderr line into the tail at close', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_STDERR_NO_NEWLINE: 'no trailing newline', FAKE_EXIT_BEFORE_INIT: '1' }))
cleanups.push(() => client.close())
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
() => { throw new Error('initialize unexpectedly succeeded') },
(error: unknown) => error,
)
expect(String(failure)).toContain('no trailing newline')
})
it('fails fast when the command does not exist', async () => {
const client = new HarnessClient({ command: join(tmpdir(), 'dsh-no-such-runtime-bin') })
cleanups.push(() => client.close())
await expect(client.request('initialize', {}, 1_000)).rejects.toThrow(TransportClosedError)
})
it('close() is idempotent, reaps the child, and fails later use', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
await Promise.all([client.close(), client.close()])
expect(() => { client.start() }).toThrow(TransportClosedError)
await expect(client.request('anything')).rejects.toThrow(TransportClosedError)
// Close with no child ever spawned is a no-op.
const untouched = new HarnessClient(fakeLaunch())
await untouched.close()
})
it('escalates through SIGTERM when the runtime ignores EOF', async () => {
const dir = await tempDir('sdk-client-ladder-')
const sigtermFile = join(dir, 'sigterm.txt')
const client = new HarnessClient(fakeLaunch(
{ FAKE_IGNORE_EOF: '1', FAKE_SIGTERM_FILE: sigtermFile },
{ shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 1_000 },
))
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
await client.close()
if (process.platform === 'win32') {
await expect(stat(sigtermFile)).rejects.toMatchObject({ code: 'ENOENT' })
} else {
expect((await stat(sigtermFile)).isFile()).toBe(true)
}
})
it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => {
const client = new HarnessClient(fakeLaunch(
{ FAKE_IGNORE_EOF: '1', FAKE_TRAP_SIGTERM: '1' },
{ shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 300 },
))
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
// Resolves (does not hang or reject): the SIGKILL rung reaped the child.
await client.close()
})
it('delivers notifications to unfiltered and filtered subscriptions in wire order', async () => {
const client = new HarnessClient(fakeLaunch())
cleanups.push(() => client.close())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const all = client.subscribe()
const idleOnly = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
const firstPending = all.next()
await client.prompt('sub-test', normalizeInput('go'))
const first = await firstPending
expect(first.method).toBe('session.event')
const idle = await idleOnly.next()
expect(idle.method).toBe('session.status')
expect(idleOnly.tryNext()).toBeUndefined()
// A bare unbounded request with omitted params sends `{}` on the wire.
const identity = await client.request('initialize') as { serverInfo: { name: string } }
expect(identity.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
// Async iteration consumes queued items and then parks.
const collected: string[] = []
for await (const notification of all) {
collected.push(notification.method)
if (notification.method === 'session.status' && notification.params.status === 'idle') break
}
expect(collected.at(-1)).toBe('session.status')
all.close()
idleOnly.close()
await expect(all.next()).rejects.toThrow('notification subscription closed')
await client.close()
})
it('contains a throwing filter to its own subscription', async () => {
const client = new HarnessClient(fakeLaunch())
cleanups.push(() => client.close())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const broken = client.subscribe(() => { throw new Error('filter exploded') })
// A non-Error throw is normalized rather than crashing dispatch.
const brokenNonError = client.subscribe(() => { throw 'string boom' })
const healthy = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
await client.prompt('filter-contain', normalizeInput('go'))
// The sibling subscription and the read loop are undisturbed.
expect((await healthy.next()).method).toBe('session.status')
// Each broken subscription failed with ITS OWN error and detached.
await expect(broken.next()).rejects.toThrow('filter exploded')
await expect(brokenNonError.next()).rejects.toThrow('string boom')
healthy.close()
await client.close()
})
it('close() drops queued notifications; runtime death keeps them drainable', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const closed = client.subscribe()
const drainable = client.subscribe()
await client.prompt('queue-drop', normalizeInput('go'))
expect(closed.tryNext()).toBeDefined()
closed.close()
// Manual close drops the rest of the queue outright.
expect(closed.tryNext()).toBeUndefined()
await expect(closed.next()).rejects.toThrow('notification subscription closed')
// Runtime teardown, by contrast, only stops FUTURE delivery: what was
// already delivered before close() stays drainable.
await client.close()
expect(drainable.tryNext()).toBeDefined()
})
it('subscriptions created after termination are born failed', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
await client.close()
// No producer can ever feed this subscription; next() must not park forever.
await expect(client.subscribe().next()).rejects.toThrow(TransportClosedError)
const dead = new HarnessClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1' }))
cleanups.push(() => dead.close())
await dead.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).catch(() => {})
await expect(dead.subscribe().next()).rejects.toThrow(TransportClosedError)
})
it('closes subscriptions with the runtime and rejects parked waiters', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const subscription = client.subscribe()
const parked = subscription.next()
await client.close()
await expect(parked).rejects.toThrow(TransportClosedError)
})
it('scopes the session tree across multi-hop lineage and ignores foreign sessions', async () => {
const client = new HarnessClient(fakeLaunch())
cleanups.push(() => client.close())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const tree = client.subscribeSessionTree('root')
// Lineage edges arrive as subagent.started notifications.
const inject = (method: string, params: Record<string, unknown>): void => {
(client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params })
}
inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' })
inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' })
inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } })
inject('session.event', { sessionId: 'stranger', event: { type: 'noop' } })
inject('subagent.started', { parentSessionId: 'other-root', childSessionId: 'other-child' })
inject('subagent.finished', { parentSessionId: 'child', childSessionId: 'grandchild' })
// Self-loop and empty edges must not corrupt the lineage map.
inject('subagent.started', { parentSessionId: 'loop', childSessionId: 'loop' })
inject('subagent.started', { parentSessionId: '', childSessionId: 'x' })
inject('subagent.finished', { childSessionId: 'root' })
expect((await tree.next()).method).toBe('subagent.started')
expect((await tree.next()).method).toBe('subagent.started')
expect((await tree.next()).params.sessionId).toBe('grandchild')
expect((await tree.next()).method).toBe('subagent.finished')
// The foreign-root edge and stranger event were filtered; next is the root-child edge.
expect((await tree.next()).params.childSessionId).toBe('root')
tree.close()
await client.close()
})
})
describe('wire payload validation', () => {
it('rejects a non-object session.event envelope as a protocol error', async () => {
const harness = harnessWith({ FAKE_MALFORMED_EVENT: '1' })
await expect(harness.run('bad-event')).rejects.toThrow(SdkProtocolError)
})
it('rejects an assistant/message without a content array as a protocol error', async () => {
const harness = harnessWith({ FAKE_MALFORMED_MESSAGE: '1' })
await expect(harness.run('bad-message')).rejects.toThrow(SdkProtocolError)
})
it('rejects an assistant/message without a data member as a protocol error', async () => {
const harness = harnessWith({ FAKE_MESSAGE_WITHOUT_DATA: '1' })
await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError)
})
})
describe('stderr tail bound', () => {
it('keeps only the newest lines up to the limit', async () => {
const manyLines = Array.from({ length: 450 }, (_, i) => `line-${i}`).join('\n')
const client = new HarnessClient(fakeLaunch({ FAKE_STDERR: manyLines, FAKE_EXIT_BEFORE_INIT: '1' }))
cleanups.push(() => client.close())
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
() => { throw new Error('initialize unexpectedly succeeded') },
(error: unknown) => error,
)
const text = String(failure)
// The tail is bounded to the newest 400 lines: the oldest are dropped.
expect(text).toContain('line-449')
expect(text).not.toContain('line-0\n')
})
})
describe('pure helpers', () => {
it('normalizeInput wraps strings and passes blocks through', () => {
expect(normalizeInput('x')).toEqual([{ type: 'text', text: 'x' }])
const blocks = [{ type: 'text' as const, text: 'y' }]
expect(normalizeInput(blocks)).toBe(blocks)
})
it('finalResponse reads the last assistant message and tolerates absence', () => {
expect(finalResponse([])).toBe('')
expect(finalResponse([{ type: 'turn/start', seq: 0, time: 0, data: { turn: 0 } } as never])).toBe('')
expect(finalResponse([
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'first' }] } } } as never,
{ type: 'assistant/message', seq: 1, time: 0, data: { message: { content: [{ type: 'text', text: 'a' }, { type: 'tool-call' }, { type: 'text', text: 'b' }] } } } as never,
])).toBe('ab')
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../protocol"
},
{
"path": "../../support/invariants"
}
]
}