refactor(packages): dissolve ui/ and rename sdk/ to scaffold/

git mv per the regrouping RFC: the five human-collaboration seams and
tui join packages/interaction/, app-boot becomes packages/boot/, and
jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half
beside client/protocol/create-sdk/helper/scripts/telemetry, whose
folders drop the legacy sdk- prefix. Three new group README triplets
replace the ui/ and sdk/ ones; tsconfig references/paths/globs,
knip keys, vitest globs, gate scripts, catalogs, docs, and the
lockfile follow. Adds the four settled FIXME rename markers
(dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts).

The scaffold folders diverge from their npm names until those renames
land, so tsconfig.base.json maps the three affected names explicitly
beside the group wildcard. Also repairs two pre-existing stale-path
classes the strengthened sweep surfaced: docs/web-styling.md's retired
web-ui host package and type-model spec fixture-literal joins.

app-boot's three Loader-composition specs time out at the default 5s
under full-suite parallel load on this filesystem (pre-existing;
pass isolated with --testTimeout=30000); interaction/scaffold/boot
suites otherwise green (687 passed).
This commit is contained in:
Tianyi Cui
2026-07-30 03:13:49 +08:00
parent 7e445c3a67
commit 3fc35c91ff
351 changed files with 368 additions and 311 deletions

View File

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

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`](../../scaffold/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. `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`](../../scaffold/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](../../scaffold/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`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-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`。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式契约的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。
## 模型体验
无,因为此包定义面向客户端的协议格式;模型可见接口属于组合在对外服务入口 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 后方的运行时插件。
#### KV Cache 影响
无;此包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **无协议版本协商**——握手只携带 `serverInfo.version`(`0.0.1`,客户端不校验);处于预发布阶段,无兼容承诺。
- **无取消与会话关闭方法**——客户端放弃轮次的方式是关闭运行时进程;见 [`dsh-jsonrpc` README](../../ui/jsonrpc/README.md)。
- **server→client 请求是未使用的功能**——传输层支持,但服务器从不发送;Python SDK 的应答接口为未来审批流程预留。

View File

@@ -0,0 +1,40 @@
{
"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",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

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 '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 final assistant message, when it produced one. */
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"
}
]
}