refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and tui join packages/interaction/, app-boot becomes packages/boot/, and jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half beside client/protocol/create-sdk/helper/scripts/telemetry, whose folders drop the legacy sdk- prefix. Three new group README triplets replace the ui/ and sdk/ ones; tsconfig references/paths/globs, knip keys, vitest globs, gate scripts, catalogs, docs, and the lockfile follow. Adds the four settled FIXME rename markers (dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts). The scaffold folders diverge from their npm names until those renames land, so tsconfig.base.json maps the three affected names explicitly beside the group wildcard. Also repairs two pre-existing stale-path classes the strengthened sweep surfaced: docs/web-styling.md's retired web-ui host package and type-model spec fixture-literal joins. app-boot's three Loader-composition specs time out at the default 5s under full-suite parallel load on this filesystem (pre-existing; pass isolated with --testTimeout=30000); interaction/scaffold/boot suites otherwise green (687 passed).
This commit is contained in:
6
packages/scaffold/client/README.i18n.yaml
Normal file
6
packages/scaffold/client/README.i18n.yaml
Normal 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: f27ec256330254156c45b136c21308529ea99e3d
|
||||
README.zh.md: bd23762500a4da469839da8ddb50455e3419b553
|
||||
49
packages/scaffold/client/README.md
Normal file
49
packages/scaffold/client/README.md
Normal 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](../sdk-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.
|
||||
49
packages/scaffold/client/README.zh.md
Normal file
49
packages/scaffold/client/README.zh.md
Normal 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 发行版消费方。
|
||||
- **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../sdk-protocol/README.md))。
|
||||
- **没有逐提示词结果或取消**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执收集到 idle,放弃该过程意味着关闭运行时。
|
||||
- **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流保留了承载能力。
|
||||
40
packages/scaffold/client/package.json
Normal file
40
packages/scaffold/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
246
packages/scaffold/client/src/api.ts
Normal file
246
packages/scaffold/client/src/api.ts
Normal 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 ''
|
||||
}
|
||||
473
packages/scaffold/client/src/client.ts
Normal file
473
packages/scaffold/client/src/client.ts
Normal 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)
|
||||
}
|
||||
99
packages/scaffold/client/src/dispose.ts
Normal file
99
packages/scaffold/client/src/dispose.ts
Normal 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)
|
||||
}
|
||||
29
packages/scaffold/client/src/index.ts
Normal file
29
packages/scaffold/client/src/index.ts
Normal 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'
|
||||
31
packages/scaffold/client/src/invariant.ts
Normal file
31
packages/scaffold/client/src/invariant.ts
Normal 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 */
|
||||
74
packages/scaffold/client/src/types.ts
Normal file
74
packages/scaffold/client/src/types.ts
Normal 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 }
|
||||
231
packages/scaffold/client/tests/dispose.spec.ts
Normal file
231
packages/scaffold/client/tests/dispose.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
230
packages/scaffold/client/tests/fake-runtime.ts
Normal file
230
packages/scaffold/client/tests/fake-runtime.ts
Normal 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}` } })
|
||||
}
|
||||
})
|
||||
522
packages/scaffold/client/tests/sdk-client.spec.ts
Normal file
522
packages/scaffold/client/tests/sdk-client.spec.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* 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 tempDir('sdk-client-relcwd-')
|
||||
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()
|
||||
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')
|
||||
})
|
||||
})
|
||||
30
packages/scaffold/client/tsconfig.json
Normal file
30
packages/scaffold/client/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user