feat(sdk): TypeScript SDK client + shared wire protocol + SDK subagent backend
- @deepseek-ai/dsh-sdk-protocol: extract the line transport from dsh-jsonrpc and name the request/result/notification wire types both ends share; error responses preserve wire code/data via JsonRpcResponseError. - @deepseek-ai/dsh-sdk-client: TypeScript twin of the Python SDK — spawns the dsh-jsonrpc-agent runtime as a subprocess, drives stdio JSON-RPC turns (DeepSeekHarness high-level API + HarnessClient protocol client), scopes notifications to session trees client-side, and reaps the child through the shared subprocess dispose ladder. - @deepseek-ai/dsh-subagent-sdk: out-of-process subagent backend driving a child harness runtime through the TS SDK; shares cwd resolution with subagent-acp via new dsh-subagent-subprocess cwd helpers. - Keyless unit suites drive real subprocesses (scripted fake runtime peer); 100% per-file coverage on all touched packages.
This commit is contained in:
45
packages/sdk/sdk-client/package.json
Normal file
45
packages/sdk/sdk-client/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sdk-client",
|
||||
"description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
193
packages/sdk/sdk-client/src/api.ts
Normal file
193
packages/sdk/sdk-client/src/api.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* High-level turns API over {@link HarnessClient}: `DeepSeekHarness` owns one
|
||||
* runtime subprocess across many sessions; `HarnessSession.run` sends a
|
||||
* prompt and settles with the final response once `session.finished` arrives.
|
||||
* Mirrors the Python SDK's `DeepSeekHarness`/`Session` pair.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-client/api
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { HarnessClient } from './client.ts'
|
||||
import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, TurnResult } 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 {
|
||||
/** The underlying JSON-RPC client (exposed for low-level access). */
|
||||
readonly client: HarnessClient
|
||||
private readonly cwd: string
|
||||
private readonly provider: string
|
||||
private readonly model: string
|
||||
private initialized: Promise<void> | undefined
|
||||
|
||||
/** @param options - runtime launch spec plus the session route (cwd/provider/model). */
|
||||
constructor(options: DeepSeekHarnessOptions) {
|
||||
this.client = new HarnessClient(options.launch)
|
||||
this.cwd = options.cwd ?? options.launch.cwd ?? process.cwd()
|
||||
this.provider = options.provider ?? 'deepseek'
|
||||
this.model = options.model ?? 'deepseek-v4-flash'
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the subprocess and perform the `initialize` handshake once.
|
||||
* @returns settlement of the (memoized) handshake.
|
||||
*/
|
||||
start(): Promise<void> {
|
||||
this.initialized ??= (async () => {
|
||||
try {
|
||||
this.client.start()
|
||||
await this.client.initialize({ cwd: this.cwd, provider: this.provider, model: this.model })
|
||||
} catch (error) {
|
||||
this.initialized = undefined
|
||||
await this.client.close()
|
||||
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 settled turn result.
|
||||
*/
|
||||
run(input: string | ContentBlock[], options?: RunOptions): Promise<TurnResult> {
|
||||
return this.session(options?.sessionId).run(input, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down and reap the runtime subprocess. Idempotent.
|
||||
* @returns settlement of the complete teardown.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
return this.client.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 the turn loop that pairs a
|
||||
* `session/prompt` with its `session.finished`.
|
||||
*/
|
||||
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) {}
|
||||
|
||||
/**
|
||||
* Run one prompt turn to settlement.
|
||||
* @param input - prompt text, or content blocks sent verbatim.
|
||||
* @param options - optional per-notification observer.
|
||||
* @returns the settled turn result; rejects on transport loss, timeout, or
|
||||
* a protocol error — never on a model-level failure (that is
|
||||
* `status: 'error'` in the result).
|
||||
*/
|
||||
async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<TurnResult> {
|
||||
await this.harness.start()
|
||||
const client = this.harness.client
|
||||
const contentBlocks = normalizeInput(input)
|
||||
const events: SessionEvent[] = []
|
||||
const notifications: HarnessNotification[] = []
|
||||
let status: TurnResult['status'] = 'error'
|
||||
let reason: TurnEndReason | undefined
|
||||
let finished = false
|
||||
|
||||
const subscription = client.subscribeSessionTree(this.id)
|
||||
const collect = (notification: HarnessNotification): void => {
|
||||
notifications.push(notification)
|
||||
options?.onNotification?.(notification)
|
||||
if (notification.method === 'session.event' && notification.params.sessionId === this.id) {
|
||||
events.push(notification.params.event as SessionEvent)
|
||||
}
|
||||
if (notification.method === 'session.finished' && notification.params.sessionId === this.id) {
|
||||
status = notification.params.status === 'ok' ? 'ok' : 'error'
|
||||
reason = notification.params.reason as TurnEndReason | undefined
|
||||
finished = true
|
||||
}
|
||||
}
|
||||
const accepted = client.prompt(this.id, contentBlocks)
|
||||
// Drain concurrently so observers see progress while the prompt request
|
||||
// is still pending (its response arrives only after settlement).
|
||||
const drain = (async () => {
|
||||
while (!finished) collect(await subscription.next())
|
||||
})()
|
||||
try {
|
||||
await Promise.all([accepted, drain])
|
||||
} finally {
|
||||
// On a prompt rejection the drain is still parked on next(); closing the
|
||||
// subscription settles it, and the swallow keeps that secondary
|
||||
// TransportClosedError from surfacing as an unhandled rejection.
|
||||
subscription.close()
|
||||
await drain.catch(() => {})
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: this.id,
|
||||
status,
|
||||
reason,
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the concatenated text of the last assistant message.
|
||||
* @param events - the turn'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.content
|
||||
.filter((block): block is ContentBlock & { type: 'text' } => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
425
packages/sdk/sdk-client/src/client.ts
Normal file
425
packages/sdk/sdk-client/src/client.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 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 the shared subprocess dispose ladder. The design twin is
|
||||
* the Python SDK's `HarnessClient` (`python/sdk`); both drive the same
|
||||
* runtime protocol.
|
||||
*
|
||||
* @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 { disposeChildProcess } from '@deepseek-ai/dsh-subagent-subprocess'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
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. Delivery order matches the wire;
|
||||
* {@link close} detaches it from the client, after which {@link next} rejects.
|
||||
*/
|
||||
export class NotificationSubscription implements AsyncIterable<HarnessNotification> {
|
||||
constructor(
|
||||
private readonly state: SubscriptionState,
|
||||
private readonly unsubscribe: () => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Await the next matching notification.
|
||||
* @returns the notification; rejects once the runtime is closed or the
|
||||
* subscription itself is closed.
|
||||
*/
|
||||
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()
|
||||
this.fail(new TransportClosedError('notification subscription closed'))
|
||||
}
|
||||
|
||||
/** Reject pending and future waits with `error` (delivery stops). */
|
||||
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. */
|
||||
push(notification: HarnessNotification): void {
|
||||
if (this.state.filter !== undefined && !this.state.filter(notification)) 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, NotificationSubscription>()
|
||||
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 } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one prompt turn to settlement (the response arrives only after the
|
||||
* turn settled; progress streams as notifications meanwhile).
|
||||
* @param sessionId - target session; an unknown id creates it.
|
||||
* @param contentBlocks - the user message, sent verbatim.
|
||||
*/
|
||||
async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise<void> {
|
||||
const params: SessionPromptParams = { sessionId, contentBlocks }
|
||||
const result = await this.request('session/prompt', { ...params })
|
||||
if (!isRecord(result) || result.accepted !== true) {
|
||||
throw new SdkProtocolError(`session/prompt was not accepted: ${JSON.stringify(result)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 pending = transport.request(method, params ?? {})
|
||||
const timeout = timeoutMs ?? this.options.requestTimeoutMs
|
||||
try {
|
||||
if (timeout === undefined) return await pending
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
pending,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
// The abandoned wire promise settles on close; keep it handled.
|
||||
pending.catch(() => {})
|
||||
reject(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for the DeepSeek Harness runtime`))
|
||||
}, timeout)
|
||||
}),
|
||||
])
|
||||
} 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.
|
||||
*/
|
||||
subscribe(filter?: NotificationFilter): NotificationSubscription {
|
||||
const id = String(this.subscriptionSerial++)
|
||||
const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined }
|
||||
const subscription = new NotificationSubscription(state, () => { this.subscriptions.delete(id) })
|
||||
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 disposeChildProcess(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). */
|
||||
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)
|
||||
}
|
||||
14
packages/sdk/sdk-client/src/index.ts
Normal file
14
packages/sdk/sdk-client/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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 turns 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 * from './api.ts'
|
||||
export * from './client.ts'
|
||||
export type * from './types.ts'
|
||||
31
packages/sdk/sdk-client/src/invariant.ts
Normal file
31
packages/sdk/sdk-client/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-sdk-client`.
|
||||
* @module @deepseek-ai/dsh-sdk-client/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-client'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'sdk-client-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this client library runs outside any harness context
|
||||
* (its peer is a separate runtime process); the runtime's own packages own
|
||||
* the event-stream relations.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
77
packages/sdk/sdk-client/src/types.ts
Normal file
77
packages/sdk/sdk-client/src/types.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Types for the TypeScript SDK client: launch options, notification shapes,
|
||||
* and turn results.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-client/types
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SdkRunStatus } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
|
||||
/** One server-to-client notification as received off the wire. */
|
||||
export interface HarnessNotification {
|
||||
/** The JSON-RPC method name (`session.event`, `session.finished`, `subagent.started`, `subagent.finished`). */
|
||||
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 `buildChildEnv` in
|
||||
* `@deepseek-ai/dsh-subagent-subprocess` for the scrub-then-inject helper).
|
||||
*/
|
||||
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`). */
|
||||
provider?: string
|
||||
/** Model for SDK-created agents (default `deepseek-v4-flash`). */
|
||||
model?: string
|
||||
}
|
||||
|
||||
/** The settled outcome of one {@link HarnessSession.run} turn. */
|
||||
export interface TurnResult {
|
||||
/** The session the turn ran on. */
|
||||
sessionId: string
|
||||
/** Deployment-mapped turn outcome from `session.finished`. */
|
||||
status: SdkRunStatus
|
||||
/** Why the last message-triggered turn ended; `undefined` when no turn ran. */
|
||||
reason: TurnEndReason | undefined
|
||||
/** Concatenated text of the session's last assistant message (empty when none). */
|
||||
finalResponse: string
|
||||
/** Every `session.event` payload for this session tree, in wire order. */
|
||||
events: SessionEvent[]
|
||||
/** Every notification observed during the turn, in wire order. */
|
||||
notifications: HarnessNotification[]
|
||||
}
|
||||
|
||||
/** Re-exported content-block alias so SDK callers need no extra import. */
|
||||
export type { ContentBlock }
|
||||
179
packages/sdk/sdk-client/tests/fake-runtime.ts
Normal file
179
packages/sdk/sdk-client/tests/fake-runtime.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/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_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_HANG`: stream a text chunk for the prompt, then never
|
||||
* finish the turn or answer (partial-output cancel probe). Touches
|
||||
* `FAKE_STREAM_READY` after the chunk when set.
|
||||
* - `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()
|
||||
event(sessionId, 'turn/start', { turn: 0 })
|
||||
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text } })
|
||||
event(sessionId, 'assistant/message', {
|
||||
turn: 0,
|
||||
step: 0,
|
||||
content: [{ type: 'text', text }],
|
||||
provenance: { 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' }],
|
||||
})
|
||||
}
|
||||
notify('session.finished', {
|
||||
sessionId,
|
||||
status: env.FAKE_STATUS ?? 'ok',
|
||||
...(reasonKind === 'none' ? {} : { reason: { kind: reasonKind } }),
|
||||
})
|
||||
}
|
||||
|
||||
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_MALFORMED !== undefined) {
|
||||
respond({})
|
||||
return
|
||||
}
|
||||
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
|
||||
return
|
||||
case 'session/prompt': {
|
||||
if (env.FAKE_STREAM_THEN_HANG !== undefined) {
|
||||
const sessionId = sessionIdOf(frame.params)
|
||||
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then hung' } })
|
||||
if (env.FAKE_STREAM_READY !== undefined) writeFileSync(env.FAKE_STREAM_READY, 'streamed\n')
|
||||
return
|
||||
}
|
||||
if (env.FAKE_HANG_PROMPT !== undefined) return
|
||||
if (env.FAKE_MALFORMED !== undefined || env.FAKE_MALFORMED_PROMPT !== undefined) {
|
||||
respond({})
|
||||
return
|
||||
}
|
||||
const sessionId = sessionIdOf(frame.params)
|
||||
runTurn(sessionId)
|
||||
respond({ accepted: true })
|
||||
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}` } })
|
||||
}
|
||||
})
|
||||
344
packages/sdk/sdk-client/tests/sdk-client.spec.ts
Normal file
344
packages/sdk/sdk-client/tests/sdk-client.spec.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* 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 { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DeepSeekHarness,
|
||||
finalResponse,
|
||||
HarnessClient,
|
||||
normalizeInput,
|
||||
RequestTimeoutError,
|
||||
SdkProtocolError,
|
||||
TransportClosedError,
|
||||
type HarnessNotification,
|
||||
} from '../src/index.ts'
|
||||
import { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
|
||||
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('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.status).toBe('ok')
|
||||
expect(first.reason).toEqual({ kind: 'completed' })
|
||||
expect(first.finalResponse).toBe('turn answer')
|
||||
expect(first.events.map(event => event.type)).toEqual(['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.status).toBe('ok')
|
||||
expect(second.sessionId).not.toBe(first.sessionId)
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('streams notifications to the observer and scopes them to 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) },
|
||||
})
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
// 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)
|
||||
// Child events do not count as the parent's own turn events.
|
||||
expect(result.events.every(event => event.type !== 'assistant/message'
|
||||
|| (event.data as { content: { type: string; text?: string }[] }).content[0]?.text !== 'child says hi')).toBe(true)
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('reports an error status with the turn-end reason', async () => {
|
||||
const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'max-tokens' })
|
||||
const result = await harness.run('overflow')
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.reason).toEqual({ kind: 'max-tokens' })
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('omits the reason when the runtime settled without one', async () => {
|
||||
const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'none' })
|
||||
const result = await harness.run('no turn')
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.reason).toBeUndefined()
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('sends the configured cwd/provider/model 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',
|
||||
})
|
||||
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' }])
|
||||
})
|
||||
|
||||
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('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.status).toBe('ok')
|
||||
}
|
||||
// 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('applies the client-wide request timeout when no per-call bound is given', async () => {
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }, { requestTimeoutMs: 400 }))
|
||||
cleanups.push(() => client.close())
|
||||
// The bound applies from send, so it holds regardless of runtime boot time.
|
||||
await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(RequestTimeoutError)
|
||||
await client.close()
|
||||
})
|
||||
|
||||
it('rejects a malformed prompt acceptance as a protocol error', async () => {
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_MALFORMED: '1' }))
|
||||
cleanups.push(() => client.close())
|
||||
await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(SdkProtocolError)
|
||||
await client.close()
|
||||
})
|
||||
|
||||
it('fails pending requests with exit code and stderr tail when the runtime dies', async () => {
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'fatal: scripted death' }))
|
||||
cleanups.push(() => client.close())
|
||||
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
|
||||
() => { throw new Error('initialize unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect(failure).toBeInstanceOf(TransportClosedError)
|
||||
expect(String(failure)).toContain('exit code: 3')
|
||||
expect(String(failure)).toContain('fatal: scripted death')
|
||||
// Requests after death fail immediately with the same context.
|
||||
await expect(client.request('initialize', {})).rejects.toThrow('exit code: 3')
|
||||
})
|
||||
|
||||
it('flushes an unterminated stderr line into the tail at close', async () => {
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_STDERR_NO_NEWLINE: 'no trailing newline', FAKE_EXIT_BEFORE_INIT: '1' }))
|
||||
cleanups.push(() => client.close())
|
||||
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
|
||||
() => { throw new Error('initialize unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect(String(failure)).toContain('no trailing newline')
|
||||
})
|
||||
|
||||
it('fails fast when the command does not exist', async () => {
|
||||
const client = new HarnessClient({ command: join(tmpdir(), 'dsh-no-such-runtime-bin') })
|
||||
cleanups.push(() => client.close())
|
||||
await expect(client.request('initialize', {}, 1_000)).rejects.toThrow(TransportClosedError)
|
||||
})
|
||||
|
||||
it('close() is idempotent, reaps the child, and fails later use', async () => {
|
||||
const client = new HarnessClient(fakeLaunch())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
await Promise.all([client.close(), client.close()])
|
||||
expect(() => { client.start() }).toThrow(TransportClosedError)
|
||||
await expect(client.request('anything')).rejects.toThrow(TransportClosedError)
|
||||
// Close with no child ever spawned is a no-op.
|
||||
const untouched = new HarnessClient(fakeLaunch())
|
||||
await untouched.close()
|
||||
})
|
||||
|
||||
it('escalates through SIGTERM when the runtime ignores EOF', async () => {
|
||||
const dir = await tempDir('sdk-client-ladder-')
|
||||
const sigtermFile = join(dir, 'sigterm.txt')
|
||||
const client = new HarnessClient(fakeLaunch(
|
||||
{ FAKE_IGNORE_EOF: '1', FAKE_SIGTERM_FILE: sigtermFile },
|
||||
{ shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 1_000 },
|
||||
))
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
await client.close()
|
||||
expect((await stat(sigtermFile)).isFile()).toBe(true)
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => {
|
||||
const client = new HarnessClient(fakeLaunch(
|
||||
{ FAKE_IGNORE_EOF: '1', FAKE_TRAP_SIGTERM: '1' },
|
||||
{ shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 300 },
|
||||
))
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
// Resolves (does not hang or reject): the SIGKILL rung reaped the child.
|
||||
await client.close()
|
||||
})
|
||||
|
||||
it('delivers notifications to unfiltered and filtered subscriptions in wire order', async () => {
|
||||
const client = new HarnessClient(fakeLaunch())
|
||||
cleanups.push(() => client.close())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
|
||||
const all = client.subscribe()
|
||||
const finishedOnly = client.subscribe(n => n.method === 'session.finished')
|
||||
await client.prompt('sub-test', normalizeInput('go'))
|
||||
|
||||
const first = await all.next()
|
||||
expect(first.method).toBe('session.event')
|
||||
const finished = await finishedOnly.next()
|
||||
expect(finished.method).toBe('session.finished')
|
||||
expect(finishedOnly.tryNext()).toBeUndefined()
|
||||
|
||||
// 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.finished') break
|
||||
}
|
||||
expect(collected.at(-1)).toBe('session.finished')
|
||||
|
||||
all.close()
|
||||
finishedOnly.close()
|
||||
await expect(all.next()).rejects.toThrow('notification subscription closed')
|
||||
await client.close()
|
||||
})
|
||||
|
||||
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('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: { content: [{ type: 'text', text: 'first' }] } } as never,
|
||||
{ type: 'assistant/message', seq: 1, time: 0, data: { content: [{ type: 'text', text: 'a' }, { type: 'tool-call' }, { type: 'text', text: 'b' }] } } as never,
|
||||
])).toBe('ab')
|
||||
})
|
||||
})
|
||||
33
packages/sdk/sdk-client/tsconfig.json
Normal file
33
packages/sdk/sdk-client/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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": "../sdk-protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent-subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
43
packages/sdk/sdk-protocol/package.json
Normal file
43
packages/sdk/sdk-protocol/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
12
packages/sdk/sdk-protocol/src/index.ts
Normal file
12
packages/sdk/sdk-protocol/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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 * from './transport.ts'
|
||||
export * from './types.ts'
|
||||
31
packages/sdk/sdk-protocol/src/invariant.ts
Normal file
31
packages/sdk/sdk-protocol/src/invariant.ts
Normal 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 */
|
||||
242
packages/sdk/sdk-protocol/src/transport.ts
Normal file
242
packages/sdk/sdk-protocol/src/transport.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
request(method: string, params: object): Promise<unknown> {
|
||||
const id = `req_${randomUUID().replaceAll('-', '')}`
|
||||
const message = { jsonrpc: '2.0', id, method, params }
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
try {
|
||||
this.write(message)
|
||||
} catch (error) {
|
||||
this.pending.delete(id)
|
||||
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> : {}
|
||||
}
|
||||
105
packages/sdk/sdk-protocol/src/types.ts
Normal file
105
packages/sdk/sdk-protocol/src/types.ts
Normal 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, TurnEndReason } 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
|
||||
}
|
||||
|
||||
/** 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[]
|
||||
}
|
||||
|
||||
/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */
|
||||
export interface SessionPromptResult {
|
||||
/** Always `true`; the turn outcome is the paired `session.finished` notification. */
|
||||
accepted: true
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** `session.finished` payload: one per accepted prompt, after turn settlement. */
|
||||
export interface SessionFinishedNotification {
|
||||
/** The settled session. */
|
||||
sessionId: string
|
||||
/** Deployment-mapped turn outcome (see `maxTokensAsSuccess` on the server). */
|
||||
status: SdkRunStatus
|
||||
/** Why the last message-triggered turn ended; absent when no turn ran. */
|
||||
reason: TurnEndReason | undefined
|
||||
}
|
||||
|
||||
/** `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.finished': SessionFinishedNotification
|
||||
'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> }
|
||||
}
|
||||
284
packages/sdk/sdk-protocol/tests/transport.spec.ts
Normal file
284
packages/sdk/sdk-protocol/tests/transport.spec.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
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.finished', { sessionId: 'main', status: 'ok' })
|
||||
a.notify('heartbeat')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(notifications).toEqual([
|
||||
{ method: 'session.finished', params: { sessionId: 'main', status: 'ok' } },
|
||||
{ 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('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()
|
||||
})
|
||||
})
|
||||
30
packages/sdk/sdk-protocol/tsconfig.json
Normal file
30
packages/sdk/sdk-protocol/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user