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:
Tianyi Cui
2026-07-27 03:21:06 +08:00
parent 46e70be34a
commit 4ad37344a8
33 changed files with 2738 additions and 122 deletions

View File

@@ -7,11 +7,10 @@
* @module @deepseek-ai/dsh-subagent-acp
*/
import { accessSync, constants, statSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
export const name = 'subagent-acp'
@@ -77,60 +76,6 @@ function assertPositiveFinite(name: string, value: number): void {
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
/**
* Whether `path` names an existing directory the harness can ENTER. The
* search-permission probe matters: `statSync().isDirectory()` is true for a
* mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
*/
function isDirectory(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
// statSync/accessSync throw only filesystem access errors here
// (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
// serve as the child's cwd.
return false
}
}
/**
* Assert `cwd` can actually host the child: absolute (it doubles as the ACP
* session workspace, and a relative path would be re-anchored to the server
* process's launch directory) and an existing directory (fail here, before the
* process boundary, instead of as an ambiguous spawn ENOENT).
* @param label - which source supplied the value, for the diagnostic.
* @param cwd - the candidate working directory.
* @returns `cwd`, validated.
*/
function assertUsableCwd(label: string, cwd: string): string {
if (!isAbsolute(cwd)) {
throw new Error(`subagent-acp: ${label} must be an absolute path: ${cwd}`)
}
if (!isDirectory(cwd)) {
throw new Error(`subagent-acp: ${label} is not an accessible directory: ${cwd}`)
}
return cwd
}
/**
* Resolve the child's working directory: the deployment `cwd` override when
* configured (already validated at load), else the parent session's workspace
* cwd (validated here, its earliest resolvable point). Fails loud when neither
* exists — falling back to the harness process cwd would silently bind the
* child to the server's launch directory instead of the delegating session's
* workspace (one server process serves many sessions, each with its own cwd).
*/
function resolveCwd(configured: string | undefined, request: SubagentStartRequest): string {
if (configured !== undefined) return configured
const parentCwd = request.parent.session.header.cwd
if (parentCwd === undefined) {
throw new Error('subagent-acp: no working directory for the child — configure `cwd` or delegate from a parent session that has one')
}
return assertUsableCwd('parent session cwd', parentCwd)
}
/**
* The ACP provider. Advertises NO start-time capabilities: an out-of-process
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
@@ -147,7 +92,7 @@ class AcpProvider implements SubagentProvider {
const spec: AcpRunSpec = {
command: this.config.command,
args: this.config.args,
cwd: resolveCwd(this.config.cwd, request),
cwd: resolveChildCwd('subagent-acp', this.config.cwd, request.parent.session.header.cwd),
permission: this.config.permission,
env: this.config.env,
disposeEofGraceMs: this.config.disposeEofGraceMs,
@@ -167,15 +112,11 @@ export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
// `path.resolve('')` is the process cwd — an empty string would silently
// reintroduce the launch-directory fallback this resolution removed.
if (resolved.cwd === '') {
throw new Error('subagent-acp: config cwd must not be empty — omit the key to inherit the parent session cwd')
}
// Interpret a relative configured cwd against the harness launch directory
// ONCE, at load, and fail a misconfigured directory here — not per start.
const validated: ResolvedConfig = resolved.cwd === undefined
const configuredCwd = validateConfiguredCwd('subagent-acp', resolved.cwd)
const validated: ResolvedConfig = configuredCwd === undefined
? resolved
: { ...resolved, cwd: assertUsableCwd('config cwd', resolve(resolved.cwd)) }
: { ...resolved, cwd: configuredCwd }
ctx.subagents.registerProvider(new AcpProvider(validated.providerName, ctx, validated))
}

View File

@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-subagent-sdk",
"description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client",
"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-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sdk-client": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subagent-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-sdk-client": "workspace:^",
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,138 @@
/**
* Out-of-process SDK subagent backend. Each child is a complete DeepSeek
* Harness runtime in its own process — own `cordis.yml`-decided composition,
* session, model route, and tools — driven over stdio JSON-RPC through the
* TypeScript SDK client, so it shares no Cordis context and advertises no
* parent-enforced start capabilities; the ONE thing it reads off
* `request.parent` is the session's workspace cwd. This plugin uses named
* exports only; a default would hide its loader metadata (see
* `docs/postmortem/0001-acp-default-export-drops-inject.md`).
* @module @deepseek-ai/dsh-subagent-sdk
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
import {
DEFAULT_DISPOSE_EOF_GRACE_MS,
DEFAULT_DISPOSE_GRACE_MS,
DEFAULT_SHUTDOWN_TIMEOUT_MS,
startSdkRun,
type SdkRunSpec,
} from './run.ts'
export const name = 'subagent-sdk'
export const inject = ['subagents']
/** Config: how to spawn and drive the child SDK runtime process. */
export interface Config {
/** Provider name on `ctx.subagents` (default `sdk`). */
providerName: string
/** The executable to spawn for each run (the child runtime bin or packaged exe). */
command: string
/** Arguments passed to {@link command} (typically the child's `cordis.yml` path). */
args: string[]
/**
* Working directory override for the child process and its SDK session
* workspace. Must be non-empty; a relative path resolves against the
* harness launch directory at load, and the result must be an existing
* directory. When omitted, each child inherits its delegating parent
* session's cwd — and starting one from a parent session that has no cwd
* fails.
*/
cwd?: string
/** Provider route the child runtime initializes with (default `deepseek`). */
provider: string
/** Model the child runtime initializes with (default `deepseek-v4-flash`). */
model: string
/**
* Extra environment variables for the child process — e.g. the child
* runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG` naming its
* config. Forwarded on top of a credential-scrubbed copy of the parent
* env, so an explicit key here reaches the child while ambient secrets do
* not leak implicitly.
*/
env: Record<string, string>
/** Bound (ms) on the protocol `shutdown` exchange during dispose. */
shutdownTimeoutMs?: number
/**
* Grace period (ms) for the child's EOF-driven quiesce on dispose — its
* window to flush persistence and tear down its own nested subprocesses
* before the parent escalates to a signal.
*/
disposeEofGraceMs?: number
/** Termination confirmation window (ms), including forced exit on every platform. */
disposeGraceMs?: number
}
export const Config: z<Config> = z.object({
providerName: z.string().default('sdk'),
command: z.string().required(),
args: z.array(z.string()).default([]),
cwd: z.string(),
provider: z.string().default('deepseek'),
model: z.string().default('deepseek-v4-flash'),
env: z.dict(z.string()).default({}),
shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS),
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
/** A timing bound must be a positive finite number (it bounds a teardown wait). */
function assertPositiveFinite(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`subagent-sdk: ${name} must be a positive finite number`)
}
}
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
/**
* The SDK provider. Advertises NO start-time capabilities: an out-of-process
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter`/`persona` (the
* service rejects a request needing any of them before `start` runs).
*/
class SdkProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
// Context contract: an out-of-process SDK child starts fresh — no parent conversation crosses the process boundary.
readonly inheritsParentContext = false
constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
start(request: SubagentStartRequest) {
const spec: SdkRunSpec = {
command: this.config.command,
args: this.config.args,
cwd: resolveChildCwd('subagent-sdk', this.config.cwd, request.parent.session.header.cwd),
provider: this.config.provider,
model: this.config.model,
env: this.config.env,
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
disposeEofGraceMs: this.config.disposeEofGraceMs,
disposeGraceMs: this.config.disposeGraceMs,
onError: (error, stopReason) => {
// The seam forbids `result` rejecting, so a child-level failure is
// flattened to a stop reason — preserve it here rather than losing it.
this.ctx.logger.warn(`subagent-sdk "${this.name}": child run failed (${stopReason}): ${error.message}`)
},
}
return startSdkRun(request, spec)
}
}
export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveFinite('shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
// Interpret a relative configured cwd against the harness launch directory
// ONCE, at load, and fail a misconfigured directory here — not per start.
const configuredCwd = validateConfiguredCwd('subagent-sdk', resolved.cwd)
const validated: ResolvedConfig = configuredCwd === undefined
? resolved
: { ...resolved, cwd: configuredCwd }
ctx.subagents.registerProvider(new SdkProvider(validated.providerName, ctx, validated))
}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-sdk`.
* @module @deepseek-ai/dsh-subagent-sdk/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-sdk'
/** Cordis companion plugin name. */
export const name = 'subagent-sdk-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: run lifecycle pairing is owned and checked by the
* subagent seam's invariant; this backend's own state lives in the child
* process beyond this context's event streams.
*/
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,217 @@
/**
* Fresh-process SDK subagent client. Drives one child DeepSeek Harness
* runtime over stdio JSON-RPC through `@deepseek-ai/dsh-sdk-client` and owns
* cancellation and quiescent disposal. Structure mirrors the ACP backend
* (`@deepseek-ai/dsh-subagent-acp`): publish after the child handshake,
* flatten child failures into stop reasons, tear down through the shared
* subprocess dispose ladder.
*
* @module @deepseek-ai/dsh-subagent-sdk/run
*/
import { randomUUID } from 'node:crypto'
import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-client'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
/** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */
export interface SdkRunSpec {
/** The executable to spawn (the child runtime — a `dsh-jsonrpc-agent` bin or packaged exe). */
command: string
/** Arguments passed to {@link command} (typically the child's `cordis.yml` path). */
args: string[]
/**
* Absolute working directory for the child process AND the workspace cwd
* of its SDK session. The provider resolves it before this spec exists:
* config override, else the delegating parent session's workspace.
*/
cwd: string
/** Provider route the child runtime initializes with. */
provider: string
/** Model the child runtime initializes with. */
model: string
/**
* Extra environment variables to ADD for the child (e.g. the child
* runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG`). Merged on top
* of the credential-scrubbed ambient env — see `buildChildEnv`.
*/
env: Record<string, string>
/** Bound (ms) on the protocol `shutdown` exchange during dispose. */
shutdownTimeoutMs: number
/** Grace period (ms) for the child's EOF-driven quiesce on dispose. */
disposeEofGraceMs: number
/** Termination confirmation window (ms), including forced exit on every platform. */
disposeGraceMs: number
/**
* Sink for a child-level failure that the run flattened into a stop reason
* (the seam contract forbids `result` rejecting). A throw from the sink
* itself is contained. Optional — omitted in unit tests that assert the
* stop reason directly.
*/
onError?: (error: Error, stopReason: SubagentStopReason) => void
}
/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/** Default bound on the protocol `shutdown` exchange during dispose. */
export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 1_000
/**
* Map a child turn-end reason to a harness {@link SubagentStopReason}.
* @param reason - the `session.finished` reason, or `undefined` when the
* child settled without running a turn.
* @returns the harness equivalent; an absent or unknown reason maps to
* `error`, so an unclean stop is never reported as `completed`.
*/
export function sdkStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
switch (reason?.kind) {
case 'completed':
return 'completed'
case 'max-tokens':
return 'max-tokens'
case 'aborted':
return 'aborted'
// error / rejected / interrupted / disposed / a future merged variant /
// no turn at all: the task did NOT finish cleanly — surface a generic
// failure so the consumer maps it to an isError result.
default:
return 'error'
}
}
/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */
function toError(value: unknown): Error {
// The catch only sees rejections from the SDK client, which are always
// `Error`s; the `String(value)` arm is a defensive fallback for a non-Error
// throw that the typed surfaces cannot produce.
/* v8 ignore next */
return value instanceof Error ? value : new Error(String(value))
}
/**
* Start and publish one SDK runtime child after its `initialize` handshake.
* Child failures resolve through the run result; startup failures reject
* after process reap. Disposal shuts the runtime down and reaps it.
* @param request - the start request; its signal is the cancellation channel.
* @param spec - the resolved spawn spec: command/args/cwd, the child's
* provider/model route, env, timeouts, and the optional error sink.
* @returns the ready run handle for the child subprocess.
*/
export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpec): Promise<SubagentRun> {
if (request.signal.aborted) throw new Error('subagent request was aborted before the SDK child started')
// The run id lives in the parent namespace; the child runtime's session id
// (minted below, private to the wire) exists only inside the child process.
const id = SessionId(randomUUID())
const harness = new DeepSeekHarness({
launch: {
command: spec.command,
args: spec.args,
cwd: spec.cwd,
env: buildChildEnv(spec.env),
shutdownTimeoutMs: spec.shutdownTimeoutMs,
disposeEofGraceMs: spec.disposeEofGraceMs,
disposeGraceMs: spec.disposeGraceMs,
},
cwd: spec.cwd,
provider: spec.provider,
model: spec.model,
})
// Cancellation settles the result without waiting for a cooperative child.
const flags = { cancelled: false }
let signalCancelSettled!: () => void
const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
const requestCancel = (): void => {
if (flags.cancelled) return
flags.cancelled = true
signalCancelSettled()
}
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
// Establish the child handshake before publishing a handle. Any failure
// owns the still-private process and reaps it before rejecting.
try {
await Promise.race([
harness.start(),
cancelSettled.then((): never => { throw new Error('subagent cancelled before the SDK child initialized') }),
])
// Defensive: an abort() is a macrotask and no user callback runs inside
// the microtask drain between handshake fulfillment and this continuation,
// so the recheck is not schedulable today; it guards future reentrancy.
/* v8 ignore next */
if (flags.cancelled) throw new Error('subagent cancelled before the SDK child initialized')
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
await harness.close()
if (flags.cancelled) throw new Error('subagent request was aborted before the SDK child started')
throw toError(error)
}
const childSessionId = `session-${randomUUID().replaceAll('-', '')}`
// The child's final answer: the last complete assistant message when one
// exists, else the text streamed so far (a partial answer surviving cancel).
let lastMessage: ContentBlock[] | undefined
const partial: string[] = []
const observe = (notification: HarnessNotification): void => {
if (notification.method !== 'session.event' || notification.params.sessionId !== childSessionId) return
const event = notification.params.event as SessionEvent
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
partial.push(event.data.chunk.text)
} else if (event.type === 'assistant/message') {
lastMessage = event.data.content
}
}
const collectOutput = (): ContentBlock[] => {
if (lastMessage !== undefined) return lastMessage
const text = partial.join('')
return text.length > 0 ? [{ type: 'text', text }] : []
}
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
try {
const turn = await Promise.race([
harness.session(childSessionId).run(request.prompt, { onNotification: observe }),
cancelSettled.then(() => 'cancelled' as const),
])
if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' }
return { output: collectOutput(), stopReason: sdkStopReason(turn.reason) }
} catch (error: unknown) {
// Cover a transport rejection already queued when cancellation arrives.
/* v8 ignore next */
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
// Flatten post-publication transport failures while preserving diagnostics.
try {
spec.onError?.(toError(error), 'error')
} catch {
// The diagnostic sink cannot reject the run result.
}
return { output: collectOutput(), stopReason: 'error' }
} finally {
request.signal.removeEventListener('abort', onAbort)
}
})()
let disposal: Promise<void> | undefined
return {
id,
localAgent: undefined,
result,
dispose(): Promise<void> {
if (disposal !== undefined) return disposal
request.signal.removeEventListener('abort', onAbort)
// There is no wire-level prompt cancel: settle the result locally, then
// the bounded shutdown request + dispose ladder tears the child down.
requestCancel()
disposal = harness.close()
return disposal
},
}
}

View File

@@ -0,0 +1,417 @@
/**
* Keyless integration tests for the SDK subagent backend. Each spawns a REAL
* subprocess — the SDK client package's scripted fake runtime — and drives it
* through the REAL backend over real stdio JSON-RPC, so the handshake, the
* turn round-trip, stop-reason mapping, cancellation, env scrubbing, and
* quiescent disposal are all exercised end to end. No model, no key.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as sdk from '../src/index.ts'
import {
DEFAULT_DISPOSE_EOF_GRACE_MS,
DEFAULT_DISPOSE_GRACE_MS,
DEFAULT_SHUTDOWN_TIMEOUT_MS,
sdkStopReason,
startSdkRun,
type SdkRunSpec,
} from '../src/run.ts'
const fakeRuntime = fileURLToPath(new URL('../../../sdk/sdk-client/tests/fake-runtime.ts', import.meta.url))
/** A parent Agent stub. The SDK backend reads exactly one thing off it: the session header's cwd (the workspace its child inherits). */
const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
function request(text = 'p', signal = new AbortController().signal) {
return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
}
/** Mount the SDK backend pointed at the fake runtime, scripted by `fakeEnv`. */
async function setup(fakeEnv: Record<string, string> = {}, config: Partial<sdk.Config> = {}) {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(sdk, {
providerName: 'sdk',
command: process.execPath,
args: [fakeRuntime],
provider: 'fake-provider',
model: 'fake-model',
env: fakeEnv,
...config,
})
return ctx
}
function text(blocks: { type: string; text?: string }[]): string {
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
}
/**
* Poll until `file` exists (the fake touches it once the probed state is
* reached), so cancel tests wait on a CONDITION rather than an arbitrary
* timeout. Fails loud if the child never signals readiness.
*/
async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!existsSync(file)) {
if (Date.now() > deadline) throw new Error(`fake runtime never became ready (${file})`)
await new Promise(r => setTimeout(r, 10))
}
}
describe('sdkStopReason', () => {
it('maps each child turn-end reason to the harness vocabulary', () => {
expect(sdkStopReason({ kind: 'completed' })).toBe('completed')
expect(sdkStopReason({ kind: 'max-tokens' })).toBe('max-tokens')
expect(sdkStopReason({ kind: 'aborted' })).toBe('aborted')
expect(sdkStopReason({ kind: 'error', step: 0, message: 'x' })).toBe('error')
expect(sdkStopReason({ kind: 'rejected', reason: 'policy' })).toBe('error')
})
it('treats an absent or unknown reason as an error', () => {
expect(sdkStopReason(undefined)).toBe('error')
expect(sdkStopReason({ kind: 'something-new' } as never)).toBe('error')
})
})
describe('dsh-subagent-sdk provider', () => {
it('runs a child turn end to end with a parent-unique run id', async () => {
const ctx = await setup({ FAKE_TEXT: 'hello from sdk child' })
const run = await ctx.subagents.start('sdk', request('do X'))
expect(run.localAgent).toBeUndefined()
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('hello from sdk child')
// dispose is idempotent (one memoized teardown).
const disposal = run.dispose()
expect(run.dispose()).toBe(disposal)
await disposal
const nextRun = await ctx.subagents.start('sdk', request('again'))
expect(nextRun.id).not.toBe(run.id)
await nextRun.result
await nextRun.dispose()
await ctx.fiber.dispose()
})
it('initializes the child with the configured provider/model and the parent cwd', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-init-'))
const recordFile = join(tmp, 'init.jsonl')
try {
const ctx = await setup({ FAKE_RECORD_INIT: recordFile })
const run = await ctx.subagents.start('sdk', request())
await run.result
await run.dispose()
const { readFileSync } = await import('node:fs')
const records = readFileSync(recordFile, 'utf8').trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
expect(records).toEqual([{ cwd: process.cwd(), provider: 'fake-provider', model: 'fake-model' }])
await ctx.fiber.dispose()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('scrubs ambient credentials but forwards explicit config env', async () => {
process.env.DSH_TEST_AMBIENT_SECRET_KEY = 'leak-me-not'
try {
const ctx = await setup({
FAKE_ECHO_ENV: 'DSH_TEST_AMBIENT_SECRET_KEY,DEEPSEEK_API_KEY',
DEEPSEEK_API_KEY: 'explicit-child-key',
FAKE_TEXT: 'done',
})
const run = await ctx.subagents.start('sdk', request())
const result = await run.result
const answer = text(result.output)
expect(answer).toContain('DSH_TEST_AMBIENT_SECRET_KEY=\n')
expect(answer).toContain('DEEPSEEK_API_KEY=explicit-child-key')
await run.dispose()
await ctx.fiber.dispose()
} finally {
delete process.env.DSH_TEST_AMBIENT_SECRET_KEY
}
})
it('maps a max-tokens child turn end', async () => {
const ctx = await setup({ FAKE_REASON_KIND: 'max-tokens', FAKE_STATUS: 'error' })
const run = await ctx.subagents.start('sdk', request())
expect((await run.result).stopReason).toBe('max-tokens')
await run.dispose()
await ctx.fiber.dispose()
})
it('flattens a child turn error into stopReason error and keeps partial text', async () => {
const ctx = await setup({ FAKE_REASON_KIND: 'error', FAKE_STATUS: 'error', FAKE_TEXT: 'partial answer' })
const run = await ctx.subagents.start('sdk', request())
const result = await run.result
expect(result.stopReason).toBe('error')
expect(text(result.output)).toBe('partial answer')
await run.dispose()
await ctx.fiber.dispose()
})
it('reports a settled-without-turn child as an error', async () => {
const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' })
const run = await ctx.subagents.start('sdk', request())
expect((await run.result).stopReason).toBe('error')
await run.dispose()
await ctx.fiber.dispose()
})
it('aborting the required signal settles a hung child as aborted', async () => {
const ctx = await setup({ FAKE_HANG_PROMPT: '1' }, { disposeEofGraceMs: 200, disposeGraceMs: 200 })
const controller = new AbortController()
const run = await ctx.subagents.start('sdk', request('p', controller.signal))
controller.abort('test')
const result = await run.result
expect(result.stopReason).toBe('aborted')
// The hung child streamed nothing, so the aborted result has no output.
expect(result.output).toEqual([])
await run.dispose()
await ctx.fiber.dispose()
})
it('cancelling between handshake and publish rejects start after reap', async () => {
// The abort lands while the child is INSIDE initialize (ready-file
// handshake window): the fake touches READY, we abort, then GO lets the
// handshake complete — so the post-race `flags.cancelled` recheck must
// reject even though the handshake itself succeeded.
const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-midcancel-'))
const ready = join(tmp, 'ready')
const go = join(tmp, 'go')
try {
const controller = new AbortController()
const spec: SdkRunSpec = {
command: process.execPath,
args: [fakeRuntime],
cwd: process.cwd(),
provider: 'p',
model: 'm',
env: { FAKE_INIT_READY: ready, FAKE_INIT_GO: go },
shutdownTimeoutMs: 100,
disposeEofGraceMs: 200,
disposeGraceMs: 200,
}
const pending = startSdkRun(request('p', controller.signal), spec)
await waitForFile(ready)
controller.abort('mid-handshake')
const { writeFileSync } = await import('node:fs')
writeFileSync(go, 'go\n')
await expect(pending).rejects.toThrow('aborted before the SDK child started')
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('keeps partial streamed text when aborted mid-turn', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-partial-'))
const streamed = join(tmp, 'streamed')
try {
const ctx = await setup(
{ FAKE_STREAM_THEN_HANG: '1', FAKE_STREAM_READY: streamed },
{ disposeEofGraceMs: 200, disposeGraceMs: 200, shutdownTimeoutMs: 100 },
)
const controller = new AbortController()
const run = await ctx.subagents.start('sdk', request('p', controller.signal))
// Cancel only after the chunk has demonstrably streamed (condition, not a sleep).
await waitForFile(streamed)
controller.abort('test')
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(text(result.output)).toBe('streamed then hung')
await run.dispose()
await ctx.fiber.dispose()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('dispose cancels a hung child locally and reaps it', async () => {
const ctx = await setup({ FAKE_HANG_PROMPT: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 })
const run = await ctx.subagents.start('sdk', request())
await run.dispose()
expect((await run.result).stopReason).toBe('aborted')
await ctx.fiber.dispose()
})
it('rejects WITHOUT spawning when the signal is already aborted', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-preabort-'))
const sentinel = join(tmp, 'spawned')
try {
const controller = new AbortController()
controller.abort()
await expect(startSdkRun(
request('p', controller.signal),
// `touch <sentinel>` — runs only if the process is actually spawned.
{
command: 'touch',
args: [sentinel],
cwd: tmp,
provider: 'p',
model: 'm',
env: {},
shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS,
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
},
)).rejects.toThrow('aborted before the SDK child started')
expect(existsSync(sentinel)).toBe(false)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('rejects after reaping when the child dies before the handshake', async () => {
const ctx = await setup({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'scripted boot failure' })
const failure = await ctx.subagents.start('sdk', request()).then(
() => { throw new Error('start unexpectedly succeeded') },
(error: unknown) => error,
)
expect(String(failure)).toContain('exit code: 3')
expect(String(failure)).toContain('scripted boot failure')
await ctx.fiber.dispose()
})
it('cancelling mid-handshake rejects start after reaping the child', async () => {
const controller = new AbortController()
const spec: SdkRunSpec = {
command: process.execPath,
args: [fakeRuntime],
cwd: process.cwd(),
provider: 'p',
model: 'm',
env: { FAKE_HANG_INIT: '1' },
shutdownTimeoutMs: 100,
disposeEofGraceMs: 200,
disposeGraceMs: 200,
}
const pending = startSdkRun(request('p', controller.signal), spec)
controller.abort('now')
await expect(pending).rejects.toThrow('aborted before the SDK child started')
})
it('routes a post-publication child failure through onError and settles error', async () => {
const seen: string[] = []
const spec: SdkRunSpec = {
command: process.execPath,
args: [fakeRuntime],
cwd: process.cwd(),
provider: 'p',
model: 'm',
// The fake dies as soon as the prompt arrives: FAKE_HANG_PROMPT plus a
// short-lived process is simulated by killing via dispose below instead;
// here use FAKE_MALFORMED to make the prompt reply violate the protocol.
env: { FAKE_MALFORMED_PROMPT: '1' },
shutdownTimeoutMs: 100,
disposeEofGraceMs: 200,
disposeGraceMs: 200,
onError: (error) => {
seen.push(error.message)
throw new Error('sink failure must be contained')
},
}
const run = await startSdkRun(request(), spec)
const result = await run.result
expect(result.stopReason).toBe('error')
expect(seen).toHaveLength(1)
await run.dispose()
})
it('routes provider-level onError through ctx.logger.warn', async () => {
const ctx = await setup({ FAKE_MALFORMED_PROMPT: '1' })
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const run = await ctx.subagents.start('sdk', request())
expect((await run.result).stopReason).toBe('error')
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('subagent-sdk "sdk": child run failed (error)')
await run.dispose()
await ctx.fiber.dispose()
})
it('registers under the configured provider name and unregisters on fiber dispose (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(sdk, {
providerName: 'sdk-hmr',
command: process.execPath,
args: [fakeRuntime],
provider: 'p',
model: 'm',
env: {},
})
expect(ctx.subagents.getProvider('sdk-hmr')?.name).toBe('sdk-hmr')
expect(ctx.subagents.getProvider('sdk-hmr')?.inheritsParentContext).toBe(false)
expect(ctx.subagents.getProvider('sdk-hmr')?.capabilities).toEqual({
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
})
await fiber.dispose()
expect(ctx.subagents.getProvider('sdk-hmr')).toBeUndefined()
await ctx.fiber.dispose()
})
it('rejects non-positive timing bounds at load', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const base = { providerName: 'sdk', command: 'true', args: [], provider: 'p', model: 'm', env: {} }
await expect(ctx.plugin(sdk, { ...base, shutdownTimeoutMs: 0 })).rejects.toThrow('shutdownTimeoutMs must be a positive finite number')
await expect(ctx.plugin(sdk, { ...base, disposeEofGraceMs: -1 })).rejects.toThrow('disposeEofGraceMs must be a positive finite number')
await expect(ctx.plugin(sdk, { ...base, disposeGraceMs: Number.NaN })).rejects.toThrow('disposeGraceMs must be a positive finite number')
await ctx.fiber.dispose()
})
it('rejects an empty config cwd at load', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await expect(ctx.plugin(sdk, {
providerName: 'sdk',
command: 'true',
args: [],
cwd: '',
provider: 'p',
model: 'm',
env: {},
})).rejects.toThrow('config cwd must not be empty')
await ctx.fiber.dispose()
})
it('uses a validated config cwd override instead of the parent session cwd', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-cwd-'))
try {
const ctx = await setup({ FAKE_ECHO_CWD: '1', FAKE_TEXT: 'done' }, { cwd: tmp })
const run = await ctx.subagents.start('sdk', request())
const result = await run.result
const { realpathSync } = await import('node:fs')
expect(text(result.output)).toContain(`cwd=${realpathSync(tmp)}`)
await run.dispose()
await ctx.fiber.dispose()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('fails loud when neither config cwd nor parent session cwd exists', async () => {
const ctx = await setup()
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
await expect(ctx.subagents.start('sdk', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
.rejects.toThrow('no working directory for the child')
await ctx.fiber.dispose()
})
it('keeps named plugin exports with no default export (loader shape)', () => {
expect(sdk.name).toBe('subagent-sdk')
expect(sdk.inject).toEqual(['subagents'])
expect(typeof sdk.apply).toBe('function')
expect(typeof sdk.Config).toBe('function')
expect((sdk as Record<string, unknown>).default).toBeUndefined()
})
})

View File

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

View File

@@ -0,0 +1,86 @@
/**
* Child working-directory resolution shared by out-of-process subagent
* backends: a deployment `cwd` override validated at load, else the
* delegating parent session's workspace cwd validated per start — never the
* server process's own cwd, because one server process serves many sessions,
* each with its own workspace.
*
* @module @deepseek-ai/dsh-subagent-subprocess/cwd
*/
import { accessSync, constants, statSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path'
/**
* Whether `path` names an existing directory the harness can ENTER. The
* search-permission probe matters: `statSync().isDirectory()` is true for a
* mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
*/
function isDirectory(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
// statSync/accessSync throw only filesystem access errors here
// (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
// serve as the child's cwd.
return false
}
}
/**
* Assert `cwd` can actually host the child: absolute (it doubles as the
* child's workspace identity, and a relative path would be re-anchored to the
* server process's launch directory) and an existing directory (fail here,
* before the process boundary, instead of as an ambiguous spawn ENOENT).
* @param prefix - the consuming plugin's diagnostic prefix (e.g. `subagent-acp`).
* @param label - which source supplied the value, for the diagnostic.
* @param cwd - the candidate working directory.
* @returns `cwd`, validated.
*/
export function assertUsableCwd(prefix: string, label: string, cwd: string): string {
if (!isAbsolute(cwd)) {
throw new Error(`${prefix}: ${label} must be an absolute path: ${cwd}`)
}
if (!isDirectory(cwd)) {
throw new Error(`${prefix}: ${label} is not an accessible directory: ${cwd}`)
}
return cwd
}
/**
* Validate a configured `cwd` override ONCE, at plugin load: reject the empty
* string (`path.resolve('')` is the process cwd — it would silently
* reintroduce the launch-directory fallback this resolution removes),
* interpret a relative path against the harness launch directory, and require
* an enterable directory.
* @param prefix - the consuming plugin's diagnostic prefix.
* @param cwd - the configured override, or `undefined` when the config omits it.
* @returns the validated absolute override, or `undefined` when omitted.
*/
export function validateConfiguredCwd(prefix: string, cwd: string | undefined): string | undefined {
if (cwd === undefined) return undefined
if (cwd === '') {
throw new Error(`${prefix}: config cwd must not be empty — omit the key to inherit the parent session cwd`)
}
return assertUsableCwd(prefix, 'config cwd', resolve(cwd))
}
/**
* Resolve the child's working directory at start: the deployment override
* when configured (already validated at load), else the parent session's
* workspace cwd (validated here, its earliest resolvable point). Fails loud
* when neither exists.
* @param prefix - the consuming plugin's diagnostic prefix.
* @param configured - the load-validated override, or `undefined`.
* @param parentCwd - the delegating parent session's workspace cwd, if any.
* @returns the absolute child working directory.
*/
export function resolveChildCwd(prefix: string, configured: string | undefined, parentCwd: string | undefined): string {
if (configured !== undefined) return configured
if (parentCwd === undefined) {
throw new Error(`${prefix}: no working directory for the child — configure \`cwd\` or delegate from a parent session that has one`)
}
return assertUsableCwd(prefix, 'parent session cwd', parentCwd)
}

View File

@@ -11,6 +11,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export * from './cwd.ts'
/**
* Credential-shaped ambient env vars are NOT forwarded to a child by default
* (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a