refactor(sdk): remove unreleased project toolchain

This commit is contained in:
Tianyi Cui
2026-08-11 14:20:53 +08:00
parent b0e022c150
commit daf90bda7e
256 changed files with 308 additions and 15082 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/README.md
README.md: 052ac933defae8766e99d79b79bc4cdc6c6d74db
README.zh.md: 364f752fcbed06cae8b0675cae852be2760bd3f4

11
packages/sdk/README.md Normal file
View File

@@ -0,0 +1,11 @@
# sdk/ — drive Harness runtimes from another process
English | [中文](README.zh.md)
This group contains the protocol stack for driving a Harness runtime from another process. Callers supply the runtime executable and its `cordis.yml`; this group does not create, configure, build, or launch developer projects. The [TypeScript SDK decision](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) owns the client contract, and the [toolchain removal](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md) owns the product boundary.
| Package | Role |
|---|---|
| [`protocol/`](protocol/README.md) | Defines the SDK runtime wire protocol |
| [`client/`](client/README.md) | Drives a Harness runtime through the TypeScript client API |
| [`server/`](server/README.md) | Serves out-of-process SDK clients over stdio JSON-RPC |

11
packages/sdk/README.zh.md Normal file
View File

@@ -0,0 +1,11 @@
# sdk/:从另一进程驱动 Harness 运行时
[English](README.md) | 中文
本组包含用于从另一进程驱动 Harness 运行时的协议栈。调用方提供运行时可执行文件及其 `cordis.yml`;本组不创建、配置、构建或启动开发者项目。[TypeScript SDK 决策](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)负责客户端约定,[工具链移除](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md)负责产品边界。
| 包 | 职责 |
|---|---|
| [`protocol/`](protocol/README.md) | 定义 SDK 运行时通信协议 |
| [`client/`](client/README.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 |
| [`server/`](server/README.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 |

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/client/README.md
README.md: b33457875f81d11d09bab2e5aa5ce730e233c78a
README.zh.md: 271f07ffb0f97abe005971962beb517acfdc05a4

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
{
"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-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/sdk/client"
},
"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": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-sdk-client`.
* @module @deepseek-ai/dsh-sdk-client/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-client'
/** Cordis companion plugin name. */
export const name = 'sdk-client-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this client library runs outside any harness context
* (its peer is a separate runtime process); the runtime's own packages own
* the event-stream relations.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

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

View File

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

View File

@@ -0,0 +1,234 @@
#!/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_EMPTY_MESSAGE`: the turn streams a text chunk, then records an empty
* assistant/message for a usage-only max-tokens step.
* - `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',
// Model the usage-only message recorded after a max-tokens step that
// assembled no output blocks.
content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
})
const reasonKind = env.FAKE_REASON_KIND ?? 'completed'
event(sessionId, 'turn/end', { turn: 0, reason: { kind: reasonKind } })
if (env.FAKE_SUBAGENT !== undefined) {
const childId = `${sessionId}-child`
notify('subagent.started', { parentSessionId: sessionId, childSessionId: childId })
event(childId, 'assistant/message', {
turn: 0,
step: 0,
content: [{ type: 'text', text: 'child says hi' }],
provenance: { provider: 'fake', model: 'fake' },
})
notify('subagent.finished', {
provider: 'spawn',
agentId: childId,
parentSessionId: sessionId,
childSessionId: childId,
status: 'ok',
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'child says hi' }],
})
}
}
function sessionIdOf(params: Record<string, unknown> | undefined): string {
const value = params?.sessionId
return typeof value === 'string' ? value : ''
}
const reader = createInterface({ input: process.stdin })
reader.on('line', (line) => {
if (line.trim().length === 0) return
const frame = JSON.parse(line) as { id?: string | number; method?: string; params?: Record<string, unknown> }
if (frame.method === undefined || frame.id === undefined) return
const respond = (result: object): void => { write({ jsonrpc: '2.0', id: frame.id, result }) }
switch (frame.method) {
case 'initialize':
if (env.FAKE_RECORD_INIT !== undefined) appendFileSync(env.FAKE_RECORD_INIT, `${JSON.stringify(frame.params)}\n`)
if (env.FAKE_HANG_INIT !== undefined) return
if (env.FAKE_INIT_READY !== undefined && env.FAKE_INIT_GO !== undefined) {
writeFileSync(env.FAKE_INIT_READY, 'ready\n')
const go = env.FAKE_INIT_GO
const id = frame.id
const poll = setInterval(() => {
if (!existsSync(go)) return
clearInterval(poll)
write({ jsonrpc: '2.0', id, result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } })
}, 5)
return
}
if (env.FAKE_INIT_ERROR !== undefined) {
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted init failure', data: { hint: 'fake' } } })
return
}
if (env.FAKE_INIT_ERROR_ONCE_FILE !== undefined && !existsSync(env.FAKE_INIT_ERROR_ONCE_FILE)) {
writeFileSync(env.FAKE_INIT_ERROR_ONCE_FILE, 'failed-once\n')
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted first-boot failure' } })
return
}
if (env.FAKE_MALFORMED !== undefined) {
respond({})
return
}
if (env.FAKE_ECHO_CWD_IN_INIT !== undefined) {
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: process.cwd() } })
return
}
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
return
case 'session/prompt': {
const sessionId = sessionIdOf(frame.params)
const messageId = `fake-user-${seq}`
event(sessionId, 'agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [{
id: messageId,
role: 'user',
content: [],
source: { kind: 'user' },
}],
})
notify('session.status', { sessionId, status: 'running' })
if (env.FAKE_STREAM_THEN_MALFORMED !== undefined) {
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } })
respond({})
return
}
if (env.FAKE_HANG_PROMPT !== undefined) return
if (env.FAKE_MALFORMED !== undefined || env.FAKE_MALFORMED_PROMPT !== undefined) {
respond({})
return
}
runTurn(sessionId)
notify('session.status', { sessionId, status: 'idle' })
respond({ messageId })
return
}
case 'shutdown':
respond({})
// An EOF-ignoring fake also refuses the protocol exit, so the client's
// dispose ladder (not this cooperative path) must reap it.
if (env.FAKE_IGNORE_EOF === undefined) setImmediate(() => process.exit(0))
return
default:
write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: `unknown method: ${frame.method}` } })
}
})

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/protocol/README.md
README.md: 082a890454f900aec51df123669f28814d39d601
README.zh.md: d9b8460e51b5313f4c3a8ac66471e8cd39142430

View File

@@ -0,0 +1,39 @@
# @deepseek-ai/dsh-sdk-protocol
English | [中文](README.zh.md)
The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delimited JSON-RPC 2.0 transport class plus the named request, result, and notification types both wire ends speak. The package root enumerates the protocol consumer interface; source modules are not exported as deep imports. The server side is the [`dsh-jsonrpc`](../server/README.md) plugin; clients are [`dsh-sdk-client`](../client/README.md) (TypeScript) and the [Python SDK](../../../python/README.md) (which mirrors these shapes but does not import them). A pure library — no plugin, no Config, no registration.
## Transport
`JsonRpcLineTransport` frames JSON-RPC 2.0 over caller-owned byte streams, one compact JSON frame per `\n`-terminated line. Frames with `id` and `method` are requests, `id` alone is a response, `method` alone is a notification; malformed JSON lines are ignored. `start()` attaches stream listeners, `close()` detaches them and rejects pending requests without destroying the streams. Missing request handlers answer `-32601`; handler rejections answer `-32603` with the error message. An error response rejects the pending `request()` with `JsonRpcResponseError`, which preserves the wire `code` and optional `data`. `JsonRpcTransportPeer` is the outbound surface (request/notify) the server class is typed against.
## Wire types
`types.ts` names every payload of the protocol served by `HarnessSdkServer`:
| Direction | Method | Types |
|---|---|---|
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) |
| client→server | `shutdown` | no params → `{}` |
| server→client | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) |
| server→client | `session.status` | `SessionStatusNotification` (whole-agent `running`/`idle` transition) |
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `SubagentFinishedNotification.lastAssistantMessage` contains the child's last non-empty assistant message or, when no such message exists, its accumulated assistant text; the field is absent when the child produced neither. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
## Model Experience
None, as this package defines the client-facing wire protocol; the model-visible surfaces belong to the runtime plugins composed behind the serving [`dsh-jsonrpc`](../server/README.md) entry.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No protocol-version negotiation** — the handshake carries only `serverInfo.version` (`0.0.1`, unvalidated by clients); pre-release stance, no compatibility promise.
- **No cancel or session-close methods** — a client abandons a turn by closing the runtime process; see the [`dsh-jsonrpc` README](../server/README.md).
- **Server→client requests are dead capability** — the transport supports them, but the server never sends one; the Python SDK's responder surface exists for future approval flows.

View File

@@ -0,0 +1,39 @@
# @deepseek-ai/dsh-sdk-protocol
[English](README.md) | 中文
DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按换行分帧的 JSON-RPC 2.0 传输类,加上协议两端共同使用的具名请求、结果与通知类型。包根枚举协议消费方接口;源模块不支持深层导入。服务端是 [`dsh-jsonrpc`](../server/README.md) 插件;客户端是 [`dsh-sdk-client`](../client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者复现这些结构但不导入它们)。纯库——无插件、无 Config、无注册。
## 传输
`JsonRpcLineTransport` 在调用方持有的字节流上为 JSON-RPC 2.0 分帧,每行一个紧凑 JSON 帧、以 `\n` 结尾。带 `id` 与 `method` 的帧是请求,仅 `id` 是响应,仅 `method` 是通知;非法 JSON 行被忽略。`start()` 挂接流监听器,`close()` 移除监听器并拒绝挂起请求,但不销毁流。缺失请求处理器时应答 `-32601`;处理器返回的 Promise 被拒绝时,则应答携带错误消息的 `-32603`。错误响应会以 `JsonRpcResponseError` 拒绝挂起的 `request()` Promise,并保留协议格式中的 `code` 与可选 `data`。`JsonRpcTransportPeer` 是服务器类据以进行类型声明的出站接口(request/notify)。
## 协议类型
`types.ts` 为 `HarnessSdkServer` 所服务协议的每个载荷命名:
| 方向 | 方法 | 类型 |
|---|---|---|
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) |
| client→server | `shutdown` | 无参数 → `{}` |
| server→client | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) |
| server→client | `session.status` | `SessionStatusNotification`(整个 agent(智能体)的 `running`/`idle` 转换) |
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`SubagentFinishedNotification.lastAssistantMessage` 包含子 agent 最后一条非空 assistant 消息;若不存在这类消息,则包含其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。
## 模型体验
无,因为此包定义面向客户端的协议格式;模型可见接口属于组合在对外服务入口 [`dsh-jsonrpc`](../server/README.md) 后方的运行时插件。
#### KV Cache 影响
无;此包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **无协议版本协商**——握手只携带 `serverInfo.version`(`0.0.1`,客户端不校验);处于预发布阶段,无兼容承诺。
- **无取消与会话关闭方法**——客户端放弃轮次的方式是关闭运行时进程;见 [`dsh-jsonrpc` README](../server/README.md)。
- **server→client 请求是未使用的功能**——传输层支持,但服务器从不发送;Python SDK 的应答接口为未来审批流程预留。

View File

@@ -0,0 +1,47 @@
{
"name": "@deepseek-ai/dsh-sdk-protocol",
"description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/sdk/protocol"
},
"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": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,25 @@
/**
* Shared wire protocol for the DeepSeek Harness SDK runtime: the
* newline-delimited JSON-RPC stdio transport plus the named request, result,
* and notification types both wire ends speak. The runtime server plugin
* (`@deepseek-ai/dsh-jsonrpc`) serves this protocol; SDK clients
* (`@deepseek-ai/dsh-sdk-client`, the Python SDK) drive it.
*
* @module @deepseek-ai/dsh-sdk-protocol
*/
export { JsonRpcLineTransport, JsonRpcResponseError } from './transport.ts'
export type { JsonRpcTransportPeer } from './transport.ts'
export type {
HarnessSdkNotificationMap,
HarnessSdkRequestMap,
InitializeParams,
InitializeResult,
SdkRunStatus,
SessionEventNotification,
SessionStatusNotification,
SessionPromptParams,
SessionPromptResult,
SubagentFinishedNotification,
SubagentStartedNotification,
} from './types.ts'

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-sdk-protocol`.
* @module @deepseek-ai/dsh-sdk-protocol/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-protocol'
/** Cordis companion plugin name. */
export const name = 'sdk-protocol-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a pure wire library (transport class + type
* declarations) with no event stream or mutable data relation of its own;
* both wire ends own their protocol behavior.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,279 @@
/**
* Newline-delimited JSON-RPC 2.0 over byte streams. Frames with `id` and
* `method` are requests, `id` alone is a response, and `method` alone is a
* notification. Malformed lines are ignored; handler failures become error frames.
*
* @module @deepseek-ai/dsh-sdk-protocol/transport
*/
import { randomUUID } from 'node:crypto'
import type { Readable, Writable } from 'node:stream'
import { StringDecoder } from 'node:string_decoder'
type JsonRpcId = string | number
type RequestHandler = (method: string, params: Record<string, unknown>) => Promise<unknown>
type NotificationHandler = (method: string, params: Record<string, unknown>) => void
/** A JSON-RPC error response, preserving the wire `code` and optional `data`. */
export class JsonRpcResponseError extends Error {
/**
* @param code - the wire error code, or `undefined` when the peer sent none.
* @param message - the wire error message.
* @param data - the optional structured error payload, verbatim.
*/
constructor(readonly code: number | undefined, message: string, readonly data?: unknown) {
super(message)
this.name = 'JsonRpcResponseError'
}
}
/**
* Outbound request and notification surface used by the runtime server and
* SDK clients.
*/
export interface JsonRpcTransportPeer {
/**
* Send a request and await its response.
* @param method - the JSON-RPC method name.
* @param params - the request parameters object.
* @returns the result; rejects with {@link JsonRpcResponseError} on an error
* response, and with a plain `Error` on a write failure or closure.
*/
request(method: string, params: object): Promise<unknown>
/**
* Send a notification; omitted params produce no `params` member.
* @param method - the JSON-RPC method name.
* @param params - the optional notification parameters object.
*/
notify(method: string, params?: object): void
}
interface PendingRequest {
resolve: (value: unknown) => void
reject: (error: Error) => void
}
/**
* Line-delimited endpoint over caller-owned streams. {@link start} attaches
* listeners; {@link close} detaches them and rejects pending requests without
* destroying the streams. Missing request handlers return `-32601`; handler
* failures return `-32603`. Notifications without a handler are dropped.
*/
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
private buffer = ''
private readonly decoder = new StringDecoder('utf8')
private started = false
private requestHandler: RequestHandler | undefined
private notificationHandler: NotificationHandler | undefined
private readonly pending = new Map<JsonRpcId, PendingRequest>()
constructor(
private readonly input: Readable,
private readonly output: Writable,
) {}
/** Attach the input listeners and begin reading frames. Idempotent. */
start(): void {
if (this.started) return
this.started = true
this.input.on('data', this.onData)
this.input.on('error', this.onInputError)
this.input.on('end', this.onInputEnd)
}
/**
* Detach listeners and reject pending requests. Safe before {@link start}.
*/
close(): void {
this.input.off('data', this.onData)
this.input.off('error', this.onInputError)
this.input.off('end', this.onInputEnd)
this.failPending(new Error('JSON-RPC transport closed'))
}
/**
* Install the request handler, replacing any prior handler.
* @param handler - resolves to the response `result`; a rejection becomes a
* `-32603` error response carrying the message.
*/
onRequest(handler: RequestHandler): void {
this.requestHandler = handler
}
/**
* Install the notification handler, replacing any prior handler.
* @param handler - invoked per notification with the method and normalized
* params object.
*/
onNotification(handler: NotificationHandler): void {
this.notificationHandler = handler
}
/**
* Send a request and await its response.
* @param method - the JSON-RPC method name.
* @param params - the request parameters object.
* @param signal - optional abandonment signal: aborting removes the pending
* entry (no state is retained for a response that may never come) and
* rejects with the signal's reason.
* @returns the result; rejects per {@link JsonRpcTransportPeer.request}.
*/
request(method: string, params: object, signal?: AbortSignal): Promise<unknown> {
const id = `req_${randomUUID().replaceAll('-', '')}`
const message = { jsonrpc: '2.0', id, method, params }
return new Promise((resolve, reject) => {
let detach = (): void => {}
if (signal !== undefined) {
if (signal.aborted) {
reject(abortError(signal.reason))
return
}
const onAbort = (): void => {
this.pending.delete(id)
reject(abortError(signal.reason))
}
signal.addEventListener('abort', onAbort, { once: true })
detach = () => { signal.removeEventListener('abort', onAbort) }
}
this.pending.set(id, {
resolve: (value) => {
detach()
resolve(value)
},
reject: (error) => {
detach()
reject(error)
},
})
try {
this.write(message)
} catch (error) {
this.pending.delete(id)
detach()
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
notify(method: string, params?: object): void {
this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params })
}
/**
* Wait for prior frame write callbacks. The empty barrier emits no bytes.
* @returns a promise that settles with the output write callback.
*/
flush(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.output.write('', (error) => {
if (error) reject(error)
else resolve()
})
})
}
private readonly onData = (chunk: Buffer | string): void => {
this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk)
this.drainLines()
}
private drainLines(): void {
for (;;) {
const newline = this.buffer.indexOf('\n')
if (newline < 0) break
const line = this.buffer.slice(0, newline).trim()
this.buffer = this.buffer.slice(newline + 1)
if (!line) continue
void this.handleLine(line)
}
}
private readonly onInputError = (error: Error): void => {
this.failPending(error)
}
private readonly onInputEnd = (): void => {
this.buffer += this.decoder.end()
this.drainLines()
this.failPending(new Error('JSON-RPC input closed'))
}
private async handleLine(line: string): Promise<void> {
let message: unknown
try {
message = JSON.parse(line)
} catch {
// Only JSON syntax errors reach this catch; malformed peer lines are ignored.
return
}
if (!message || typeof message !== 'object') return
const frame = message as Record<string, unknown>
const id = frame.id
const method = frame.method
if ((typeof id === 'string' || typeof id === 'number') && typeof method === 'string') {
await this.handleIncomingRequest(id, method, objectParams(frame.params))
return
}
if (typeof id === 'string' || typeof id === 'number') {
this.handleIncomingResponse(id, frame)
return
}
if (typeof method === 'string') {
this.notificationHandler?.(method, objectParams(frame.params))
}
}
private async handleIncomingRequest(id: JsonRpcId, method: string, params: Record<string, unknown>): Promise<void> {
const handler = this.requestHandler
if (!handler) {
this.writeError(id, -32601, `method not found: ${method}`)
return
}
try {
const result = await handler(method, params)
this.write({ jsonrpc: '2.0', id, result })
} catch (error) {
this.writeError(id, -32603, error instanceof Error ? error.message : String(error))
}
}
private handleIncomingResponse(id: JsonRpcId, frame: Record<string, unknown>): void {
const pending = this.pending.get(id)
if (!pending) return
this.pending.delete(id)
if (frame.error && typeof frame.error === 'object') {
const error = frame.error as Record<string, unknown>
pending.reject(new JsonRpcResponseError(
typeof error.code === 'number' ? error.code : undefined,
typeof error.message === 'string' ? error.message : 'JSON-RPC error',
error.data,
))
return
}
pending.resolve(frame.result)
}
private writeError(id: JsonRpcId, code: number, message: string): void {
this.write({ jsonrpc: '2.0', id, error: { code, message } })
}
private write(message: Record<string, unknown>): void {
this.output.write(`${JSON.stringify(message)}\n`)
}
private failPending(error: Error): void {
const pending = [...this.pending.values()]
this.pending.clear()
for (const waiter of pending) waiter.reject(error)
}
}
/** Normalize JSON-RPC `params` to a plain object (arrays and scalars collapse to `{}`). */
function objectParams(params: unknown): Record<string, unknown> {
return params && typeof params === 'object' && !Array.isArray(params) ? params as Record<string, unknown> : {}
}
/** Normalize an abort reason into the rejection Error (a non-Error reason is stringified). */
function abortError(reason: unknown): Error {
return reason instanceof Error ? reason : new Error(`JSON-RPC request aborted: ${String(reason)}`)
}

View File

@@ -0,0 +1,105 @@
/**
* Named wire types for the DeepSeek Harness SDK runtime protocol: the three
* request/result pairs and the four server-to-client notification payloads
* exchanged over the newline-delimited JSON-RPC stdio transport. The server
* plugin (`@deepseek-ai/dsh-jsonrpc`) and SDK clients share these shapes;
* `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
*
* @module @deepseek-ai/dsh-sdk-protocol/types
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent'
/** Parameters for the process-wide SDK handshake. */
export interface InitializeParams {
/** Working directory recorded on every SDK-created session's header. */
cwd: string
/** Provider route every SDK-created agent runs on. */
provider: string
/** Model name every SDK-created agent runs on (the server may mount a fallback adapter; see `HarnessSdkServer.initialize`). */
model: string
/** Optional positive output-token cap inherited by SDK-created agents and their in-process descendants. */
maxTokens?: number
}
/** Wire-stable server identity returned by initialization. */
export interface InitializeResult {
/** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */
serverInfo: { name: string; version: string }
}
/** One user turn on one SDK session. */
export interface SessionPromptParams {
/** The SDK-side session id; an unknown id lazily creates the agent+session pair. */
sessionId: string
/** The prompt content blocks, sent verbatim as the user message. */
contentBlocks: ContentBlock[]
}
/** Durable enqueue receipt for one prompt. */
export interface SessionPromptResult {
/** Identity of the queued user message. */
messageId: string
}
/** Deployment-mapped SDK outcome: `ok` for an accepted result, `error` otherwise. */
export type SdkRunStatus = 'ok' | 'error'
/** `session.event` payload: one session-log event, streamed as it is recorded. */
export interface SessionEventNotification {
/** Session the event belongs to (every session in the runtime, not only SDK-created ones). */
sessionId: string
/** The full session-log event envelope. */
event: SessionEvent
}
/** Whole-agent lifecycle state for one session. */
export interface SessionStatusNotification {
/** Session whose live agent changed status. */
sessionId: string
/** The whole-agent state after the transition. */
status: 'idle' | 'running'
}
/** `subagent.started` payload: an in-runtime child session was created. */
export interface SubagentStartedNotification {
/** The delegating session. */
parentSessionId: string
/** The new child session. */
childSessionId: string
}
/** `subagent.finished` payload: an in-process subagent run ended (remote runs are not reported). */
export interface SubagentFinishedNotification {
/** Subagent provider name that ran the child. */
provider: string
/** The child agent's id (equals {@link childSessionId} for local runs). */
agentId: string
/** The delegating session. */
parentSessionId: string
/** The child session. */
childSessionId: string
/** Deployment-mapped run outcome. */
status: SdkRunStatus
/** The provider-reported stop reason. */
stopReason: SubagentStopReason
/** The child's selected assistant output; absent when the child produced none. */
lastAssistantMessage?: ContentBlock[]
}
/** Server-to-client notifications by JSON-RPC method name. */
export interface HarnessSdkNotificationMap {
'session.event': SessionEventNotification
'session.status': SessionStatusNotification
'subagent.started': SubagentStartedNotification
'subagent.finished': SubagentFinishedNotification
}
/** Client-to-server request methods with their param and result shapes. */
export interface HarnessSdkRequestMap {
'initialize': { params: InitializeParams; result: InitializeResult }
'session/prompt': { params: SessionPromptParams; result: SessionPromptResult }
'shutdown': { params: undefined; result: Record<string, never> }
}

View File

@@ -0,0 +1,307 @@
import { once } from 'node:events'
import { PassThrough, Writable } from 'node:stream'
import { describe, expect, it } from 'vitest'
import { JsonRpcLineTransport, JsonRpcResponseError } from '../src/index.ts'
function transportPair() {
const aToB = new PassThrough()
const bToA = new PassThrough()
const a = new JsonRpcLineTransport(bToA, aToB)
const b = new JsonRpcLineTransport(aToB, bToA)
return { a, b, aToB, bToA }
}
describe('JsonRpcLineTransport', () => {
it('supports bidirectional requests and notifications over newline-delimited JSON-RPC', async () => {
const { a, b } = transportPair()
const notifications: Record<string, unknown>[] = []
a.onRequest(async (method, params) => {
expect(method).toBe('echo')
return { echoed: params }
})
b.onNotification((method, params) => {
notifications.push({ method, params })
})
a.start()
b.start()
const response = await b.request('echo', { value: 42 })
expect(response).toEqual({ echoed: { value: 42 } })
a.notify('session.status', { sessionId: 'main', status: 'idle' })
a.notify('heartbeat')
await new Promise(resolve => setTimeout(resolve, 10))
expect(notifications).toEqual([
{ method: 'session.status', params: { sessionId: 'main', status: 'idle' } },
{ method: 'heartbeat', params: {} },
])
a.close()
b.close()
})
it('reports JSON-RPC request errors from the remote peer with their wire code', async () => {
const { a, b } = transportPair()
a.onRequest(async () => {
throw new Error('handler boom')
})
a.start()
b.start()
const failure = await b.request('explode', {}).then(
() => { throw new Error('request unexpectedly succeeded') },
(error: unknown) => error,
)
expect(failure).toBeInstanceOf(JsonRpcResponseError)
expect(failure).toMatchObject({ message: 'handler boom', code: -32603, data: undefined })
a.close()
b.close()
})
it('rejects immediately on a pre-aborted signal without registering pending state', async () => {
const { b } = transportPair()
b.start()
const controller = new AbortController()
controller.abort(new Error('already gone'))
await expect(b.request('never-sent', {}, controller.signal)).rejects.toThrow('already gone')
expect((b as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0)
b.close()
})
it('abandons a pending request on abort, stringifying a non-Error reason', async () => {
const { b } = transportPair()
b.start()
const controller = new AbortController()
const pending = b.request('never-answered', {}, controller.signal)
controller.abort('plain-string-reason')
await expect(pending).rejects.toThrow('JSON-RPC request aborted: plain-string-reason')
// The abandonment removed the pending entry — nothing is retained for a
// response that may never come.
expect((b as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0)
b.close()
})
it('preserves structured error data from an error response frame', async () => {
const { aToB, bToA, b } = transportPair()
b.start()
const pending = b.request('remote-error-data', {})
const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
const request = JSON.parse(String(requestChunk)) as { id: string }
aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: 7, message: 'structured', data: { detail: 'x' } } })}\n`)
const failure = await pending.then(
() => { throw new Error('request unexpectedly succeeded') },
(error: unknown) => error,
)
expect(failure).toBeInstanceOf(JsonRpcResponseError)
expect(failure).toMatchObject({ code: 7, message: 'structured', data: { detail: 'x' } })
b.close()
})
it('stringifies non-Error request handler failures', async () => {
const { a, b } = transportPair()
a.onRequest(async () => {
throw 'string boom'
})
a.start()
b.start()
await expect(b.request('explode-string', {})).rejects.toThrow('string boom')
a.close()
b.close()
})
it('reports method-not-found when no request handler is installed', async () => {
const { a, b } = transportPair()
a.start()
b.start()
await expect(b.request('missing', {})).rejects.toThrow('method not found: missing')
a.close()
b.close()
})
it('normalizes non-object request params and ignores notifications without a handler', async () => {
const { aToB, bToA, b } = transportPair()
const seen: Record<string, unknown>[] = []
b.onRequest(async (method, params) => {
seen.push({ method, params })
return { ok: true }
})
b.start()
aToB.write('{"jsonrpc":"2.0","method":"ignored"}\n')
aToB.write('{"jsonrpc":"2.0","id":7,"method":"array-params","params":[]}\n')
const chunk = (await once(bToA, 'data'))[0] as Buffer | string
expect(seen).toEqual([{ method: 'array-params', params: {} }])
expect(JSON.parse(String(chunk))).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } })
b.close()
})
it('ignores malformed frames and accepts notifications without params', async () => {
const { aToB, b } = transportPair()
const notifications: Record<string, unknown>[] = []
b.onNotification((method, params) => {
notifications.push({ method, params })
})
b.start()
b.start()
aToB.write('not json\n')
aToB.write('\n')
aToB.write('null\n')
aToB.write('{"jsonrpc":"2.0","params":{}}\n')
aToB.write('{"jsonrpc":"2.0","method":"tick"}\n')
aToB.emit('data', '{"jsonrpc":"2.0","method":"string-chunk"}\n')
await new Promise(resolve => setTimeout(resolve, 10))
expect(notifications).toEqual([
{ method: 'tick', params: {} },
{ method: 'string-chunk', params: {} },
])
b.close()
})
it('preserves multibyte UTF-8 characters split across Buffer chunks', async () => {
const input = new PassThrough()
const output = new PassThrough()
const transport = new JsonRpcLineTransport(input, output)
const notifications: Record<string, unknown>[] = []
transport.onNotification((method, params) => { notifications.push({ method, params }) })
transport.start()
const frame = Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', method: 'message', params: { text: '你好' } })}\n`)
const character = Buffer.from('你')
const characterStart = frame.indexOf(character)
expect(characterStart).toBeGreaterThanOrEqual(0)
input.write(frame.subarray(0, characterStart + 1))
input.write(frame.subarray(characterStart + 1))
await new Promise(resolve => setTimeout(resolve, 10))
expect(notifications).toEqual([{ method: 'message', params: { text: '你好' } }])
transport.close()
})
it('flush waits for all earlier output writes', async () => {
const events: string[] = []
const output = new Writable({
write(chunk: Buffer, _encoding, callback) {
const label = chunk.length === 0 ? 'barrier' : 'frame'
events.push(`start:${label}`)
setTimeout(() => {
events.push(`finish:${label}`)
callback()
}, 5)
},
})
const transport = new JsonRpcLineTransport(new PassThrough(), output)
transport.notify('tick')
await transport.flush()
expect(events).toEqual([
'start:frame',
'finish:frame',
'start:barrier',
'finish:barrier',
])
transport.close()
})
it('reports an output callback failure from flush', async () => {
const output = {
write(_chunk: string, callback?: (error?: Error) => void) {
callback?.(new Error('flush failed'))
return true
},
}
const transport = new JsonRpcLineTransport(new PassThrough(), output as never)
await expect(transport.flush()).rejects.toThrow('flush failed')
})
it('rejects pending requests when the input closes', async () => {
const { aToB, b } = transportPair()
b.start()
const pending = b.request('never-replies', {})
aToB.end()
await expect(pending).rejects.toThrow('JSON-RPC input closed')
b.close()
})
it('rejects pending requests when the input errors', async () => {
const { aToB, b } = transportPair()
b.start()
const pending = b.request('never-replies', {})
aToB.emit('error', new Error('input broke'))
await expect(pending).rejects.toThrow('input broke')
b.close()
})
it('rejects pending requests when the transport closes', async () => {
const { b } = transportPair()
const pending = b.request('never-replies', {})
b.close()
await expect(pending).rejects.toThrow('JSON-RPC transport closed')
})
it('rejects a request when writing the frame throws', async () => {
const input = new PassThrough()
const output = {
write() {
throw new Error('write exploded')
},
}
const transport = new JsonRpcLineTransport(input, output as never)
await expect(transport.request('write-fails', {})).rejects.toThrow('write exploded')
})
it('stringifies non-Error write failures', async () => {
const input = new PassThrough()
const output = {
write() {
throw 'write string'
},
}
const transport = new JsonRpcLineTransport(input, output as never)
await expect(transport.request('write-fails', {})).rejects.toThrow('write string')
})
it('uses a fallback message for malformed JSON-RPC error responses', async () => {
const { aToB, bToA, b } = transportPair()
b.start()
const pending = b.request('remote-error', {})
const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
const request = JSON.parse(String(requestChunk)) as { id: string }
aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: {} })}\n`)
await expect(pending).rejects.toThrow('JSON-RPC error')
b.close()
})
it('ignores responses that do not match a pending request', async () => {
const { aToB, b } = transportPair()
b.start()
aToB.write('{"jsonrpc":"2.0","id":"unknown","result":{"ignored":true}}\n')
await new Promise(resolve => setTimeout(resolve, 10))
b.close()
})
})

View File

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

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/server/README.md
README.md: c5a7da2dd2ee962bd4d7cac7063ae1191cab3324
README.zh.md: f1e425bc90df4119db9b1ffad7c3ef454369cb4c

View File

@@ -0,0 +1,48 @@
# @deepseek-ai/dsh-jsonrpc
English | [中文](README.zh.md)
The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; the transport and the named wire types live in [`dsh-sdk-protocol`](../protocol/README.md), shared with the client SDKs; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application.
## Wiring
`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`.
## Config
`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`.
## stdout is the protocol
Stdout carries only JSON-RPC frames. The deployment must not compose a stdout logger; diagnostics belong on stderr.
## Shutdown and exit semantics
The plugin answers `shutdown`, flushes the response, disposes the root context so SDK-owned agents, subscriptions, and persistence reach quiescence, then exits with code 0. EOF and signal exits belong to the app bin, which also disposes the root context. Unloading only this plugin stops serving without exiting the process.
## Wire notes
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from `cordis.yml`.
## Model Experience
### SDK user message
#### What the model sees
For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the plugins in the surrounding `cordis.yml`.
#### Token effect
Data-dependent user-message tokens enter retained session history and are resent on later turns until another package compacts them. The JSON-RPC frames, session notifications, and server bookkeeping add zero model-context tokens.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **The wire has no per-session close or prompt-cancel method** — SDK-created agents remain live until process shutdown.
- **There is no per-prompt result** — `MessageId` identifies inbox admission only; clients that own an automation interval must define and observe that interval themselves.
- **stdout purity is deployment-enforced** — a surrounding config can still load a stdout logger and corrupt the JSON-RPC channel; this plugin does not inspect or veto sibling loggers.
- **Automatic adapter mounting is DeepSeek-specific** — `initialize` can reuse any pre-registered model adapter, but its only fallback mounts `dsh-llm-deepseek`.

View File

@@ -0,0 +1,48 @@
# @deepseek-ai/dsh-jsonrpc
[English](README.md) | 中文
`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。
## 组装
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。
## 配置
`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。
## stdout 即协议
Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写入 stderr。
## 关闭与退出语义
插件响应 `shutdown`,刷新响应并 dispose(资源释放)根上下文,使 SDK 持有的 agent、订阅和持久化全部停稳,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者也会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。
## 协议说明
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由 `cordis.yml` 提供。
## 模型体验
### SDK 用户消息
#### 模型看到的内容
对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样接收为该 SDK 会话中的一条用户消息。此包不会添加系统提示词文本或工具 schema;这些内容来自外围 `cordis.yml` 中的插件。
#### Token 影响
依数据而定的用户消息 token 会进入保留的会话历史,并在后续轮次中重复发送,直至另一个包将其压缩(compaction)。JSON-RPC 帧、会话通知和服务器内部记录不会增加模型上下文 token。
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
## 已知限制与暂缓事项
- **协议没有逐会话关闭或提示词取消方法**:SDK 创建的 agent 会一直存活到进程关闭。
- **没有逐提示词结果**:`MessageId` 只标识 inbox 准入;拥有自动化活动区间的客户端必须自行定义并观察该区间。
- **stdout 纯净性由部署保证**:外围配置仍可能加载 stdout logger 并破坏 JSON-RPC 通道;此插件不会检查或否决同级 logger。
- **自动挂载适配器仅支持 DeepSeek**:`initialize` 可以复用任何预先注册的模型适配器,但唯一的回退行为是挂载 `dsh-llm-deepseek`。

View File

@@ -0,0 +1,62 @@
{
"name": "@deepseek-ai/dsh-jsonrpc",
"description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/sdk/server"
},
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,96 @@
/**
* SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides
* whether to load it; see the single-executable Agent Note and package README.
* Stdout is reserved for protocol frames, so the tree must not load a stdout logger.
* This plugin answers `shutdown`, disposes the complete root runtime, and exits 0; the app bin
* owns EOF and signal exits. Keep named plugin exports with no default export so
* Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`.
*
* FIXME: rename to `@deepseek-ai/dsh-sdk-server` before the first tagged release —
* the current name says the wire encoding, not the role; it is the server half of
* the SDK protocol ([regrouping Agent Note](../../../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md)).
*
* @module @deepseek-ai/dsh-jsonrpc
*/
import type { Context } from '@deepseek-ai/cordis'
import type { Readable, Writable } from 'node:stream'
import Schema from '@deepseek-ai/schemastery'
import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
import { HarnessSdkServer } from './server.ts'
export * from './server.ts'
export const name = 'jsonrpc'
// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
export const inject = ['agents']
/** JSON-RPC deployment config plus runtime-only test hooks. */
export interface JsonRpcConfig {
/** Report max-token turn/subagent termination as a successful SDK result. */
maxTokensAsSuccess?: boolean
/** Transport input override; production uses `process.stdin`. */
input?: Readable
/** Transport output override; production uses `process.stdout`. */
output?: Writable
/** Process-exit override; production uses `process.exit`. */
exit?: (code: number) => void
}
export const Config: Schema<JsonRpcConfig> = Schema.object({
maxTokensAsSuccess: Schema.boolean().default(false),
})
/**
* Serve SDK requests over the configured streams. Effect disposal shuts down
* SDK-created agents and closes the transport. A `shutdown` response is flushed
* before the root runtime is disposed and the process exits 0; the app bin
* owns root-context disposal for EOF and signals.
*/
export function apply(ctx: Context, config: JsonRpcConfig): void {
// Cordis applies the schema default before invoking the plugin.
const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean }
// Protocol shutdown owns the complete runtime process, so it must await the
// root lifecycle (including persistence) before exiting.
const rootFiber = ctx.root.fiber
/* v8 ignore next -- production stdio wiring; tests always inject the runtime hooks */
const input = config.input ?? process.stdin
/* v8 ignore next -- production stdio wiring; tests always inject the runtime hooks */
const output = config.output ?? process.stdout
/* v8 ignore next -- production exit wiring; tests always inject the runtime hooks */
const exit = config.exit ?? ((code: number): void => { process.exit(code) })
const transport = new JsonRpcLineTransport(input, output)
const server = new HarnessSdkServer(ctx, transport, {
maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
})
// Share one exit task so racing shutdown requests cannot dispose the root or
// exit the process more than once.
let exitTask: Promise<void> | undefined
const disposeAndExit = (): Promise<void> => {
exitTask ??= (async () => {
await Promise.allSettled([Promise.resolve().then(() => transport.flush())])
await Promise.allSettled([Promise.resolve().then(() => rootFiber.dispose())])
exit(0)
})()
return exitTask
}
transport.onRequest(async (method, params) => {
const result = await server.handleRequest(method, params)
if (method === 'shutdown') {
// Run after the handler result is written; the task then flushes, disposes, and exits.
setImmediate(() => { void disposeAndExit() })
}
return result
})
ctx.effect(() => {
transport.start()
return async () => {
await server.shutdown()
transport.close()
}
}, 'jsonrpc.serve')
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-jsonrpc`.
* @module @deepseek-ai/dsh-jsonrpc/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc'
/** Cordis companion plugin name. */
export const name = 'jsonrpc-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
* boundary and replay tests cover its protocol mapping.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,236 @@
/**
* JSON-RPC method and notification surface for out-of-process harness SDKs.
* The surrounding context owns plugins, persistence, and configured adapters.
*
* @module @deepseek-ai/dsh-jsonrpc/server
*/
import type { Context } from '@deepseek-ai/cordis'
import { resolve } from 'node:path'
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
import { SessionId } from '@deepseek-ai/dsh-session'
import type SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type {
InitializeParams,
InitializeResult,
JsonRpcTransportPeer,
SessionEventNotification,
SessionPromptParams,
SessionPromptResult,
SubagentFinishedNotification,
SubagentStartedNotification,
} from '@deepseek-ai/dsh-sdk-protocol'
interface SessionRecord {
handle: AgentHandle
}
/** Recover the delegating parent from the service-owned scoped carrier. */
function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
return carrierKeyOf(carrier) as Agent
}
/** Deployment-specific status mapping for SDK turn and subagent outcomes. */
export interface HarnessSdkServerOptions {
/** Report max-token termination as an accepted result instead of an infrastructure error. */
maxTokensAsSuccess?: boolean
}
function successStatus(reason: string, options: HarnessSdkServerOptions): 'ok' | 'error' {
if (reason === 'completed') return 'ok'
return reason === 'max-tokens' && options.maxTokensAsSuccess === true ? 'ok' : 'error'
}
/**
* SDK server over one booted harness context and transport peer. Construction
* subscribes to session, agent, and subagent lifecycle events until shutdown;
* reinitialization is unsupported.
*/
export class HarnessSdkServer {
private cwd = process.cwd()
private provider = 'deepseek-official'
private model = 'deepseek-official'
private maxTokens: number | undefined
private llmFiber: { dispose(): Promise<void> } | undefined
private readonly sessions = new Map<string, SessionRecord>()
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
private readonly disposers: (() => void)[] = []
private shutdownTask: Promise<Record<string, never>> | undefined
private shuttingDown = false
constructor(
private readonly ctx: Context,
private readonly transport: JsonRpcTransportPeer,
private readonly options: HarnessSdkServerOptions = {},
) {
const serverOptions = this.options
this.disposers.push(ctx.on('session/event', (session, event) => {
const payload: SessionEventNotification = { sessionId: String(session.id), event }
this.transport.notify('session.event', payload)
}))
this.disposers.push(ctx.on('agent/status', ({ agent, status }) => {
this.transport.notify('session.status', { sessionId: String(agent.session.id), status })
}))
this.disposers.push(ctx.on('session/created', (session) => {
const parentSession = session.header.parentSession
if (parentSession === undefined) return
const payload: SubagentStartedNotification = {
parentSessionId: String(parentSession),
childSessionId: String(session.id),
}
this.transport.notify('subagent.started', payload)
}))
this.disposers.push(ctx.on('subagent/end', function (this: Scoped<SubagentService>, info: SubagentRunEndInfo) {
const parent = subagentParentOf(this)
// This protocol reports only in-process child sessions. The service
// snapshots the provider name and local flag through child disposal;
// matching ids or parent lineage alone never establishes locality.
if (!info.local) return
const payload: SubagentFinishedNotification = {
provider: info.provider,
agentId: String(info.id),
parentSessionId: String(parent.session.id),
childSessionId: String(info.id),
status: successStatus(info.stopReason, serverOptions),
stopReason: info.stopReason,
...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
}
transport.notify('subagent.finished', payload)
}))
}
/**
* Configure the SDK route, mounting the DeepSeek fallback only when unowned.
* @param params - SDK handshake parameters.
* @returns server identity for the handshake.
*/
async initialize(params: InitializeParams): Promise<InitializeResult> {
if (params.maxTokens !== undefined
&& (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) {
throw new TypeError('initialize maxTokens must be a positive safe integer')
}
this.cwd = resolve(params.cwd)
this.provider = params.provider
this.model = params.model
this.maxTokens = params.maxTokens
if (!this.hasAdapterFor(this.provider)) {
if (this.provider !== 'deepseek-official') throw new Error(`no adapter registered for provider "${this.provider}"`)
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
}
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
}
/**
* Queue one identified prompt without assigning later activity to it.
* @param params - target session and user content.
* @returns the durable message identity.
*/
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
const rec = await this.getOrCreateSession(params.sessionId)
// An agent-loop-only reload disposes the loop's agents while this record
// survives; a retained agent accepts followup() silently, so validate the
// record against the live registry before delivery (as the ACP bridge does).
if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
throw new Error(`session agent was disposed outside the server: ${params.sessionId}`)
}
const message = createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } })
rec.handle.agent.followup(message)
return { messageId: message.id }
}
/**
* Dispose server-owned agents, adapter, and subscriptions to quiescence.
* The surrounding context remains running.
* @returns empty JSON-RPC result.
*/
shutdown(): Promise<Record<string, never>> {
this.shutdownTask ??= this.performShutdown()
return this.shutdownTask
}
private async performShutdown(): Promise<Record<string, never>> {
this.shuttingDown = true
const pendingCreations = [...this.sessionCreations.values()]
await Promise.allSettled(pendingCreations)
this.sessionCreations.clear()
const records = [...this.sessions.values()]
this.sessions.clear()
const failures: unknown[] = []
while (this.disposers.length > 0) {
try {
this.disposers.pop()?.()
} catch (error) {
failures.push(error)
}
}
const teardownResults = await Promise.allSettled([
...records.map(rec => Promise.resolve().then(() => rec.handle.dispose())),
...(this.llmFiber === undefined ? [] : [Promise.resolve().then(() => this.llmFiber?.dispose())]),
])
this.llmFiber = undefined
failures.push(...teardownResults
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map(result => result.reason as unknown))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'SDK server teardown failed')
return {}
}
/**
* Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a
* JSON-RPC error response) on an unknown method.
* @param method - the JSON-RPC method name.
* @param params - the raw params object from the wire.
* @returns the handler's result, to be serialized as the response.
*/
async handleRequest(method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
switch (method) {
case 'initialize':
return this.initialize(params as unknown as InitializeParams)
case 'session/prompt':
return this.prompt(params as unknown as SessionPromptParams)
case 'shutdown':
return this.shutdown()
default:
throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`)
}
}
private async getOrCreateSession(sessionId: string): Promise<SessionRecord> {
if (this.shuttingDown) throw new Error('SDK server is shutting down')
const existing = this.sessions.get(sessionId)
if (existing) return existing
const pending = this.sessionCreations.get(sessionId)
if (pending) return pending
const creation = this.createSession(sessionId)
this.sessionCreations.set(sessionId, creation)
void creation.then(
() => { this.sessionCreations.delete(sessionId) },
() => { this.sessionCreations.delete(sessionId) },
)
return creation
}
private async createSession(sessionId: string): Promise<SessionRecord> {
const handle = await this.ctx.agents.create({
sessionId: SessionId(sessionId),
meta: { cwd: this.cwd },
agentOptions: {
provider: this.provider,
model: this.model,
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
},
})
const rec: SessionRecord = { handle }
this.sessions.set(sessionId, rec)
return rec
}
private hasAdapterFor(provider: string): boolean {
return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false
}
}

View File

@@ -0,0 +1,123 @@
/**
* Built-artifact guard for the scope carrier shared by `dsh-subagent` and
* `dsh-jsonrpc`. The carrier registry is module-local, so both bundles must
* externalize `dsh-scope`; source-mode tests cannot expose an accidentally
* inlined second registry. This test runs the real `lib/index.js` bundles in a
* plain Node subprocess, disposes the child before settlement, and requires the
* SDK completion notification to retain the delegating parent.
*/
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const jsonrpcBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url))
const execFileAsync = promisify(execFile)
const builtRuntimeProbe = String.raw`
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const load = (path) => import(pathToFileURL(resolve(path)).href);
const [
{ Context },
agentCore,
{ default: SubagentService },
{ default: SessionPersistenceJsonl },
{ HarnessSdkServer },
{ SessionId },
] = await Promise.all([
load("vendor/cordis/lib/index.js"),
load("packages/examples/agent-spine-demo/lib/index.js"),
load("packages/subagent/subagent/lib/index.js"),
load("packages/session/session-persistence-jsonl/lib/index.js"),
load("packages/sdk/server/lib/index.js"),
load("packages/core/session/lib/index.js"),
]);
const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-"));
const ctx = new Context();
try {
await ctx.plugin(agentCore, { workspaceContext: false });
await ctx.plugin(SubagentService);
await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot });
await new Promise((ready) => setTimeout(ready, 50));
const notifications = [];
const server = new HarnessSdkServer(ctx, {
request() { return Promise.reject(new Error("unexpected host request")); },
notify(method, params) { notifications.push({ method, params }); },
});
const parent = await ctx.agents.create({
sessionId: SessionId("built-parent"),
meta: { cwd: storageRoot },
agentOptions: { model: "test" },
});
const child = await parent.agent.ctx.agents.create({
sessionId: SessionId("built-child"),
meta: { cwd: storageRoot, parentSession: SessionId("built-parent") },
agentOptions: { model: "test" },
});
const result = Promise.withResolvers();
const unregister = ctx.subagents.registerProvider({
name: "built-local",
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start() {
return Promise.resolve({
id: child.agent.id,
localAgent: child.agent,
result: result.promise,
dispose() { return Promise.resolve(); },
});
},
});
const run = await ctx.subagents.start("built-local", {
parent: parent.agent,
prompt: [],
signal: new AbortController().signal,
});
await child.dispose();
result.resolve({ output: [], stopReason: "completed" });
await run.result;
await Promise.resolve();
console.log(JSON.stringify(notifications.filter(({ method }) => method === "subagent.finished")));
await run.dispose();
unregister();
await parent.dispose();
await server.shutdown();
} finally {
await ctx.fiber.dispose();
await rm(storageRoot, { recursive: true, force: true });
}
`
describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', () => {
it('preserves parent-scoped completion after child disposal', async () => {
const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', builtRuntimeProbe], {
cwd: repoRoot,
timeout: 15_000,
})
expect(stderr).not.toContain('listener threw')
// A result without output omits lastAssistantMessage from the wire; it
// never sends `[]`.
expect(JSON.parse(stdout) as unknown).toEqual([{
method: 'subagent.finished',
params: {
provider: 'built-local',
agentId: 'built-child',
parentSessionId: 'built-parent',
childSessionId: 'built-child',
status: 'ok',
stopReason: 'completed',
},
}])
})
})

View File

@@ -0,0 +1,305 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { PassThrough, Writable } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as jsonrpc from '../src/index.ts'
/**
* Mount the real namespace plugin with in-memory stdio and exit hooks. Covers
* the full transport/server path, response-before-exit shutdown exactly once,
* and bare-fiber disposal without process exit.
*/
/** One ordered frame, write completion, or exit observation. */
type WireEvent =
| { kind: 'frame'; frame: Record<string, unknown> }
| { kind: 'write-complete'; ids: (string | number)[] }
| { kind: 'root-disposed' }
| { kind: 'exit'; code: number }
interface ApplyHarness {
ctx: Context
/** The plugin fiber used by the bare-dispose case. */
fiber: Awaited<ReturnType<Context['plugin']>>
/** Frames, write completions, and exits in observation order. */
events: WireEvent[]
outputErrors: Error[]
send(frame: Record<string, unknown>): void
sendRaw(text: string): void
frames(): Record<string, unknown>[]
exits(): number[]
waitForFrame(predicate: (frame: Record<string, unknown>) => boolean, description: string): Promise<Record<string, unknown>>
dispose(): Promise<void>
}
/** Poll asynchronous output for up to five seconds. */
async function waitFor<T>(get: () => T | undefined, description: string): Promise<T> {
const deadline = Date.now() + 5000
for (;;) {
const value = get()
if (value !== undefined) return value
if (Date.now() > deadline) throw new Error(`timed out waiting for ${description}`)
await new Promise(resolve => setTimeout(resolve, 5))
}
}
/** Drain asynchronous work before a negative assertion. */
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 25))
}
/** Mount the real plugin on a minimal harness with in-memory stdio and exit. */
async function mountPlugin(
storageDir: string,
options: { writeDelayMs?: number; failFlush?: boolean } = {},
): Promise<ApplyHarness> {
const ctx = new Context()
await ctx.plugin(agentCore, { workspaceContext: false })
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
await new Promise(resolve => setTimeout(resolve, 50))
const input = new PassThrough()
const events: WireEvent[] = []
const outputErrors: Error[] = []
let pendingOutput = ''
// Record frame admission separately from write completion so delayed output
// tests the flush barrier.
const output = new Writable({
write(chunk: Buffer, _encoding, callback) {
const ids: (string | number)[] = []
pendingOutput += chunk.toString('utf8')
for (;;) {
const newline = pendingOutput.indexOf('\n')
if (newline < 0) break
const line = pendingOutput.slice(0, newline).trim()
pendingOutput = pendingOutput.slice(newline + 1)
if (line) {
const frame = JSON.parse(line) as Record<string, unknown>
events.push({ kind: 'frame', frame })
if (typeof frame.id === 'string' || typeof frame.id === 'number') ids.push(frame.id)
}
}
const complete = (): void => {
if (options.failFlush === true && chunk.length === 0) {
callback(new Error('flush callback failed'))
return
}
events.push({ kind: 'write-complete', ids })
callback()
}
if ((options.writeDelayMs ?? 0) > 0) setTimeout(complete, options.writeDelayMs)
else complete()
},
})
output.on('error', (error: Error) => { outputErrors.push(error) })
const exit = (code: number): void => { events.push({ kind: 'exit', code }) }
ctx.effect(() => () => { events.push({ kind: 'root-disposed' }) }, 'jsonrpc test root-disposal witness')
const fiber = await ctx.plugin(jsonrpc, { input, output, exit })
const frames = (): Record<string, unknown>[] =>
events.flatMap(event => event.kind === 'frame' ? [event.frame] : [])
return {
ctx,
fiber,
events,
outputErrors,
send: (frame) => { input.write(`${JSON.stringify(frame)}\n`) },
sendRaw: (text) => { input.write(text) },
frames,
exits: () => events.flatMap(event => event.kind === 'exit' ? [event.code] : []),
waitForFrame: (predicate, description) => waitFor(() => frames().find(predicate), description),
dispose: async () => { await ctx.fiber.dispose() },
}
}
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
vi.unstubAllEnvs()
})
/** Keyless SSE endpoint for completing a prompt turn. */
async function mockCompletionServer(): Promise<{ url: string; requests: unknown[] }> {
const requests: unknown[] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
requests.push(JSON.parse(body))
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n')
response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
response.write('data: [DONE]\n\n')
response.end()
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return { url: `http://127.0.0.1:${address.port}`, requests }
}
describe('dsh-jsonrpc plugin apply', () => {
it('serves initialize over the injected stdio pair', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-init-'))
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
const harness = await mountPlugin(storageDir)
try {
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'apply-model' } })
const response = await harness.waitForFrame(frame => frame.id === 'init-1', 'initialize response')
expect(response).toEqual({
jsonrpc: '2.0',
id: 'init-1',
result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } },
})
expect(harness.exits()).toEqual([])
} finally {
await harness.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('drives a session/prompt turn end-to-end and forwards session notifications as output frames', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-prompt-'))
const llmServer = await mockCompletionServer()
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
const harness = await mountPlugin(storageDir)
try {
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'dsagent-model' } })
await harness.waitForFrame(frame => frame.id === 1, 'initialize response')
harness.send({
jsonrpc: '2.0',
id: 2,
method: 'session/prompt',
params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'fix it' }] },
})
const response = await harness.waitForFrame(frame => frame.id === 2, 'prompt response')
expect((response.result as { messageId?: unknown }).messageId).toBeTypeOf('string')
await harness.waitForFrame(
frame => frame.method === 'session.status'
&& (frame.params as { status?: string } | undefined)?.status === 'idle',
'idle session status',
)
expect(llmServer.requests).toHaveLength(1)
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
expect(body.model).toBe('dsagent-model')
expect(body.messages.at(-1)?.role).toBe('user')
// Notifications use the same transport and arrive as id-less frames.
const notifications = harness.frames().filter(frame => frame.id === undefined)
expect(notifications.some(frame => frame.method === 'session.event')).toBe(true)
expect(notifications.findLast(frame => frame.method === 'session.status')).toMatchObject({
jsonrpc: '2.0',
params: { sessionId: 'main', status: 'idle' },
})
} finally {
await harness.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-'))
const harness = await mountPlugin(storageDir, { writeDelayMs: 10 })
try {
// One chunk makes the two deferred exit callbacks race.
const first = { jsonrpc: '2.0', id: 'sd-1', method: 'shutdown' }
const second = { jsonrpc: '2.0', id: 'sd-2', method: 'shutdown' }
harness.sendRaw(`${JSON.stringify(first)}\n${JSON.stringify(second)}\n`)
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit recorder call')
expect(harness.exits()).toEqual([0])
// Both response writes and the flush barrier complete before exit.
const exitIndex = harness.events.findIndex(event => event.kind === 'exit')
const firstResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-1')
const secondResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-2')
const firstComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-1'))
const secondComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-2'))
const flushComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.length === 0)
const rootDisposed = harness.events.findIndex(event => event.kind === 'root-disposed')
expect(firstResponse).toBeGreaterThanOrEqual(0)
expect(secondResponse).toBeGreaterThanOrEqual(0)
expect(firstComplete).toBeGreaterThan(firstResponse)
expect(secondComplete).toBeGreaterThan(secondResponse)
expect(flushComplete).toBeGreaterThan(firstComplete)
expect(flushComplete).toBeGreaterThan(secondComplete)
expect(rootDisposed).toBeGreaterThan(flushComplete)
expect(exitIndex).toBeGreaterThan(rootDisposed)
await settle()
expect(harness.exits()).toEqual([0])
expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1)
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
} finally {
await harness.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('still disposes and exits once when the flush callback fails', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-flush-failure-'))
const harness = await mountPlugin(storageDir, { failFlush: true })
try {
harness.send({ jsonrpc: '2.0', id: 'sd-fail', method: 'shutdown' })
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit after flush failure')
await settle()
expect(harness.exits()).toEqual([0])
expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1)
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
} finally {
await harness.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('stops serving on a bare fiber dispose (HMR-style unload) without calling exit', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-dispose-'))
const harness = await mountPlugin(storageDir)
try {
// Prove the handler-rejection path is live before disposal.
harness.send({ jsonrpc: '2.0', id: 'probe-1', method: 'nope/unknown' })
const error = await harness.waitForFrame(frame => frame.id === 'probe-1', 'error response for unknown method')
expect(error.error).toMatchObject({
code: -32603,
message: 'unknown DeepSeek Harness SDK runtime method: nope/unknown',
})
await harness.fiber.dispose()
expect(harness.events.some(event => event.kind === 'root-disposed')).toBe(false)
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
expect(harness.exits()).toEqual([])
} finally {
await harness.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
})

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import * as jsonrpc from '../src/index.ts'
/**
* Run the real namespace export through `Loader.unwrapExports`; a stray
* default would discard `name`, `inject`, `Config`, and `apply`.
*/
describe('dsh-jsonrpc plugin export shape', () => {
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
expect('default' in jsonrpc).toBe(false)
expect(typeof jsonrpc.apply).toBe('function')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(jsonrpc) as Record<string, unknown>
expect(unwrapped).toBe(jsonrpc)
expect(unwrapped.name).toBe('jsonrpc')
expect(unwrapped.inject).toEqual(['agents'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
})

View File

@@ -0,0 +1,968 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SubagentService, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol'
import { HarnessSdkServer } from '../src/index.ts'
class FakeTransport implements JsonRpcTransportPeer {
notifications: { method: string; params?: Record<string, unknown> }[] = []
async request(method: string, params: object): Promise<unknown> {
throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`)
}
notify(method: string, params?: object): void {
this.notifications.push(params === undefined ? { method } : { method, params: params as Record<string, unknown> })
}
}
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
vi.unstubAllEnvs()
})
async function mockCompletionServer(): Promise<{ url: string; requests: unknown[]; headers: IncomingMessage['headers'][] }> {
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
requests.push(JSON.parse(body))
headers.push(request.headers)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n')
response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
response.write('data: [DONE]\n\n')
response.end()
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return { url: `http://127.0.0.1:${address.port}`, requests, headers }
}
async function makeHarness(storageDir: string) {
const ctx = new Context()
await ctx.plugin(agentCore, { workspaceContext: false })
await ctx.plugin(SubagentService)
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
await new Promise(resolve => setTimeout(resolve, 50))
return ctx
}
/** Drive the owning service so test lifecycle events carry the real parent scope. */
async function settleSubagent(
ctx: Context,
parent: Agent,
info: Omit<SubagentRunEndInfo, 'runId' | 'local'> & { localAgent: Agent | undefined },
beforeSettle?: () => Promise<void>,
): Promise<void> {
const result = Promise.withResolvers<SubagentResult>()
const disposeProvider = ctx.subagents.registerProvider({
name: info.provider,
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
async start() {
return {
id: info.id,
localAgent: info.localAgent,
result: result.promise,
dispose: () => Promise.resolve(),
}
},
})
try {
const run = await ctx.subagents.start(info.provider, {
parent,
prompt: [],
signal: new AbortController().signal,
})
await beforeSettle?.()
if (info.lastAssistantMessage === undefined) {
result.reject(new Error('synthetic infrastructure failure'))
} else {
result.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason })
}
await run.result.then(() => undefined, () => undefined)
await run.dispose()
} finally {
disposeProvider()
}
}
describe('HarnessSdkServer', () => {
it('creates a harness agent and calls the configured OpenAI-compatible endpoint', { timeout: 15_000 }, async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-'))
const llmServer = await mockCompletionServer()
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
const ctx = await makeHarness(storageDir)
try {
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const init = await server.handleRequest('initialize', {
cwd: storageDir,
provider: 'deepseek-official',
model: 'dsagent-model',
maxTokens: 321,
}) as { serverInfo: { name: string } }
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
const receipt = await server.handleRequest('session/prompt', {
sessionId: 'main',
contentBlocks: [{ type: 'text', text: 'fix it' }],
})
expect((receipt as { messageId?: unknown }).messageId).toBeTypeOf('string')
await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) })
const body = llmServer.requests[0] as { model: string; messages: { role: string }[]; max_tokens?: number }
expect(body.model).toBe('dsagent-model')
expect(body.max_tokens).toBe(321)
expect(body.messages[0]?.role).toBe('system')
expect(body.messages.at(-1)?.role).toBe('user')
expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key')
expect(transport.notifications.some(n => n.method === 'session.event')).toBe(true)
await vi.waitFor(() => {
expect(transport.notifications.findLast(n => n.method === 'session.status')).toEqual({
method: 'session.status',
params: { sessionId: 'main', status: 'idle' },
})
})
await server.handleRequest('session/prompt', {
sessionId: 'main',
contentBlocks: [{ type: 'text', text: 'again' }],
})
await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) })
const orphanHandle = await ctx.agents.create({
sessionId: SessionId('orphan-session'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek-official', model: 'dsagent-model' },
})
orphanHandle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } }))
await orphanHandle.agent.whenIdle()
await orphanHandle.dispose()
expect(llmServer.requests).toHaveLength(3)
await server.handleRequest('shutdown', undefined)
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('queues overlapping prompts for one session without blocking other sessions', async () => {
const mainFollowup = vi.fn<Agent['followup']>()
const mainAgent = ({
id: SessionId('main'),
followup: mainFollowup,
} satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent
const otherFollowup = vi.fn<Agent['followup']>()
const otherAgent = ({
id: SessionId('other'),
followup: otherFollowup,
} satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
const create = vi.fn(async (options: { sessionId: SessionId }) =>
String(options.sessionId) === 'main' ? mainHandle : otherHandle)
const liveAgents = new Map<string, Agent>([['main', mainAgent], ['other', otherAgent]])
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create, get: (id: SessionId) => liveAgents.get(String(id)) },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport())
const prompt = (sessionId: string, text: string) => server.prompt({
sessionId,
contentBlocks: [{ type: 'text', text }],
})
expect((await prompt('main', 'first')).messageId).toBeTypeOf('string')
expect((await prompt('main', 'overlap')).messageId).toBeTypeOf('string')
expect((await prompt('other', 'independent')).messageId).toBeTypeOf('string')
expect(mainFollowup).toHaveBeenCalledTimes(2)
expect(otherFollowup).toHaveBeenCalledOnce()
await server.shutdown()
expect(mainHandle.dispose).toHaveBeenCalledOnce()
expect(otherHandle.dispose).toHaveBeenCalledOnce()
})
it('rejects a prompt for a session whose agent was disposed outside the server', async () => {
const followup = vi.fn<Agent['followup']>()
const agent = ({
id: SessionId('zombie'),
followup,
whenIdle: vi.fn(() => Promise.resolve()),
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
const handle = { agent, dispose: vi.fn(() => Promise.resolve()) }
// The registry drops the agent after creation, modelling an agent-loop-only
// reload that leaves the server's SessionRecord pointing at a detached agent.
let live = true
const ctx = {
on: vi.fn(() => () => undefined),
agents: {
create: vi.fn(async () => handle),
get: (id: SessionId) => (live && String(id) === 'zombie' ? agent : undefined),
},
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport())
const prompt = (text: string) => server.prompt({
sessionId: 'zombie',
contentBlocks: [{ type: 'text', text }],
})
expect((await prompt('while live')).messageId).toBeTypeOf('string')
live = false
await expect(prompt('after detach')).rejects.toThrow('session agent was disposed outside the server: zombie')
// The detached agent was never driven by the rejected prompt.
expect(followup).toHaveBeenCalledOnce()
await server.shutdown()
})
it('forwards whole-agent status without attributing a turn outcome', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const session = ctx.sessions.create(SessionId('message-outcome'))
const agent = ({
id: SessionId('message-outcome'),
session,
} satisfies Pick<Agent, 'id' | 'session'>) as Agent
ctx.emit('agent/status', { agent, status: 'running' })
ctx.emit('agent/status', { agent, status: 'idle' })
expect(transport.notifications.filter(notification => notification.method === 'session.status'))
.toEqual([
{ method: 'session.status', params: { sessionId: 'message-outcome', status: 'running' } },
{ method: 'session.status', params: { sessionId: 'message-outcome', status: 'idle' } },
])
await server.shutdown()
await ctx.fiber.dispose()
})
it('notifies the host when a child session is created with parent lineage', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-'))
const ctx = await makeHarness(storageDir)
try {
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
ctx.sessions.create(SessionId('root-session'), {
meta: { cwd: storageDir },
})
ctx.sessions.create(SessionId('child-session'), {
meta: { cwd: storageDir, parentSession: SessionId('main') },
})
expect(transport.notifications).toContainEqual({
method: 'subagent.started',
params: {
parentSessionId: 'main',
childSessionId: 'child-session',
},
})
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('creates an SDK session without an optional system prompt', { timeout: 15_000 }, async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-no-system-'))
const llmServer = await mockCompletionServer()
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'plain-model' })
await server.prompt({
sessionId: 'plain',
contentBlocks: [{ type: 'text', text: 'hello' }],
})
await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) })
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('notifies the host when a subagent run settles', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-end-'))
const ctx = await makeHarness(storageDir)
try {
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const parentHandle = await ctx.agents.create({
sessionId: SessionId('main'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
// A custom in-process provider may own its child at the provider/root
// scope while preserving durable parent lineage.
const handle = await ctx.agents.create({
sessionId: SessionId('child-session'),
meta: { cwd: storageDir, parentSession: SessionId('main') },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
expect(ctx.agents.roots()).toContain(handle.agent)
const parentlessHandle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('parentless-child-session'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek-official' },
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'spawn',
id: SessionId('child-session'),
localAgent: handle.agent,
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
}, () => handle.dispose())
await settleSubagent(ctx, parentHandle.agent, {
provider: 'spawn',
id: SessionId('parentless-child-session'),
localAgent: parentlessHandle.agent,
stopReason: 'error',
}, () => parentlessHandle.dispose())
expect(transport.notifications).toContainEqual({
method: 'subagent.finished',
params: {
provider: 'spawn',
agentId: 'child-session',
parentSessionId: 'main',
childSessionId: 'child-session',
status: 'ok',
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
},
})
expect(transport.notifications).toContainEqual({
method: 'subagent.finished',
params: {
provider: 'spawn',
agentId: 'parentless-child-session',
parentSessionId: 'main',
childSessionId: 'parentless-child-session',
status: 'error',
stopReason: 'error',
},
})
await parentHandle.dispose()
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('ignores a remote run id that collides with a local child of the same parent', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-'))
const ctx = await makeHarness(storageDir)
try {
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const parentHandle = await ctx.agents.create({
sessionId: SessionId('collision-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek-official' },
})
const collidingChild = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('remote-run-id'),
meta: { cwd: storageDir, parentSession: SessionId('collision-parent') },
agentOptions: { model: 'deepseek-official' },
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'remote',
id: SessionId('remote-run-id'),
localAgent: undefined,
stopReason: 'completed',
lastAssistantMessage: [],
})
expect(transport.notifications.some(notification =>
notification.method === 'subagent.finished'
&& notification.params?.agentId === 'remote-run-id',
)).toBe(false)
await collidingChild.dispose()
await parentHandle.dispose()
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('retains locality across continuation runs on one live child', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-continuation-'))
const ctx = await makeHarness(storageDir)
try {
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const parentHandle = await ctx.agents.create({
sessionId: SessionId('continuation-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek-official' },
})
const childHandle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('continuation-child'),
meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') },
agentOptions: { model: 'deepseek-official' },
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'continuation',
id: SessionId('continuation-child'),
localAgent: childHandle.agent,
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'first' }],
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'continuation',
id: SessionId('continuation-child'),
localAgent: childHandle.agent,
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'second' }],
}, () => childHandle.dispose())
expect(transport.notifications.filter(notification =>
notification.method === 'subagent.finished'
&& notification.params?.childSessionId === 'continuation-child',
)).toHaveLength(2)
await parentHandle.dispose()
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('correlates reused local ids by parent scope when runs settle out of order', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-reuse-'))
const ctx = await makeHarness(storageDir)
try {
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const oldParent = await ctx.agents.create({
sessionId: SessionId('old-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek-official' },
})
const oldChild = await oldParent.agent.ctx.agents.create({
sessionId: SessionId('reused-child'),
meta: { cwd: storageDir, parentSession: SessionId('old-parent') },
agentOptions: { model: 'deepseek-official' },
})
const first = Promise.withResolvers<SubagentResult>()
const sameLifetime = Promise.withResolvers<SubagentResult>()
const replacement = Promise.withResolvers<SubagentResult>()
const results = [first.promise, sameLifetime.promise, replacement.promise]
let starts = 0
let currentLocalAgent = oldChild.agent
const disposeProvider = ctx.subagents.registerProvider({
name: 'reused',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start() {
const result = results[starts]
starts += 1
if (result === undefined) throw new Error('unexpected fourth reused-id run')
return Promise.resolve({ id: SessionId('reused-child'), localAgent: currentLocalAgent, result, dispose: () => Promise.resolve() })
},
})
const firstRun = await ctx.subagents.start('reused', {
parent: oldParent.agent,
prompt: [],
signal: new AbortController().signal,
})
const sameLifetimeRun = await ctx.subagents.start('reused', {
parent: oldParent.agent,
prompt: [],
signal: new AbortController().signal,
})
sameLifetime.resolve({ output: [{ type: 'text', text: 'same lifetime' }], stopReason: 'completed' })
await sameLifetimeRun.result
await oldChild.dispose()
const newParent = await ctx.agents.create({
sessionId: SessionId('new-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek-official' },
})
const newChild = await newParent.agent.ctx.agents.create({
sessionId: SessionId('reused-child'),
meta: { cwd: storageDir, parentSession: SessionId('new-parent') },
agentOptions: { model: 'deepseek-official' },
})
currentLocalAgent = newChild.agent
const secondRun = await ctx.subagents.start('reused', {
parent: newParent.agent,
prompt: [],
signal: new AbortController().signal,
})
replacement.resolve({ output: [{ type: 'text', text: 'new lifetime' }], stopReason: 'completed' })
await secondRun.result
first.resolve({ output: [{ type: 'text', text: 'old lifetime' }], stopReason: 'completed' })
await firstRun.result
await Promise.resolve()
const finished = transport.notifications.filter(notification =>
notification.method === 'subagent.finished'
&& notification.params?.childSessionId === 'reused-child',
)
expect(finished.map(notification => notification.params?.lastAssistantMessage)).toEqual([
[{ type: 'text', text: 'same lifetime' }],
[{ type: 'text', text: 'new lifetime' }],
[{ type: 'text', text: 'old lifetime' }],
])
expect(finished.map(notification => notification.params?.parentSessionId)).toEqual([
'old-parent',
'new-parent',
'old-parent',
])
await firstRun.dispose()
await sameLifetimeRun.dispose()
await secondRun.dispose()
disposeProvider()
await newChild.dispose()
await oldParent.dispose()
await newParent.dispose()
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('keeps locality bound to the accepted run across provider re-registration', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-provider-reuse-'))
const ctx = await makeHarness(storageDir)
try {
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const parent = await ctx.agents.create({
sessionId: SessionId('provider-reuse-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek-official' },
})
const child = await parent.agent.ctx.agents.create({
sessionId: SessionId('provider-reuse-child'),
meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') },
agentOptions: { model: 'deepseek-official' },
})
const localResult = Promise.withResolvers<SubagentResult>()
const remoteResult = Promise.withResolvers<SubagentResult>()
const unregisterLocal = ctx.subagents.registerProvider({
name: 'reused-provider',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => Promise.resolve({
id: SessionId('provider-reuse-child'),
localAgent: child.agent,
result: localResult.promise,
dispose: () => Promise.resolve(),
}),
})
const localRun = await ctx.subagents.start('reused-provider', {
parent: parent.agent,
prompt: [],
signal: new AbortController().signal,
})
unregisterLocal()
const unregisterRemote = ctx.subagents.registerProvider({
name: 'reused-provider',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => Promise.resolve({
id: SessionId('provider-reuse-child'),
localAgent: undefined,
result: remoteResult.promise,
dispose: () => Promise.resolve(),
}),
})
const remoteRun = await ctx.subagents.start('reused-provider', {
parent: parent.agent,
prompt: [],
signal: new AbortController().signal,
})
remoteResult.resolve({ output: [{ type: 'text', text: 'remote' }], stopReason: 'completed' })
await remoteRun.result
await Promise.resolve()
expect(transport.notifications.some(notification =>
notification.method === 'subagent.finished'
&& notification.params?.lastAssistantMessage !== undefined,
)).toBe(false)
await child.dispose()
localResult.resolve({ output: [{ type: 'text', text: 'local' }], stopReason: 'completed' })
await localRun.result
await Promise.resolve()
expect(transport.notifications.filter(notification =>
notification.method === 'subagent.finished'
&& notification.params?.childSessionId === 'provider-reuse-child',
)).toEqual([{
method: 'subagent.finished',
params: {
provider: 'reused-provider',
agentId: 'provider-reuse-child',
parentSessionId: 'provider-reuse-parent',
childSessionId: 'provider-reuse-child',
status: 'ok',
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'local' }],
},
}])
await localRun.dispose()
await remoteRun.dispose()
unregisterRemote()
await parent.dispose()
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('uses the recorded local flag when start was missed and ignores remote runs', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-'))
const ctx = await makeHarness(storageDir)
let parentHandle: AgentHandle | undefined
let handle: AgentHandle | undefined
let failedHandle: AgentHandle | undefined
try {
parentHandle = await ctx.agents.create({
sessionId: SessionId('fallback-parent'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
handle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('fallback-child-session'),
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
const fallbackChild = handle.agent
failedHandle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('failed-child-session'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
const missedStartResult = Promise.withResolvers<SubagentResult>()
const disposeMissedStartProvider = ctx.subagents.registerProvider({
name: 'fork',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: true,
start: () => Promise.resolve({
id: SessionId('fallback-child-session'),
localAgent: fallbackChild,
result: missedStartResult.promise,
dispose: () => Promise.resolve(),
}),
})
// Start before the server subscribes. The terminal payload still carries
// this run's exact local child without reconstructing it from ids.
const missedStartRun = await ctx.subagents.start('fork', {
parent: parentHandle.agent,
prompt: [],
signal: new AbortController().signal,
})
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport, { maxTokensAsSuccess: true })
missedStartResult.resolve({ output: [], stopReason: 'max-tokens' })
await missedStartRun.result
await Promise.resolve()
await missedStartRun.dispose()
disposeMissedStartProvider()
// The server also missed this agent's creation but sees the exact child
// on the run lifecycle payload.
await settleSubagent(ctx, parentHandle.agent, {
provider: 'fork-live-fallback',
id: SessionId('fallback-child-session'),
localAgent: fallbackChild,
stopReason: 'completed',
lastAssistantMessage: [],
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'fork',
id: SessionId('failed-child-session'),
localAgent: failedHandle.agent,
stopReason: 'error',
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'fork',
id: SessionId('missing-child-agent'),
localAgent: undefined,
stopReason: 'error',
})
// A result without output omits lastAssistantMessage from the wire; it
// never sends `[]`.
expect(transport.notifications).toContainEqual({
method: 'subagent.finished',
params: {
provider: 'fork',
agentId: 'fallback-child-session',
parentSessionId: 'fallback-parent',
childSessionId: 'fallback-child-session',
status: 'ok',
stopReason: 'max-tokens',
},
})
expect(transport.notifications).toContainEqual({
method: 'subagent.finished',
params: {
provider: 'fork',
agentId: 'failed-child-session',
parentSessionId: 'fallback-parent',
childSessionId: 'failed-child-session',
status: 'error',
stopReason: 'error',
},
})
expect(transport.notifications.some(n =>
n.method === 'subagent.finished'
&& n.params?.agentId === 'missing-child-agent',
)).toBe(false)
await server.shutdown()
} finally {
await handle?.dispose()
await failedHandle?.dispose()
await parentHandle?.dispose()
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('does not re-register an LLM adapter whose provider already has an owner', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-'))
const ctx = await makeHarness(storageDir)
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
await ctx.plugin(LlmDeepSeek)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
const inspect = server as unknown as { hasAdapterFor(provider: string): boolean }
expect(inspect.hasAdapterFor('deepseek-official')).toBe(true)
expect(inspect.hasAdapterFor('missing-provider')).toBe(false)
await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'preinstalled-model' })
expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek-official')).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('rejects a missing non-DeepSeek provider when an LLM service already exists', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-new-llm-'))
const ctx = await makeHarness(storageDir)
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
await ctx.plugin(LlmDeepSeek)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' }))
.rejects.toThrow('no adapter registered for provider "private"')
expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid initialize maxTokens %s at the wire boundary',
async (maxTokens) => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-invalid-max-tokens-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.initialize({
cwd: storageDir,
provider: 'deepseek-official',
model: 'model',
maxTokens,
})).rejects.toThrow('initialize maxTokens must be a positive safe integer')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
},
)
it('reports no adapter when the LLM service is absent', async () => {
const ctx = new Context()
try {
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
hasAdapterFor(model: string): boolean
shutdown(): Promise<Record<string, never>>
}
expect(server.hasAdapterFor('missing-model')).toBe(false)
await server.shutdown()
} finally {
await ctx.fiber.dispose()
}
})
it('rejects unknown JSON-RPC runtime methods', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-unknown-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.handleRequest('does/not/exist', {}))
.rejects
.toThrow('unknown DeepSeek Harness SDK runtime method: does/not/exist')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('coalesces concurrent session creation and retries a failed creation', async () => {
let resolveShared: ((handle: AgentHandle) => void) | undefined
const sharedCreation = new Promise<AgentHandle>((resolve) => { resolveShared = resolve })
const sharedHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) }
const retryHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) }
const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
.mockReturnValueOnce(sharedCreation)
.mockRejectedValueOnce(new Error('creation failed'))
.mockResolvedValueOnce(retryHandle)
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create, get: () => undefined },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
getOrCreateSession(sessionId: string): Promise<{ handle: AgentHandle }>
shutdown(): Promise<Record<string, never>>
}
const first = server.getOrCreateSession('shared')
const second = server.getOrCreateSession('shared')
expect(create).toHaveBeenCalledTimes(1)
resolveShared?.(sharedHandle)
const [firstRecord, secondRecord] = await Promise.all([first, second])
expect(firstRecord).toBe(secondRecord)
await expect(server.getOrCreateSession('retry')).rejects.toThrow('creation failed')
await expect(server.getOrCreateSession('retry')).resolves.toMatchObject({ handle: retryHandle })
expect(create).toHaveBeenCalledTimes(3)
await server.shutdown()
expect(sharedHandle.dispose).toHaveBeenCalledOnce()
expect(retryHandle.dispose).toHaveBeenCalledOnce()
await expect(server.getOrCreateSession('after-shutdown')).rejects.toThrow('SDK server is shutting down')
})
it('resolves a relative cwd before creating the session', async () => {
const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
.mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() })
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create, get: () => undefined },
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
initialize(params: { cwd: string; provider: string; model: string; maxTokens?: number }): Promise<unknown>
getOrCreateSession(sessionId: string): Promise<unknown>
shutdown(): Promise<Record<string, never>>
}
await server.initialize({ cwd: '.', provider: 'mock', model: 'model', maxTokens: 123 })
await server.getOrCreateSession('relative')
expect(create).toHaveBeenCalledWith(expect.objectContaining({
meta: { cwd: process.cwd() },
agentOptions: { provider: 'mock', model: 'model', maxTokens: 123 },
}))
await server.shutdown()
})
it('settles every teardown and aggregates multiple failures', async () => {
const firstDispose = vi.fn(() => { throw new Error('first teardown failed') })
const secondDispose = vi.fn(() => Promise.reject(new Error('second teardown failed')))
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create: vi.fn(), get: () => undefined },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
shutdown(): Promise<Record<string, never>>
}
server.sessions.set('first', { handle: { agent: {} as Agent, dispose: firstDispose }, lastTurnEnd: undefined, activePrompt: false })
server.sessions.set('second', { handle: { agent: {} as Agent, dispose: secondDispose }, lastTurnEnd: undefined, activePrompt: false })
await expect(server.shutdown()).rejects.toThrow('SDK server teardown failed')
expect(firstDispose).toHaveBeenCalledOnce()
expect(secondDispose).toHaveBeenCalledOnce()
})
it('continues teardown after a subscription disposer fails', async () => {
let subscription = 0
const listenerFailure = new Error('listener teardown failed')
const on = vi.fn(() => {
subscription += 1
return subscription === 1 ? () => { throw listenerFailure } : () => undefined
})
const ctx = {
on,
agents: { create: vi.fn(), get: () => undefined },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.shutdown()).rejects.toBe(listenerFailure)
expect(on).toHaveBeenCalledTimes(4)
})
})

View File

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