From 4ad37344a892209887eed777041d503cf1caf268 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:21:06 +0800 Subject: [PATCH 01/13] feat(sdk): TypeScript SDK client + shared wire protocol + SDK subagent backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @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. --- packages/sdk/sdk-client/package.json | 45 ++ packages/sdk/sdk-client/src/api.ts | 193 ++++++++ packages/sdk/sdk-client/src/client.ts | 425 ++++++++++++++++++ packages/sdk/sdk-client/src/index.ts | 14 + packages/sdk/sdk-client/src/invariant.ts | 31 ++ packages/sdk/sdk-client/src/types.ts | 77 ++++ packages/sdk/sdk-client/tests/fake-runtime.ts | 179 ++++++++ .../sdk/sdk-client/tests/sdk-client.spec.ts | 344 ++++++++++++++ packages/sdk/sdk-client/tsconfig.json | 33 ++ packages/sdk/sdk-protocol/package.json | 43 ++ packages/sdk/sdk-protocol/src/index.ts | 12 + packages/sdk/sdk-protocol/src/invariant.ts | 31 ++ .../sdk-protocol}/src/transport.ts | 35 +- packages/sdk/sdk-protocol/src/types.ts | 105 +++++ .../sdk-protocol}/tests/transport.spec.ts | 30 +- packages/sdk/sdk-protocol/tsconfig.json | 30 ++ packages/subagent/subagent-acp/src/index.ts | 69 +-- packages/subagent/subagent-sdk/package.json | 55 +++ packages/subagent/subagent-sdk/src/index.ts | 138 ++++++ .../subagent/subagent-sdk/src/invariant.ts | 31 ++ packages/subagent/subagent-sdk/src/run.ts | 217 +++++++++ .../subagent-sdk/tests/subagent-sdk.spec.ts | 417 +++++++++++++++++ packages/subagent/subagent-sdk/tsconfig.json | 48 ++ .../subagent/subagent-subprocess/src/cwd.ts | 86 ++++ .../subagent/subagent-subprocess/src/index.ts | 2 + packages/ui/jsonrpc/package.json | 2 + packages/ui/jsonrpc/src/index.ts | 3 +- packages/ui/jsonrpc/src/server.ts | 64 +-- packages/ui/jsonrpc/tests/server.spec.ts | 9 +- packages/ui/jsonrpc/tsconfig.json | 3 + pnpm-lock.yaml | 85 ++++ python/sdk-runtime/package.json | 1 + tsconfig.host.json | 3 + 33 files changed, 2738 insertions(+), 122 deletions(-) create mode 100644 packages/sdk/sdk-client/package.json create mode 100644 packages/sdk/sdk-client/src/api.ts create mode 100644 packages/sdk/sdk-client/src/client.ts create mode 100644 packages/sdk/sdk-client/src/index.ts create mode 100644 packages/sdk/sdk-client/src/invariant.ts create mode 100644 packages/sdk/sdk-client/src/types.ts create mode 100644 packages/sdk/sdk-client/tests/fake-runtime.ts create mode 100644 packages/sdk/sdk-client/tests/sdk-client.spec.ts create mode 100644 packages/sdk/sdk-client/tsconfig.json create mode 100644 packages/sdk/sdk-protocol/package.json create mode 100644 packages/sdk/sdk-protocol/src/index.ts create mode 100644 packages/sdk/sdk-protocol/src/invariant.ts rename packages/{ui/jsonrpc => sdk/sdk-protocol}/src/transport.ts (85%) create mode 100644 packages/sdk/sdk-protocol/src/types.ts rename packages/{ui/jsonrpc => sdk/sdk-protocol}/tests/transport.spec.ts (85%) create mode 100644 packages/sdk/sdk-protocol/tsconfig.json create mode 100644 packages/subagent/subagent-sdk/package.json create mode 100644 packages/subagent/subagent-sdk/src/index.ts create mode 100644 packages/subagent/subagent-sdk/src/invariant.ts create mode 100644 packages/subagent/subagent-sdk/src/run.ts create mode 100644 packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts create mode 100644 packages/subagent/subagent-sdk/tsconfig.json create mode 100644 packages/subagent/subagent-subprocess/src/cwd.ts diff --git a/packages/sdk/sdk-client/package.json b/packages/sdk/sdk-client/package.json new file mode 100644 index 0000000000..6f5b962ce0 --- /dev/null +++ b/packages/sdk/sdk-client/package.json @@ -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" + } +} diff --git a/packages/sdk/sdk-client/src/api.ts b/packages/sdk/sdk-client/src/api.ts new file mode 100644 index 0000000000..433a80d8c7 --- /dev/null +++ b/packages/sdk/sdk-client/src/api.ts @@ -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 | 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 { + 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 { + return this.session(options?.sessionId).run(input, options) + } + + /** + * Shut down and reap the runtime subprocess. Idempotent. + * @returns settlement of the complete teardown. + */ + close(): Promise { + return this.client.close() + } + + /** + * `await using` support: {@link close}. + * @returns settlement of the teardown. + */ + [Symbol.asyncDispose](): Promise { + 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): Promise { + 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 '' +} diff --git a/packages/sdk/sdk-client/src/client.ts b/packages/sdk/sdk-client/src/client.ts new file mode 100644 index 0000000000..f802ee479f --- /dev/null +++ b/packages/sdk/sdk-client/src/client.ts @@ -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 { + 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 { + 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 { + 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() + private readonly sessionParents = new Map() + private subscriptionSerial = 0 + private exitCode: number | null | undefined + private spawnError: Error | undefined + private streamsSettled: Promise = Promise.resolve() + private closeTask: Promise | 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 { + 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 { + 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 { + 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((_, 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 { + this.closeTask ??= this.performClose() + return this.closeTask + } + + private async performClose(): Promise { + 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() + 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 { + return Promise.race([ + this.streamsSettled, + new Promise((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 { + 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) +} diff --git a/packages/sdk/sdk-client/src/index.ts b/packages/sdk/sdk-client/src/index.ts new file mode 100644 index 0000000000..6b8ac9a58e --- /dev/null +++ b/packages/sdk/sdk-client/src/index.ts @@ -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' diff --git a/packages/sdk/sdk-client/src/invariant.ts b/packages/sdk/sdk-client/src/invariant.ts new file mode 100644 index 0000000000..db40e4e005 --- /dev/null +++ b/packages/sdk/sdk-client/src/invariant.ts @@ -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 */ diff --git a/packages/sdk/sdk-client/src/types.ts b/packages/sdk/sdk-client/src/types.ts new file mode 100644 index 0000000000..e1d38a7c0b --- /dev/null +++ b/packages/sdk/sdk-client/src/types.ts @@ -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 +} + +/** 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 } diff --git a/packages/sdk/sdk-client/tests/fake-runtime.ts b/packages/sdk/sdk-client/tests/fake-runtime.ts new file mode 100644 index 0000000000..622f2605a7 --- /dev/null +++ b/packages/sdk/sdk-client/tests/fake-runtime.ts @@ -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 | 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 } + 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}` } }) + } +}) diff --git a/packages/sdk/sdk-client/tests/sdk-client.spec.ts b/packages/sdk/sdk-client/tests/sdk-client.spec.ts new file mode 100644 index 0000000000..3feff693f4 --- /dev/null +++ b/packages/sdk/sdk-client/tests/sdk-client.spec.ts @@ -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)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup() +}) + +type LaunchOverrides = Partial[0]> + +/** Launch options running the fake runtime on the current node (type stripping). */ +function fakeLaunch(env: Record = {}, extra: LaunchOverrides = {}) { + return { + command: process.execPath, + args: [fakeRuntime], + env: { ...process.env as Record, ...env }, + ...extra, + } +} + +function harnessWith(env: Record = {}, extra: LaunchOverrides = {}): DeepSeekHarness { + const harness = new DeepSeekHarness({ launch: fakeLaunch(env, extra) }) + cleanups.push(() => harness.close()) + return harness +} + +async function tempDir(prefix: string): Promise { + 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): 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') + }) +}) diff --git a/packages/sdk/sdk-client/tsconfig.json b/packages/sdk/sdk-client/tsconfig.json new file mode 100644 index 0000000000..d884f41f98 --- /dev/null +++ b/packages/sdk/sdk-client/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/sdk/sdk-protocol/package.json b/packages/sdk/sdk-protocol/package.json new file mode 100644 index 0000000000..6eba1c3091 --- /dev/null +++ b/packages/sdk/sdk-protocol/package.json @@ -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" + } +} diff --git a/packages/sdk/sdk-protocol/src/index.ts b/packages/sdk/sdk-protocol/src/index.ts new file mode 100644 index 0000000000..da2d54c170 --- /dev/null +++ b/packages/sdk/sdk-protocol/src/index.ts @@ -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' diff --git a/packages/sdk/sdk-protocol/src/invariant.ts b/packages/sdk/sdk-protocol/src/invariant.ts new file mode 100644 index 0000000000..c1f0b45f2d --- /dev/null +++ b/packages/sdk/sdk-protocol/src/invariant.ts @@ -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 */ diff --git a/packages/ui/jsonrpc/src/transport.ts b/packages/sdk/sdk-protocol/src/transport.ts similarity index 85% rename from packages/ui/jsonrpc/src/transport.ts rename to packages/sdk/sdk-protocol/src/transport.ts index 2e081f6041..a1291f019b 100644 --- a/packages/ui/jsonrpc/src/transport.ts +++ b/packages/sdk/sdk-protocol/src/transport.ts @@ -3,7 +3,7 @@ * `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-jsonrpc/transport + * @module @deepseek-ai/dsh-sdk-protocol/transport */ import { randomUUID } from 'node:crypto' @@ -14,23 +14,38 @@ type JsonRpcId = string | number type RequestHandler = (method: string, params: Record) => Promise type NotificationHandler = (method: string, params: Record) => 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 {@link HarnessSdkServer}. + * 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 on an error response, write failure, or closure. + * @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: Record): Promise + request(method: string, params: object): Promise /** * 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?: Record): void + notify(method: string, params?: object): void } interface PendingRequest { @@ -94,7 +109,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { this.notificationHandler = handler } - request(method: string, params: Record): Promise { + request(method: string, params: object): Promise { const id = `req_${randomUUID().replaceAll('-', '')}` const message = { jsonrpc: '2.0', id, method, params } return new Promise((resolve, reject) => { @@ -108,7 +123,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { }) } - notify(method: string, params?: Record): void { + notify(method: string, params?: object): void { this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params }) } @@ -196,7 +211,11 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { this.pending.delete(id) if (frame.error && typeof frame.error === 'object') { const error = frame.error as Record - pending.reject(new Error(typeof error.message === 'string' ? error.message : 'JSON-RPC error')) + 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) diff --git a/packages/sdk/sdk-protocol/src/types.ts b/packages/sdk/sdk-protocol/src/types.ts new file mode 100644 index 0000000000..73459923ae --- /dev/null +++ b/packages/sdk/sdk-protocol/src/types.ts @@ -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 } +} diff --git a/packages/ui/jsonrpc/tests/transport.spec.ts b/packages/sdk/sdk-protocol/tests/transport.spec.ts similarity index 85% rename from packages/ui/jsonrpc/tests/transport.spec.ts rename to packages/sdk/sdk-protocol/tests/transport.spec.ts index 2f3173c7c9..7eedb12867 100644 --- a/packages/ui/jsonrpc/tests/transport.spec.ts +++ b/packages/sdk/sdk-protocol/tests/transport.spec.ts @@ -1,7 +1,7 @@ import { once } from 'node:events' import { PassThrough, Writable } from 'node:stream' import { describe, expect, it } from 'vitest' -import { JsonRpcLineTransport } from '../src/index.ts' +import { JsonRpcLineTransport, JsonRpcResponseError } from '../src/index.ts' function transportPair() { const aToB = new PassThrough() @@ -41,7 +41,7 @@ describe('JsonRpcLineTransport', () => { b.close() }) - it('reports JSON-RPC request errors from the remote peer', async () => { + 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') @@ -49,12 +49,36 @@ describe('JsonRpcLineTransport', () => { a.start() b.start() - await expect(b.request('explode', {})).rejects.toThrow('handler boom') + 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 () => { diff --git a/packages/sdk/sdk-protocol/tsconfig.json b/packages/sdk/sdk-protocol/tsconfig.json new file mode 100644 index 0000000000..bc0619fb85 --- /dev/null +++ b/packages/sdk/sdk-protocol/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index f8b3cd78c6..9d953d529a 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -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> & Pick -/** - * 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)) } diff --git a/packages/subagent/subagent-sdk/package.json b/packages/subagent/subagent-sdk/package.json new file mode 100644 index 0000000000..59ef79b065 --- /dev/null +++ b/packages/subagent/subagent-sdk/package.json @@ -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" + } +} diff --git a/packages/subagent/subagent-sdk/src/index.ts b/packages/subagent/subagent-sdk/src/index.ts new file mode 100644 index 0000000000..d61fd5c49e --- /dev/null +++ b/packages/subagent/subagent-sdk/src/index.ts @@ -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 + /** 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 = 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> & Pick + +/** + * 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)) +} diff --git a/packages/subagent/subagent-sdk/src/invariant.ts b/packages/subagent/subagent-sdk/src/invariant.ts new file mode 100644 index 0000000000..d47322706a --- /dev/null +++ b/packages/subagent/subagent-sdk/src/invariant.ts @@ -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 */ diff --git a/packages/subagent/subagent-sdk/src/run.ts b/packages/subagent/subagent-sdk/src/run.ts new file mode 100644 index 0000000000..1fedfd20b5 --- /dev/null +++ b/packages/subagent/subagent-sdk/src/run.ts @@ -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 + /** 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 { + 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((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 = (async (): Promise => { + 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 | undefined + return { + id, + localAgent: undefined, + result, + dispose(): Promise { + 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 + }, + } +} diff --git a/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts b/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts new file mode 100644 index 0000000000..2198f02693 --- /dev/null +++ b/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts @@ -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 = {}, config: Partial = {}) { + 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 { + 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) + 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 ` — 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).default).toBeUndefined() + }) +}) diff --git a/packages/subagent/subagent-sdk/tsconfig.json b/packages/subagent/subagent-sdk/tsconfig.json new file mode 100644 index 0000000000..c35a947d23 --- /dev/null +++ b/packages/subagent/subagent-sdk/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/subagent/subagent-subprocess/src/cwd.ts b/packages/subagent/subagent-subprocess/src/cwd.ts new file mode 100644 index 0000000000..c41ce486ef --- /dev/null +++ b/packages/subagent/subagent-subprocess/src/cwd.ts @@ -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) +} diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 47a97bafb6..e54df67398 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -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 diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index 72dd9fe8c3..ff51571ace 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -47,6 +48,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts index 3da953400f..78ac9ef2be 100644 --- a/packages/ui/jsonrpc/src/index.ts +++ b/packages/ui/jsonrpc/src/index.ts @@ -12,11 +12,10 @@ import type { Context } from 'cordis' import type { Readable, Writable } from 'node:stream' import Schema from 'schemastery' +import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' import { HarnessSdkServer } from './server.ts' -import { JsonRpcLineTransport } from './transport.ts' export * from './server.ts' -export * from './transport.ts' export const name = 'jsonrpc' // Only the agent factory is required; initialize reads the optional LLM seam with ctx.get(). diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 5200a2de13..aba7e20e88 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -7,44 +7,23 @@ import type { Context } from 'cordis' import { resolve } from 'node:path' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import type { JsonRpcTransportPeer } from './transport.ts' - -/** 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 (see {@link HarnessSdkServer.initialize} for adapter fallback). */ - 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 -} +import type { + InitializeParams, + InitializeResult, + JsonRpcTransportPeer, + SessionEventNotification, + SessionFinishedNotification, + SessionPromptParams, + SessionPromptResult, + SubagentFinishedNotification, + SubagentStartedNotification, +} from '@deepseek-ai/dsh-sdk-protocol' interface SessionRecord { handle: AgentHandle @@ -97,15 +76,17 @@ export class HarnessSdkServer { rec.lastTurnEnd = event.data.reason } } - this.transport.notify('session.event', { sessionId: String(session.id), event }) + const payload: SessionEventNotification = { sessionId: String(session.id), event } + this.transport.notify('session.event', payload) })) this.disposers.push(ctx.on('session/created', (session) => { const parentSession = session.header.parentSession if (parentSession === undefined) return - this.transport.notify('subagent.started', { + const payload: SubagentStartedNotification = { parentSessionId: String(parentSession), childSessionId: String(session.id), - }) + } + this.transport.notify('subagent.started', payload) })) this.disposers.push(ctx.on('subagent/end', function (this: Scoped, info: SubagentRunEndInfo) { const parent = subagentParentOf(this) @@ -113,7 +94,7 @@ export class HarnessSdkServer { // snapshots the provider's exact run provenance through child disposal; // matching ids or parent lineage alone never establishes locality. if (!info.local) return - transport.notify('subagent.finished', { + const payload: SubagentFinishedNotification = { provider: info.provider, agentId: String(info.id), parentSessionId: String(parent.session.id), @@ -121,7 +102,8 @@ export class HarnessSdkServer { status: successStatus(info.stopReason, serverOptions), stopReason: info.stopReason, ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), - }) + } + transport.notify('subagent.finished', payload) })) } @@ -154,12 +136,12 @@ export class HarnessSdkServer { rec.lastTurnEnd = undefined rec.handle.agent.followup(params.contentBlocks) await rec.handle.agent.whenIdle() - const status = this.finishedStatus(rec.lastTurnEnd) - this.transport.notify('session.finished', { + const payload: SessionFinishedNotification = { sessionId: params.sessionId, - status, + status: this.finishedStatus(rec.lastTurnEnd), reason: rec.lastTurnEnd, - }) + } + this.transport.notify('session.finished', payload) return { accepted: true } } finally { rec.activePrompt = false diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 91d3503263..548de1d116 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -12,17 +12,18 @@ import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' -import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts' +import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' +import { HarnessSdkServer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { notifications: { method: string; params?: Record }[] = [] - async request(method: string, params: Record): Promise { + async request(method: string, params: object): Promise { throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`) } - notify(method: string, params?: Record): void { - this.notifications.push(params === undefined ? { method } : { method, params }) + notify(method: string, params?: object): void { + this.notifications.push(params === undefined ? { method } : { method, params: params as Record }) } } diff --git a/packages/ui/jsonrpc/tsconfig.json b/packages/ui/jsonrpc/tsconfig.json index 14a70d8eaa..00ac7000e6 100644 --- a/packages/ui/jsonrpc/tsconfig.json +++ b/packages/ui/jsonrpc/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/session" }, + { + "path": "../../sdk/sdk-protocol" + }, { "path": "../../subagent/subagent" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc4b87c934..8783218d1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3012,6 +3012,45 @@ importers: specifier: ^4.22.4 version: 4.22.4 + packages/sdk/sdk-client: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../sdk-protocol + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent-subprocess': + specifier: workspace:^ + version: link:../../subagent/subagent-subprocess + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/sdk/sdk-protocol: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/sdk/telemetry: dependencies: yaml: @@ -3685,6 +3724,46 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/subagent/subagent-sdk: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-sdk-client': + specifier: workspace:^ + version: link:../../sdk/sdk-client + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/sdk-protocol + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subagent-subprocess': + specifier: workspace:^ + version: link:../subagent-subprocess + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/subagent/subagent-spawn: dependencies: schemastery: @@ -4076,6 +4155,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/sdk-protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -4743,6 +4825,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../packages/core/scope + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../packages/sdk/sdk-protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index abf943793d..4a22d484fe 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jsonrpc": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", diff --git a/tsconfig.host.json b/tsconfig.host.json index 2c368e53ed..b1c2cf6f9f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -142,6 +142,7 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, + { "path": "./packages/subagent/subagent-sdk" }, { "path": "./packages/tasks/tasks" }, { "path": "./packages/tasks/tasks-local" }, { "path": "./packages/tasks/tool-tasks" }, @@ -159,7 +160,9 @@ { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/webserver" }, + { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, + { "path": "./packages/sdk/sdk-protocol" }, { "path": "./packages/sdk/scripts" }, { "path": "./packages/sdk/create-sdk" }, { "path": "./packages/sdk/telemetry" }, From 3e89a73c71b5962cacbdbb9227cb9254821c83b2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:59:46 +0800 Subject: [PATCH 02/13] test(subagent-sdk): keyless Loader-composition e2e across the SDK wire A test-only cordis.yml under examples/jsonrpc-agent boots the headless app through the Loader; a scripted model delegates once to the SDK backend, whose child is a COMPLETE second harness runtime (own cordis.yml, jsonrpc serving surface, scripted cwd-echo model, own JSONL persistence). Asserts the parent tool result AND the child's own persisted transcript both carry the parent session's workspace cwd; child launch resolves through the shared example-launch resolver so src/lib modes both hold. --- .../subagent/subagent-sdk/child-mock-llm.ts | 32 ++++++ .../subagent/subagent-sdk/child.cordis.yml | 36 +++++++ .../fixtures/subagent/subagent-sdk/cordis.yml | 42 ++++++++ .../fixtures/subagent/subagent-sdk/driver.ts | 15 +++ .../subagent-sdk/mock-delegating-llm.ts | 48 +++++++++ examples/package.json | 13 +-- knip.json | 23 +++- .../tests/loader-composition.e2e.ts | 101 ++++++++++++++++++ 8 files changed, 300 insertions(+), 10 deletions(-) create mode 100644 examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts create mode 100644 examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml create mode 100644 examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml create mode 100644 examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts create mode 100644 examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts create mode 100644 packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts new file mode 100644 index 0000000000..30f480064e --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts @@ -0,0 +1,32 @@ +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' + +/** + * Scripted model for the CHILD runtime: answers every request with its own + * process cwd, so the driving e2e can prove the parent session's workspace + * reached the child process across the SDK wire. `options` carries the + * request; the reply depends only on process state. + */ +class CwdEchoAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + void options + const reply = `child cwd: ${process.cwd()}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 3, outputTokens: reply.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'child-mock-llm' +export const inject = ['llm'] + +/** + * Register the cwd-echo adapter under the `mock` provider. + * @param ctx - the plugin context supplying `ctx.llm`. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new CwdEchoAdapter()) +} diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml new file mode 100644 index 0000000000..ad77c03c19 --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml @@ -0,0 +1,36 @@ +# The CHILD runtime for the SDK subagent composition test: a complete +# stdio JSON-RPC harness whose scripted model echoes its process cwd. The +# parent's subagent-sdk backend spawns this composition per run; stdout is +# reserved for JSON-RPC frames. +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +- id: child-mock-llm + name: './child-mock-llm.ts' + +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + persona: 'Echo where you run.' + workspaceContext: false + skills: + enabled: false + toolBash: + enableRunInBackground: false + toolTasks: false + +# The child persists its own session log beside the parent's (distinct root), +# so the driving e2e can inspect both transcripts after the run. +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.child-sessions' + compression: none + +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml new file mode 100644 index 0000000000..a1e95fcddd --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml @@ -0,0 +1,42 @@ +# Test-only composition: the SDK subagent backend on the real Loader/app path. +# The scripted model delegates once; the child — a COMPLETE second harness +# runtime speaking stdio JSON-RPC — echoes its process cwd, so parent-session +# cwd inheritance is asserted keylessly end to end across the SDK wire. +# `cwd` is deliberately omitted — the inheritance branch under test. The child +# launch is machine-absolute, so the driving e2e supplies it via +# DSH_TEST_CHILD_COMMAND / DSH_TEST_CHILD_ARGS / DSH_TEST_CHILD_ENV (resolved +# through the shared example-launch resolver, per testing policy). +- id: mock-llm + name: './mock-delegating-llm.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-sdk + name: '@deepseek-ai/dsh-subagent-sdk' + config: + providerName: sdk + command: !!js process.env.DSH_TEST_CHILD_COMMAND + args: !!js JSON.parse(process.env.DSH_TEST_CHILD_ARGS ?? '[]') + provider: mock + model: mock-echo + env: !!js JSON.parse(process.env.DSH_TEST_CHILD_ENV ?? '{}') + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: sdk + toolName: subagent + # The SDK backend advertises no depthLimit: the child harness owns its own + # recursion budget, so the local numeric default cannot apply here. + maxDepth: 'provider-managed' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: mock + model: mock-delegate + persona: 'Test SDK subagent cwd inheritance.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts new file mode 100644 index 0000000000..34412c1839 --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +/** Test driver: one delegation turn through a headless Loader composition. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('sdk-subagent cwd driver requires a config path') + +const ctx = await boot('sdk-subagent-cwd-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'delegate' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts new file mode 100644 index 0000000000..e5ce753fd8 --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts @@ -0,0 +1,48 @@ +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' + +/** + * Test adapter for the `mock-delegate` model: the first request calls the + * `subagent` tool once, and the follow-up streams the tool result text back + * verbatim — so the SDK child runtime's answer (the scripted child model's + * cwd echo) reaches the parent session log for the driving e2e to assert. + */ +class MockDelegatingAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const toolResultText = options.messages.at(-1)?.content + .filter(block => block.type === 'tool-result') + .flatMap(block => block.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') ?? '' + + if (toolResultText.length === 0) { + const args = JSON.stringify({ description: 'cwd probe', prompt: 'report your workspace' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: CallId('call-delegate'), name: 'subagent', argumentsDelta: args } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-delegate'), name: 'subagent', arguments: args } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + + const reply = `child reported:\n${toolResultText}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'mock-llm' +export const inject = ['llm'] + +/** + * Register the delegating mock adapter under the `mock` provider. + * @param ctx - the plugin context supplying `ctx.llm`. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new MockDelegatingAdapter()) +} diff --git a/examples/package.json b/examples/package.json index 8a81d399b8..279e211fe2 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", @@ -30,24 +30,24 @@ "@deepseek-ai/dsh-llm-replay": "workspace:*", "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", - "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", + "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", - "@deepseek-ai/dsh-tui-demo": "workspace:*", - "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", + "@deepseek-ai/dsh-subagent-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", @@ -57,15 +57,16 @@ "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", - "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-lsp": "workspace:*", + "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-tui-demo": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-local": "workspace:*", diff --git a/knip.json b/knip.json index 110abb3a2b..f49fc986d5 100644 --- a/knip.json +++ b/knip.json @@ -36,6 +36,9 @@ "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" ], @@ -262,8 +265,14 @@ ] }, "packages/session-query/session-query-sqlite": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/code-runtime/code-runtime-worker": { "entry": [ @@ -316,8 +325,14 @@ ] }, "packages/session-persistence/session-checkpoint-policy": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/util/paths": { "entry": [ diff --git a/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts b/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..4fe435b311 --- /dev/null +++ b/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts @@ -0,0 +1,101 @@ +/** + * Keyless REAL-composition coverage for parent-session cwd inheritance across + * the SDK wire: a test-only cordis.yml boots the headless app through the + * Loader with the SDK backend's `cwd` omitted, a scripted model delegates + * once, and the child — a COMPLETE second harness runtime booted from its own + * cordis.yml and driven over stdio JSON-RPC — echoes where it actually ran. + * Both the parent's tool result and the child's own persisted session log + * must carry the parent session's cwd. Mock-only composition, so only this + * keyless tier applies (the with-key tier lives in subagent-sdk.e2e.ts). + */ + +import { realpathSync } from 'node:fs' +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +const fixtureDir = new URL('../../../../examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/', import.meta.url) +const driver = fileURLToPath(new URL('driver.ts', fixtureDir)) +const configPath = fileURLToPath(new URL('cordis.yml', fixtureDir)) +const childConfigPath = fileURLToPath(new URL('child.cordis.yml', fixtureDir)) +const runtimeBin = fileURLToPath(new URL('../../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +async function sessionEvents(log: string): Promise { + const lines = (await readFile(log, 'utf8')).trimEnd().split('\n') + return lines.slice(1).map(line => JSON.parse(line) as SessionEvent) +} + +describe('SDK subagent cwd inheritance through a real cordis.yml', () => { + it('runs the child runtime in the parent session workspace', async () => { + // The child launch honors the same src/lib mode as the driving harness, + // per the shared example-launch resolver (testing policy forbids + // hand-written `--import tsx` argv for example subprocesses). + const childLaunch = resolveExampleLaunch({ + srcBin: runtimeBin, + configArgs: [childConfigPath], + tsconfigPath: repoTsconfig, + }) + + let events: SessionEvent[] = [] + let childEvents: SessionEvent[] = [] + let workspace = '' + const { stderr } = await runLoaderSmoke({ + label: 'sdk-subagent cwd composition smoke', + tempDirPrefix: 'sdk-subagent-cwd-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { + DSH_TEST_CHILD_COMMAND: childLaunch.command, + DSH_TEST_CHILD_ARGS: JSON.stringify(childLaunch.args), + DSH_TEST_CHILD_ENV: JSON.stringify({ + ...Object.fromEntries(Object.entries(childLaunch.env).filter(([, value]) => value !== undefined)), + }), + }, + inspect: async (cwd) => { + // The child reports realpaths; canonicalize the temp workspace to match. + workspace = realpathSync(cwd) + const parentLogs = await jsonlFiles(join(cwd, '.sessions')) + expect(parentLogs).toHaveLength(1) + events = await sessionEvents(parentLogs[0] as string) + // The child runtime persisted its own transcript in ITS cwd — which + // must be the parent session's workspace for the inheritance to hold. + const childLogs = await jsonlFiles(join(cwd, '.child-sessions')) + expect(childLogs).toHaveLength(1) + childEvents = await sessionEvents(childLogs[0] as string) + }, + }) + expect(stderr).not.toContain('UNHANDLED') + + // The parent's tool result carries the child model's echo of its real + // process.cwd() — the parent session's workspace, never the harness + // process's launch directory. + const results = events.filter(event => event.type === 'tool/result') + expect(results).toHaveLength(1) + const resultText = results[0]!.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + expect(resultText).toBe(`child cwd: ${workspace}`) + + // The child ran a real turn of its own: user message in, assistant out. + expect(childEvents.some(event => event.type === 'user/message')).toBe(true) + const childAnswers = childEvents.filter(event => event.type === 'assistant/message') + expect(childAnswers.length).toBeGreaterThan(0) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) From 34aabc41830eb09d006471b2eb99d2fa638ca387 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:37:23 +0800 Subject: [PATCH 03/13] test(sdk): snapshot suite over the SDK path; docs for the SDK stack - examples/jsonrpc-agent gains its first snapshot suite (sdk.snapshot.ts): the real dsh-jsonrpc-agent runtime driven through the real dsh-sdk-client, keyless llm-replay behind a new cordis.snapshot.yml overlay; three recorded scenarios (text turn, bash tool, spawn subagent) pin the notification stream, the SDK turn result, and the persisted parent+child session logs. - Bilingual READMEs for dsh-sdk-protocol / dsh-sdk-client / dsh-subagent-sdk; sdk/ and subagent/ group tables extended; dsh-jsonrpc README points at the extracted protocol package; Agent Note (en+zh) owns the decision. - The proposed make-jsonrpc-directional note is updated for the transport's new home and its second (client) consumer. - Model Experience sentence allowlist entries for the two client-side packages; module graph + config catalog regenerated; i18n pairings recorded. doc-sync passes 24/24. --- ...ipt-sdk-and-sdk-subagent-backend.i18n.yaml | 6 + ...typescript-sdk-and-sdk-subagent-backend.md | 47 +++ ...escript-sdk-and-sdk-subagent-backend.zh.md | 47 +++ ...6-07-19-make-jsonrpc-directional.i18n.yaml | 4 +- .../2026-07-19-make-jsonrpc-directional.md | 8 +- .../2026-07-19-make-jsonrpc-directional.zh.md | 8 +- docs/config-catalog.md | 55 +++- docs/module-graph.md | 39 ++- examples/jsonrpc-agent/cordis.snapshot.yml | 28 ++ examples/jsonrpc-agent/cordis.yml | 2 + examples/jsonrpc-agent/tests/sdk.snapshot.ts | 294 ++++++++++++++++++ .../bash-tool/notifications.expected.jsonl | 97 ++++++ .../snapshots/bash-tool/result.expected.json | 1 + .../tests/snapshots/bash-tool/session.jsonl | 97 ++++++ .../notifications.expected.jsonl | 175 +++++++++++ .../subagent-spawn/result.expected.json | 1 + .../snapshots/subagent-spawn/session.1.jsonl | 34 ++ .../snapshots/subagent-spawn/session.jsonl | 140 +++++++++ .../text-turn/notifications.expected.jsonl | 38 +++ .../snapshots/text-turn/result.expected.json | 1 + .../tests/snapshots/text-turn/session.jsonl | 38 +++ packages/sdk/README.i18n.yaml | 4 +- packages/sdk/README.md | 6 +- packages/sdk/README.zh.md | 6 +- packages/sdk/sdk-client/README.i18n.yaml | 6 + packages/sdk/sdk-client/README.md | 50 +++ packages/sdk/sdk-client/README.zh.md | 50 +++ packages/sdk/sdk-client/src/client.ts | 16 +- packages/sdk/sdk-protocol/README.i18n.yaml | 6 + packages/sdk/sdk-protocol/README.md | 39 +++ packages/sdk/sdk-protocol/README.zh.md | 39 +++ packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 5 +- packages/subagent/README.zh.md | 5 +- .../subagent/subagent-sdk/README.i18n.yaml | 6 + packages/subagent/subagent-sdk/README.md | 97 ++++++ packages/subagent/subagent-sdk/README.zh.md | 97 ++++++ packages/ui/jsonrpc/README.i18n.yaml | 4 +- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/README.zh.md | 2 +- pnpm-lock.yaml | 3 + .../verify-package-readme-model-experience.ts | 2 + 42 files changed, 1570 insertions(+), 39 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md create mode 100644 examples/jsonrpc-agent/cordis.snapshot.yml create mode 100644 examples/jsonrpc-agent/tests/sdk.snapshot.ts create mode 100644 examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl create mode 100644 examples/jsonrpc-agent/tests/snapshots/bash-tool/result.expected.json create mode 100644 examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl create mode 100644 examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl create mode 100644 examples/jsonrpc-agent/tests/snapshots/subagent-spawn/result.expected.json create mode 100644 examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl create mode 100644 examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl create mode 100644 examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl create mode 100644 examples/jsonrpc-agent/tests/snapshots/text-turn/result.expected.json create mode 100644 examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl create mode 100644 packages/sdk/sdk-client/README.i18n.yaml create mode 100644 packages/sdk/sdk-client/README.md create mode 100644 packages/sdk/sdk-client/README.zh.md create mode 100644 packages/sdk/sdk-protocol/README.i18n.yaml create mode 100644 packages/sdk/sdk-protocol/README.md create mode 100644 packages/sdk/sdk-protocol/README.zh.md create mode 100644 packages/subagent/subagent-sdk/README.i18n.yaml create mode 100644 packages/subagent/subagent-sdk/README.md create mode 100644 packages/subagent/subagent-sdk/README.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml new file mode 100644 index 0000000000..730b72dba0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: b0a5461b00c76e06a75d9ac4bd8cde9778a26ce8 +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 856db82f6d47ee686216c8ef5f91048493a90801 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md new file mode 100644 index 0000000000..b0a5461b00 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md @@ -0,0 +1,47 @@ +# Agent Note: TypeScript SDK client and the SDK subagent backend + +Status: implemented + +English | [中文](2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md) + +## Problem + +The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-jsonrpc`, the [single-exe Agent Note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)) had exactly one client: the Python SDK. TypeScript consumers wanting the same drive-a-harness-as-a-subprocess capability — repo tests, automation, and above all a subagent backend whose child is a *complete harness runtime* rather than a generic ACP agent — had nothing to import: the request/notification payload shapes existed only as anonymous object literals inside the server, and the transport class lived inside the server plugin package. + +## Decision + +Three packages, layered exactly like the existing Python stack, plus one seam registration: + +- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/sdk-protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). +- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/sdk-client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Session-tree scoping from `subagent.started` lineage edges is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `buildChildEnv` is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown reuses `disposeChildProcess` instead of hand-rolled terminate/kill. +- **`@deepseek-ai/dsh-subagent-sdk`** (`packages/subagent/subagent-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`. +- **`dsh-subagent-subprocess` grows a third shared concern**: child cwd resolution (`assertUsableCwd`/`validateConfiguredCwd`/`resolveChildCwd`), extracted from `subagent-acp` when the SDK backend needed the identical config-override-else-parent-session-cwd policy, prefix-parameterized for diagnostics. + +`dsh-jsonrpc` keeps serving unchanged (the wire is byte-identical); `dsh-jsonrpc-agent-pkg` (the Python runtime closure) gains the `dsh-sdk-protocol` dependency line. + +## Testing + +Four tiers, per [testing policy](../../../../docs/testing.md): + +- **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-sdk` drives the same fake through the real provider. 100% per-file coverage on all three packages. +- **Keyless Loader composition** — `subagent-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/`) where the child is a REAL second harness runtime with its own cordis.yml; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd. The child launch resolves through `resolveExampleLaunch`, so src/lib modes both hold. +- **Keyless snapshot** — `examples/jsonrpc-agent/tests/sdk.snapshot.ts` is the jsonrpc example's first snapshot suite: the real `dsh-jsonrpc-agent` runtime driven through the real `dsh-sdk-client`, replaying recorded fixtures via `llm-replay` behind the new `cordis.snapshot.yml` overlay (passed explicitly through `DSH_CORDIS_CONFIG`; the jsonrpc bin performs no snapshot config swap of its own). Three scenarios — text turn, bash tool, spawn subagent — each pinning the normalized notification stream, the SDK turn result, and the persisted parent+child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side. +- **With-key e2e** — the snapshot suite's `DSH_SNAPSHOT=record` mode is the live-API path (it produced the committed fixtures); the composition e2e needs no key by design. + +## Alternatives considered + +**Import wire types from `dsh-jsonrpc` instead of extracting a protocol package.** Makes every SDK consumer (including `subagent-sdk`, which must not serve JSON-RPC) depend on the server plugin and its `dsh-agent`/`dsh-llm-deepseek` peer set, and leaves the notification payloads anonymous. The capability-seam rule (interface/implementation/consumer as separate packages) already names this shape; the transport is genuinely two-sided. + +**Have `subagent-sdk` speak raw JSON-RPC without the client SDK.** Duplicates the request/notification pairing, subscription fan-out, timeout, and teardown logic the SDK exists to own; the user's ask was explicitly a backend that *uses* the SDK, and the layering earns its keep by making the backend ~200 lines of policy over a reusable client. + +**Fold the SDK backend into `subagent-acp` with a transport switch.** The two backends share the subprocess lifecycle but nothing about the wire (ACP SDK connection vs harness JSON-RPC), the child contract (any ACP agent vs a harness runtime), or the result extraction (`agent_message_chunk` accumulation vs session-event reading). A config discriminant would bury two protocols in one package; the shared parts are exactly what `subagent-subprocess` already holds, so that library grew instead. + +**Give the TS SDK bundled-runtime resolution parity with Python.** Python's carrier resolution exists to ship wheels to users without Node. A TypeScript consumer definitionally has Node and (in-repo) the workspace; inventing a distribution story with no consumer violates the require-current-need rule. Deferred until a real npm-distribution consumer appears. + +**Reuse `dsh-acp-snapshot`'s `runScenario` for the SDK snapshots.** That harness speaks ACP (`ClientSideConnection`, `InputStep` scripts). The SDK suite's whole point is to drive the *SDK client* as the entry surface; it reuses the normalize/refresh library layer (`normalizeSessionLog`, `refreshFixtureReplacements`, …) and leaves the ACP driver alone. + +## Consequences + +**Bought**: the SDK runtime protocol now has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors and structured turn reasons; the subagent seam gains a harness-native out-of-process backend whose children are full peers (own config, persistence, tools) — the recursive-composition story the seam note anticipated; the jsonrpc example finally has snapshot coverage, through the SDK path itself. + +**Paid**: a third package in the `sdk/` group and a fourth subagent backend to keep current; the SDK backend boots a complete plugin tree per child (heavier per-run than an ACP child; pooling remains future work, same as ACP); the wire still has no cancel method, so both the SDK's `RequestTimeoutError` and the backend's dispose settle locally while the server-side turn runs on until process teardown; fixtures for the snapshot suite were recorded against `deepseek-v4-flash` and re-record on model-behavior drift like every other recorded corpus. diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md new file mode 100644 index 0000000000..856db82f6d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -0,0 +1,47 @@ +# Agent Note: TypeScript SDK 客户端与 SDK subagent 后端 + +Status: implemented + +[English](2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) | 中文 + +## Problem + +stdio JSON-RPC 服务表面(`@deepseek-ai/dsh-jsonrpc`,见[单文件可执行 Agent Note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md))当时只有一个客户端:Python SDK。想要同样"把 harness 作为子进程驱动"能力的 TypeScript 消费者——仓库测试、自动化,尤其是一个其子进程是*完整 harness 运行时*(而非通用 ACP 代理)的 subagent 后端——无物可导入:请求/通知载荷形状只以匿名对象字面量存在于服务器内部,传输类也躺在服务器插件包里。 + +## Decision + +三个包,分层与既有 Python 栈完全一致,外加一个接缝注册: + +- **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/sdk-protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 +- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/sdk-client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(生成、分帧、通知扇出、有类型的错误表面、经共享处置阶梯关闭至静止)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费者的发行问题);`env` 整体替换而非合并(凭据策略归调用方;`buildChildEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除复用 `disposeChildProcess` 而不是手写 terminate/kill。 +- **`@deepseek-ai/dsh-subagent-sdk`**(`packages/subagent/subagent-sdk/`)—— 第二个进程外 `SubagentProvider`,以 `subagent-acp` 的同胞结构组织:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样的经 `onError` 汇把结果压平为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未跑回合就尘埃落定的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 +- **`dsh-subagent-subprocess` 增长出第三个共享关注点**:子进程 cwd 解析(`assertUsableCwd`/`validateConfiguredCwd`/`resolveChildCwd`),在 SDK 后端需要与 `subagent-acp` 完全相同的"配置覆盖、否则父会话 cwd"策略时从后者提取,以前缀参数化诊断信息。 + +`dsh-jsonrpc` 的服务不变(线上字节完全一致);`dsh-jsonrpc-agent-pkg`(Python 运行时闭包)增加 `dsh-sdk-protocol` 一行依赖。 + +## Testing + +四层,依[测试政策](../../../../docs/testing.md): + +- **免密钥单元** —— `sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-sdk` 经真实 provider 驱动同一伪运行时。三个包全部 100% 逐文件覆盖。 +- **免密钥 Loader 组合** —— `subagent-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/`),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml;断言父工具结果与子进程自己持久化的转录都携带父会话 cwd。子启动经 `resolveExampleLaunch` 解析,src/lib 两种模式都成立。 +- **免密钥快照** —— `examples/jsonrpc-agent/tests/sdk.snapshot.ts` 是 jsonrpc 示例的第一个快照套件:真实 `dsh-jsonrpc-agent` 运行时经真实 `dsh-sdk-client` 驱动,在新的 `cordis.snapshot.yml` 覆盖层后经 `llm-replay` 回放已录制夹具(经 `DSH_CORDIS_CONFIG` 显式传入;jsonrpc bin 自身不做快照配置切换)。三个场景——文本回合、bash 工具、spawn 子代理——各自钉住规范化通知流、SDK 回合结果与持久化的父+子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。 +- **带密钥 e2e** —— 快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交夹具由它产出);组合 e2e 设计上无需密钥。 + +## Alternatives considered + +**从 `dsh-jsonrpc` 导入线类型而不是提取协议包。** 会让每个 SDK 消费者(包括绝不能提供 JSON-RPC 服务的 `subagent-sdk`)依赖服务器插件及其 `dsh-agent`/`dsh-llm-deepseek` peer 集合,且通知载荷仍然匿名。能力接缝规则(接口/实现/消费者三包分立)已经点名了这种形态;这个传输是货真价实的双边物。 + +**让 `subagent-sdk` 直说裸 JSON-RPC、绕开客户端 SDK。** 会复制 SDK 存在意义所在的请求/通知配对、订阅扇出、超时与拆除逻辑;用户的要求明确是一个*使用* SDK 的后端,分层的回报是后端成为可复用客户端之上约 200 行的纯策略。 + +**把 SDK 后端折进 `subagent-acp`、用传输开关区分。** 两个后端共享子进程生命周期,但线协议(ACP SDK 连接 vs harness JSON-RPC)、子进程契约(任意 ACP 代理 vs harness 运行时)、结果提取(`agent_message_chunk` 累积 vs 会话事件读取)毫无共享。配置判别子会把两个协议埋进一个包;共享部分恰好就是 `subagent-subprocess` 已持有的,于是让那个库生长。 + +**给 TS SDK 与 Python 对等的捆绑运行时解析。** Python 的载体解析是为了给没有 Node 的用户发 wheel。TypeScript 消费者定义上就有 Node 且(仓库内)有工作区;为不存在的消费者发明发行故事违反"要求当前需求"规则。推迟到真实 npm 发行消费者出现。 + +**复用 `dsh-acp-snapshot` 的 `runScenario` 做 SDK 快照。** 那个 harness 说 ACP(`ClientSideConnection`、`InputStep` 脚本)。SDK 套件的全部意义就是以 *SDK 客户端*为入口表面;它复用 normalize/refresh 库层(`normalizeSessionLog`、`refreshFixtureReplacements`……),不动 ACP 驱动器。 + +## Consequences + +**买到**:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费者获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化回合原因;subagent 接缝获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是接缝 Note 预期的递归组合故事;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。 + +**付出**:`sdk/` 组多了第三个包、subagent 多了第四个要保持最新的后端;SDK 后端每个子进程启动完整插件树(单次成本高于 ACP 子进程;池化与 ACP 一样留作未来工作);线上仍无取消方法,SDK 的 `RequestTimeoutError` 与后端的 dispose 都只在本地定格、服务器侧回合继续跑到进程拆除为止;快照夹具录制于 `deepseek-v4-flash`,与其他录制语料一样随模型行为漂移而重录。 diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml index 50dfd13aab..a50ddc4f12 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-make-jsonrpc-directional.md: 74de3c960a415a9a2601e57ec75f244ca753193d -2026-07-19-make-jsonrpc-directional.zh.md: 76228ba56cfbd4fb86f39d0d0873d49edb13309b +2026-07-19-make-jsonrpc-directional.md: 910b4988aca34dec499b2e34cb2a42042c81b0cb +2026-07-19-make-jsonrpc-directional.zh.md: acc1b433e14c65da2ed37dff7a5fe181ebee36ad diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md index 74de3c960a..910b4988ac 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md @@ -6,7 +6,7 @@ English | [中文](2026-07-19-make-jsonrpc-directional.zh.md) ## Problem -The JSON-RPC bridge models both endpoints as symmetric peers although the shipped protocol is directional. The TypeScript server accepts requests and emits responses or notifications, but its transport also implements unused outbound requests and inbound notification dispatch. The Python SDK sends requests and receives responses or notifications, but it also queues unused inbound server requests and exposes response helpers. +The JSON-RPC bridge models both endpoints as symmetric peers although the shipped protocol is directional. The shared transport (now `dsh-sdk-protocol`, used by the server and by the TypeScript SDK client, which exercises the outbound-request/inbound-notification direction) still implements two halves no endpoint uses: server-originated requests and client-originated notifications. The Python SDK sends requests and receives responses or notifications, but it also queues unused inbound server requests and exposes response helpers. `session/prompt` also reports one settled turn through two protocol shapes. The server emits `session.finished` and then returns the constant `{ accepted: true }`; the Python SDK discards that response and waits for the notification to recover the status. Because the response is written only after the handler returns, the notification necessarily precedes the constant response on the same stream. @@ -14,17 +14,17 @@ The unused halves add pending-request maps, generated IDs, request queues, close ## Proposal -Specialize each endpoint to its actual role. The TypeScript transport will retain inbound requests, outbound responses, and outbound notifications. The Python client will retain outbound requests and inbound responses or notifications. Delete the opposite-direction request machinery from each side. +Specialize each endpoint to its actual role. The server keeps inbound requests, outbound responses, and outbound notifications; the TypeScript and Python clients keep outbound requests and inbound responses or notifications. Delete the direction no endpoint uses — server-originated requests and client-originated notifications. Return the settled outcome directly from `session/prompt` as `{ status, reason }` after `agent.whenIdle()`. Delete `session.finished`, the constant acceptance response, and the Python post-response completion loop. `session.event` and subagent notifications still stream before the response, and durable session events remain the source for final-response reconstruction. ## Implementation plan 1. In `packages/ui/jsonrpc/src/server.ts`, replace `SessionPromptResult.accepted` with `status: 'ok' | 'error' | 'aborted'` and the captured `TurnEndReason`. `HarnessSdkServer.prompt()` will return `completed` as `ok`, `aborted` as `aborted`, and every other current or merge-extensible reason as `error`; reaching idle without a `turn/end` remains an invariant error. Remove only `session.finished`, leaving `session.event`, `subagent.started`, and `subagent.finished` unchanged. -2. In `packages/ui/jsonrpc/src/transport.ts`, replace `JsonRpcTransportPeer` with a server-side notification surface and retain `onRequest()`, `notify()`, `start()`, `flush()`, and `close()`. Remove generated request IDs, the pending-response map, outbound `request()`, inbound response and notification dispatch, and close-time pending-request rejection. Incoming response- and notification-shaped frames will be ignored, while request result, method-not-found, and handler-error responses retain their current behavior and remain ordered after notifications emitted by the awaited handler. +2. In `packages/sdk/sdk-protocol/src/transport.ts`, narrow the shared class to the directions with consumers — inbound requests/outbound responses (the server) and outbound requests/inbound responses plus inbound notifications (the TypeScript SDK client) — removing only server-originated `request()` use and client-originated notification dispatch, or split the class into a server-side and client-side transport. Request result, method-not-found, and handler-error responses retain their current behavior and remain ordered after notifications emitted by the awaited handler. 3. In `python/sdk/src/deepseek_harness/client.py`, `models.py`, and `__init__.py`, remove `IncomingRequest`, `_requests`, `notify()`, `next_request()`, `respond()`, and `respond_error()`. Add a public validated `SessionPromptResponse` carrying status and reason, return it from `session_prompt()`, and keep an explicit reader guard that ignores unexpected server-request frames instead of allowing them to match a response waiter. 4. In `python/sdk/src/deepseek_harness/api.py`, build `TurnResult.status` and a new `TurnResult.reason` from `SessionPromptResponse`, then delete the `session.finished` branch and second completion loop. Keep the subscription open during the request and preserve `_request_raw()`'s final notification drain so the last `turn/end` event and any subagent notification written before the response are collected before `Session.run()` reconstructs the final assistant message. -5. Replace the symmetric transport-pair cases in `packages/ui/jsonrpc/tests/transport.spec.ts` with raw client-input/server-output coverage, and update `server.spec.ts`, `plugin-apply.spec.ts`, and `built-scope-carrier.e2e.ts` for direct outcomes, ordering, overlap, shutdown, and the narrowed fake. Update `python/sdk/tests/test_client.py` for response-based settlement, unexpected-request-frame handling, callback and concurrency behavior, and the removed public helpers. Update the JSON-RPC and bilingual Python SDK READMEs, export JSDoc and declarations, `scripts/smoke-python-runtime.py`, and the Python single-executable snapshot. +5. Replace the symmetric transport-pair cases in `packages/sdk/sdk-protocol/tests/transport.spec.ts` with per-direction coverage, and update `server.spec.ts`, `plugin-apply.spec.ts`, and `built-scope-carrier.e2e.ts` for direct outcomes, ordering, overlap, shutdown, and the narrowed fake; update the TypeScript SDK client (`packages/sdk/sdk-client`) and its suites for response-based settlement. Update `python/sdk/tests/test_client.py` for response-based settlement, unexpected-request-frame handling, callback and concurrency behavior, and the removed public helpers. Update the JSON-RPC and bilingual Python SDK READMEs, export JSDoc and declarations, `scripts/smoke-python-runtime.py`, and the Python single-executable snapshot. ## Alternatives considered diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md index 76228ba56c..acc1b433e1 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协议具有固定方向。TypeScript 服务端接收请求并发出响应或通知,其传输层却还实现了未使用的出站请求和入站通知分发。Python SDK 发送请求并接收响应或通知,却还会把未使用的服务端入站请求放入队列,并公开响应辅助方法。 +JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协议具有固定方向。共享传输层(现为 `dsh-sdk-protocol`,由服务端与 TypeScript SDK 客户端共用,后者行使出站请求/入站通知方向)仍实现着没有任何端点使用的两个半边:服务端发起的请求与客户端发起的通知。Python SDK 发送请求并接收响应或通知,却还会把未使用的服务端入站请求放入队列,并公开响应辅助方法。 `session/prompt` 还会用两种协议结构报告同一个已结束轮次。服务端先发出 `session.finished`,再返回常量 `{ accepted: true }`;Python SDK 丢弃该响应,转而等待通知以取得状态。响应只有在处理函数返回后才会写入,因此在同一条有序流上,通知必然先于这个常量响应。 @@ -14,17 +14,17 @@ JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协 ## 提案 -按实际角色收窄两个端点。TypeScript 传输层只保留入站请求、出站响应和出站通知。Python 客户端只保留出站请求以及入站响应或通知。删除两侧与实际方向相反的请求机制。 +按实际角色收窄两个端点。服务端保留入站请求、出站响应和出站通知;TypeScript 与 Python 客户端保留出站请求以及入站响应或通知。删除没有任何端点使用的方向——服务端发起的请求与客户端发起的通知。 在 `agent.whenIdle()` 完成后,由 `session/prompt` 直接返回 `{ status, reason }` 作为轮次结果。删除 `session.finished`、常量接纳响应以及 Python 中响应后的完成等待循环。`session.event` 与 subagent 通知仍在响应前流式发出,持久会话事件仍是最终响应重建的真源。 ## 实施计划 1. 在 `packages/ui/jsonrpc/src/server.ts` 中,用 `status: 'ok' | 'error' | 'aborted'` 和捕获的 `TurnEndReason` 替换 `SessionPromptResult.accepted`。`HarnessSdkServer.prompt()` 把 `completed` 映射为 `ok`,把 `aborted` 映射为 `aborted`,把其他当前或可合并扩展的原因映射为 `error`;进入空闲状态却没有 `turn/end` 仍视为不变量错误。只删除 `session.finished`,保持 `session.event`、`subagent.started` 和 `subagent.finished` 不变。 -2. 在 `packages/ui/jsonrpc/src/transport.ts` 中,用服务端通知接口替换 `JsonRpcTransportPeer`,并保留 `onRequest()`、`notify()`、`start()`、`flush()` 和 `close()`。删除生成的请求 ID、待处理响应表、出站 `request()`、入站响应与通知分发,以及关闭时对待处理请求的拒绝逻辑。入站响应结构和通知结构将被忽略;请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。 +2. 在 `packages/sdk/sdk-protocol/src/transport.ts` 中,把共享类收窄到有消费者的方向——入站请求/出站响应(服务端)与出站请求/入站响应加入站通知(TypeScript SDK 客户端)——只删除服务端发起的 `request()` 用法与客户端发起的通知分发,或把该类拆分为服务端与客户端两个传输。请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。 3. 在 `python/sdk/src/deepseek_harness/client.py`、`models.py` 和 `__init__.py` 中,删除 `IncomingRequest`、`_requests`、`notify()`、`next_request()`、`respond()` 和 `respond_error()`。新增公开且经过校验的 `SessionPromptResponse` 来携带状态与原因,由 `session_prompt()` 返回该对象,并保留明确的读取保护:忽略意外的服务端请求帧,避免它们命中响应等待器。 4. 在 `python/sdk/src/deepseek_harness/api.py` 中,根据 `SessionPromptResponse` 构造 `TurnResult.status` 和新增的 `TurnResult.reason`,再删除 `session.finished` 分支与第二个完成循环。请求期间保持订阅打开,并保留 `_request_raw()` 最后的通知排空步骤,确保写在响应前的最后一条 `turn/end` 事件与任何 subagent 通知,都会在 `Session.run()` 重建最终助手消息之前被收集。 -5. 用原始客户端输入与服务端输出覆盖替换 `packages/ui/jsonrpc/tests/transport.spec.ts` 中的对称传输对用例,并更新 `server.spec.ts`、`plugin-apply.spec.ts` 和 `built-scope-carrier.e2e.ts`,覆盖直接结果、顺序、重叠、关闭和收窄后的伪实现。更新 `python/sdk/tests/test_client.py`,覆盖基于响应的结束流程、意外请求帧处理、回调与并发行为,以及已删除的公开辅助方法。同步更新 JSON-RPC README、双语 Python SDK README、导出 JSDoc 与声明、`scripts/smoke-python-runtime.py` 和 Python 单可执行文件快照。 +5. 用按方向的覆盖替换 `packages/sdk/sdk-protocol/tests/transport.spec.ts` 中的对称传输对用例,并更新 `server.spec.ts`、`plugin-apply.spec.ts` 和 `built-scope-carrier.e2e.ts`,覆盖直接结果、顺序、重叠、关闭和收窄后的伪实现;同步更新 TypeScript SDK 客户端(`packages/sdk/sdk-client`)及其套件以采用基于响应的结束流程。更新 `python/sdk/tests/test_client.py`,覆盖基于响应的结束流程、意外请求帧处理、回调与并发行为,以及已删除的公开辅助方法。同步更新 JSON-RPC README、双语 Python SDK README、导出 JSDoc 与声明、`scripts/smoke-python-runtime.py` 和 Python 单可执行文件快照。 ## 备选方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6bc3b1ea0d..7f197310b3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -555,7 +555,7 @@ export interface JsonRpcConfig { Depends on: `Readable` (`node:stream`) · `Writable` (`node:stream`) -Source: [`packages/ui/jsonrpc/src/index.ts:26`](../packages/ui/jsonrpc/src/index.ts) +Source: [`packages/ui/jsonrpc/src/index.ts:25`](../packages/ui/jsonrpc/src/index.ts) ## `@deepseek-ai/dsh-llm-deepseek` @@ -1330,7 +1330,7 @@ export interface Config { export type PermissionPolicy = 'allow' | 'reject' ``` -Source: [`packages/subagent/subagent-acp/src/index.ts:21`](../packages/subagent/subagent-acp/src/index.ts) +Source: [`packages/subagent/subagent-acp/src/index.ts:20`](../packages/subagent/subagent-acp/src/index.ts) ## `@deepseek-ai/dsh-subagent-fork` @@ -1346,6 +1346,55 @@ export interface Config { Source: [`packages/subagent/subagent-fork/src/index.ts:25`](../packages/subagent/subagent-fork/src/index.ts) +## `@deepseek-ai/dsh-subagent-sdk` + +Requires: `subagents` + +```ts config-catalog +/** 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 + /** 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 +} +``` + +Source: [`packages/subagent/subagent-sdk/src/index.ts:29`](../packages/subagent/subagent-sdk/src/index.ts) + ## `@deepseek-ai/dsh-subagent-spawn` Requires: `subagents` @@ -2113,6 +2162,8 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) +- `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/sdk-client/src/index.ts`](../packages/sdk/sdk-client/src/index.ts)) +- `@deepseek-ai/dsh-sdk-protocol` ([`packages/sdk/sdk-protocol/src/index.ts`](../packages/sdk/sdk-protocol/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 3c687e6b16..a34c2ff5b5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -63,6 +63,7 @@ flowchart TD pkg_subagent_acp["subagent-acp"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] + pkg_subagent_sdk["subagent-sdk"] pkg_subagent_spawn["subagent-spawn"] pkg_subagent_subprocess["subagent-subprocess"] pkg_tool_subagent["tool-subagent"] @@ -200,6 +201,8 @@ flowchart TD subgraph group_sdk["packages/sdk"] pkg_helper["helper"] pkg_scripts["scripts"] + pkg_sdk_client["sdk-client"] + pkg_sdk_protocol["sdk-protocol"] pkg_telemetry["telemetry"] end subgraph group_storage["packages/storage"] @@ -717,13 +720,6 @@ flowchart TD pkg_hooks_claude --> pkg_session_persistence pkg_hooks_claude --> pkg_subagent pkg_hooks_claude --> pkg_tools - pkg_jsonrpc --> pkg_agent - pkg_jsonrpc --> pkg_invariants - pkg_jsonrpc --> pkg_llm - pkg_jsonrpc --> pkg_llm_deepseek - pkg_jsonrpc --> pkg_scope - pkg_jsonrpc --> pkg_session - pkg_jsonrpc --> pkg_subagent pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_commands @@ -762,6 +758,10 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_tasks pkg_agent_spine_demo --> pkg_tools pkg_agent_spine_demo --> pkg_workspace_context + pkg_sdk_protocol --> pkg_invariants + pkg_sdk_protocol --> pkg_llm + pkg_sdk_protocol --> pkg_session + pkg_sdk_protocol --> pkg_subagent pkg_tool_ralph --> pkg_agent pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm @@ -785,6 +785,14 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_jsonrpc --> pkg_agent + pkg_jsonrpc --> pkg_invariants + pkg_jsonrpc --> pkg_llm + pkg_jsonrpc --> pkg_llm_deepseek + pkg_jsonrpc --> pkg_scope + pkg_jsonrpc --> pkg_sdk_protocol + pkg_jsonrpc --> pkg_session + pkg_jsonrpc --> pkg_subagent pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -823,6 +831,18 @@ flowchart TD pkg_tui_demo --> pkg_tui pkg_tui_demo --> pkg_user_interaction pkg_tui_demo --> pkg_workspace_context + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_sdk_client --> pkg_subagent_subprocess + pkg_subagent_sdk --> pkg_agent + pkg_subagent_sdk --> pkg_invariants + pkg_subagent_sdk --> pkg_llm + pkg_subagent_sdk --> pkg_sdk_client + pkg_subagent_sdk --> pkg_session + pkg_subagent_sdk --> pkg_subagent + pkg_subagent_sdk --> pkg_subagent_subprocess ``` | Package | Group | Depends on | @@ -958,13 +978,16 @@ flowchart TD | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-sdk`](../packages/subagent/subagent-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | diff --git a/examples/jsonrpc-agent/cordis.snapshot.yml b/examples/jsonrpc-agent/cordis.snapshot.yml new file mode 100644 index 0000000000..28d17c9b3d --- /dev/null +++ b/examples/jsonrpc-agent/cordis.snapshot.yml @@ -0,0 +1,28 @@ +# Keyless replay includes the live `cordis.yml`, disables the key-requiring +# DeepSeek adapter, and inserts `llm-replay` to serve recorded JSONL without a +# key or network; every other entry remains shared. The replay provider +# catalog claims the `deepseek` provider so the SDK server's `initialize` +# finds it owned and never mounts the real-adapter fallback. The SDK snapshot +# suite passes this path explicitly through `DSH_CORDIS_CONFIG` (the +# jsonrpc-demo bin performs no DSH_SNAPSHOT config swap of its own), and +# `llm-replay` reads `DSH_SNAPSHOT_FILE` / `DSH_SNAPSHOT_CHILD_FILES` from the +# harness. Stdout remains reserved for JSON-RPC frames. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + # `name` asserts the target: a mismatch skips the patch and warns only + # when a logger exists. + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index f0eb7ea6d8..0b5011f4da 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -38,6 +38,8 @@ name: '@deepseek-ai/dsh-session-persistence-jsonl' config: root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + # Snapshot runs read the raw JSONL back; production keeps zstd frames. + compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" - id: session-checkpoints name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts new file mode 100644 index 0000000000..53776c1662 --- /dev/null +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -0,0 +1,294 @@ +/** + * Keyless snapshot coverage for the TypeScript SDK path: each scenario spawns + * the REAL `dsh-jsonrpc-agent` runtime (per `DSH_EXAMPLE_MODE`) through the + * REAL `@deepseek-ai/dsh-sdk-client`, drives one turn over stdio JSON-RPC, + * and pins three surfaces — the SDK `TurnResult`, the complete notification + * stream, and the persisted session logs. Replay serves recorded model + * responses via `llm-replay` (`cordis.snapshot.yml`); `DSH_SNAPSHOT=record` + * re-records against the live API; `DSH_SNAPSHOT=refresh` replays committed + * fixtures and rewrites expected outputs. + */ + +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + normalizeSessionLog, + normalizeStdout, + refreshFixtureReplacements, + scrubRequestHeaders, + stabilizeRefreshLog, + type HarvestedLog, + type NormalizeContext, +} from '@deepseek-ai/dsh-acp-snapshot' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { DeepSeekHarness, type HarnessNotification, type TurnResult } from '@deepseek-ai/dsh-sdk-client' + +const testsDir = dirOf(import.meta.url) +const snapshotsDir = join(testsDir, 'snapshots') +const liveConfig = join(testsDir, '..', 'cordis.yml') +const replayConfig = join(testsDir, '..', 'cordis.snapshot.yml') +const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) + +const mode = process.env.DSH_SNAPSHOT ?? 'replay' +const recording = mode === 'record' +const refreshing = mode === 'refresh' + +function dirOf(url: string): string { + return fileURLToPath(new URL('.', url)) +} + +interface SdkScenario { + /** Scenario name; the snapshots/ fixture directory. */ + name: string + /** The user prompt for the single SDK turn. */ + prompt: string + /** Fixed SDK session id, so fixtures and replay binding stay stable. */ + sessionId: string + /** How many child sessions the turn persists (subagent scenarios). */ + children: number +} + +const SCENARIOS: SdkScenario[] = [ + { + name: 'text-turn', + prompt: 'Reply with exactly: SDK snapshot OK', + sessionId: 'sdk-snapshot-text', + children: 0, + }, + { + name: 'bash-tool', + prompt: 'Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391', + sessionId: 'sdk-snapshot-bash', + children: 0, + }, + { + name: 'subagent-spawn', + prompt: "Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim.", + sessionId: 'sdk-snapshot-subagent', + children: 1, + }, +] + +interface PersistedLog { + readonly path: string + readonly content: string + readonly header: Record +} + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { recursive: true }) + return entries.filter(entry => entry.endsWith('.jsonl')).map(entry => join(dir, entry)).sort() +} + +async function persistedLogs(sessionsRoot: string): Promise { + const files = await jsonlFiles(sessionsRoot) + return Promise.all(files.map(async (path) => { + const content = await readFile(path, 'utf8') + const header = JSON.parse(content.slice(0, content.indexOf('\n'))) as Record + return { path, content, header } + })) +} + +function contextOf(logs: readonly { content: string; header: Record }[], cwd: string): NormalizeContext { + return { + sessionIds: logs.flatMap(log => typeof log.header.id === 'string' ? [log.header.id] : []), + cwd, + } +} + +function contextOfContents(contents: readonly string[]): NormalizeContext { + const headers = contents.map(content => JSON.parse(content.slice(0, content.indexOf('\n'))) as Record) + return { + sessionIds: headers.flatMap(header => typeof header.id === 'string' ? [header.id] : []), + cwd: typeof headers[0]?.cwd === 'string' ? headers[0].cwd : '\0no-cwd\0', + } +} + +/** + * Normalize the SDK-visible notification stream: embedded `session.event` + * envelopes get the session-log treatment (times zeroed, headers tokenized), + * then every record is scrubbed like a wire frame. + */ +function normalizeNotifications(notifications: readonly HarnessNotification[], ctx: NormalizeContext): string { + const events = notifications + .filter(n => n.method === 'session.event') + .map(n => n.params.event as Record) + const normalizedEvents = events.length === 0 + ? [] + : scrubRequestHeaders(normalizeSessionLog( + `${events.map(event => JSON.stringify(event)).join('\n')}\n`, + ctx, + )).trimEnd().split('\n').map(line => JSON.parse(line) as Record) + let eventIndex = 0 + const records = notifications.map((notification) => { + if (notification.method !== 'session.event') return { method: notification.method, params: notification.params } + const event = normalizedEvents[eventIndex++] + return { method: notification.method, params: { ...notification.params, event } } + }) + return normalizeStdout(`${records.map(record => JSON.stringify(record)).join('\n')}\n`, ctx) +} + +/** Normalize the turn-result projection (status, reason kind, final text). */ +function normalizeResult(result: TurnResult, ctx: NormalizeContext): string { + return normalizeStdout(`${JSON.stringify({ + status: result.status, + reason: result.reason, + finalResponse: result.finalResponse, + })}\n`, ctx) +} + +/** One SDK turn against a fresh runtime subprocess in an isolated cwd. */ +async function runScenario(scenario: SdkScenario): Promise<{ + result: TurnResult + notifications: HarnessNotification[] + logs: PersistedLog[] + cwd: string +}> { + const cwd = await mkdtemp(join(tmpdir(), `sdk-snapshot-${scenario.name}-`)) + const sessionsRoot = join(cwd, '.sessions') + const scenarioDir = join(snapshotsDir, scenario.name) + const launch = resolveExampleLaunch({ + srcBin: runtimeBin, + configArgs: [], + tsconfigPath: repoTsconfig, + }) + const childFixtures = Array.from( + { length: scenario.children }, + (_, index) => join(scenarioDir, `session.${index + 1}.jsonl`), + ) + const env: Record = { + ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, + ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, + DSH_CORDIS_CONFIG: recording ? liveConfig : replayConfig, + DSH_SESSION_ROOT: sessionsRoot, + DSH_CWD: cwd, + DSH_SNAPSHOT: mode, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + ...recording ? {} : { + DSH_SNAPSHOT_FILE: join(scenarioDir, 'session.jsonl'), + ...childFixtures.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: childFixtures.join(delimiter) } : {}, + }, + } + + const harness = new DeepSeekHarness({ + launch: { + command: launch.command, + args: launch.args, + cwd, + env, + requestTimeoutMs: 110_000, + }, + cwd, + provider: 'deepseek', + model: 'deepseek-v4-flash', + }) + try { + const notifications: HarnessNotification[] = [] + const result = await harness.run(scenario.prompt, { + sessionId: scenario.sessionId, + onNotification: (notification) => { notifications.push(notification) }, + }) + await harness.close() + const logs = await persistedLogs(sessionsRoot) + return { result, notifications, logs, cwd } + } finally { + await harness.close() + await rm(cwd, { recursive: true, force: true }) + } +} + +/** Order logs parent-first, children by creation time (fixture layout order). */ +function orderLogs(logs: PersistedLog[], scenario: SdkScenario): PersistedLog[] { + const parents = logs.filter(log => typeof log.header.parentSession !== 'string') + const children = logs.filter(log => typeof log.header.parentSession === 'string') + .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt)) + expect(parents).toHaveLength(1) + expect(children).toHaveLength(scenario.children) + return [...parents, ...children] +} + +function fixtureFiles(scenario: SdkScenario): string[] { + const dir = join(snapshotsDir, scenario.name) + return [ + join(dir, 'session.jsonl'), + ...Array.from({ length: scenario.children }, (_, index) => join(dir, `session.${index + 1}.jsonl`)), + ] +} + +describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { + for (const scenario of SCENARIOS) { + it(`replays ${scenario.name} through the SDK`, async () => { + const scenarioDir = join(snapshotsDir, scenario.name) + const notificationsExpectedPath = join(scenarioDir, 'notifications.expected.jsonl') + const resultExpectedPath = join(scenarioDir, 'result.expected.json') + + const { result, notifications, logs, cwd } = await runScenario(scenario) + const ordered = orderLogs(logs, scenario) + const actualContext = contextOf(ordered, cwd) + + if (recording) { + // Fixtures carry tokenized request headers; llm-replay reads only + // assistant output and tool traffic, so scrubbing keeps prompts and + // schemas out of the corpus without affecting replay. + await mkdir(scenarioDir, { recursive: true }) + await Promise.all(ordered.map(async (log, index) => { + const file = fixtureFiles(scenario)[index] + if (file === undefined) throw new Error(`no fixture path for persisted log ${index}`) + await writeFile(file, scrubRequestHeaders(log.content)) + })) + } + + const files = fixtureFiles(scenario) + let expectedContents = await Promise.all(files.map(file => readFile(file, 'utf8'))) + + if (refreshing) { + const harvested = ordered.map((log): HarvestedLog => ({ + id: String(log.header.id), + createdAt: Number(log.header.createdAt), + ...typeof log.header.parentSession === 'string' ? { parentSession: log.header.parentSession } : {}, + content: log.content, + })) + const replacements = refreshFixtureReplacements(harvested, expectedContents) + expectedContents = await Promise.all(ordered.map(async (log, index) => { + const existing = expectedContents[index] + const file = files[index] + if (existing === undefined || file === undefined) throw new Error(`no fixture for persisted log ${index}`) + const stable = stabilizeRefreshLog(log.content, existing, replacements) + await writeFile(file, stable) + return stable + })) + } + + // Persisted transcripts match the committed fixtures. + const expectedContext = contextOfContents(expectedContents) + for (const [index, log] of ordered.entries()) { + const expected = expectedContents[index] + if (expected === undefined) throw new Error(`no fixture for persisted log ${index}`) + expect(scrubRequestHeaders(normalizeSessionLog(log.content, actualContext))) + .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext))) + } + + // The SDK-visible wire stream and turn result match their expected outputs. + const normalizedNotifications = normalizeNotifications(notifications, actualContext) + const normalizedResult = normalizeResult(result, actualContext) + if (recording || refreshing) { + await writeFile(notificationsExpectedPath, normalizedNotifications) + await writeFile(resultExpectedPath, normalizedResult) + } + expect(normalizedNotifications).toBe(await readFile(notificationsExpectedPath, 'utf8')) + expect(normalizedResult).toBe(await readFile(resultExpectedPath, 'utf8')) + + // Wire-shape invariants that must hold in every mode. + expect(result.status).toBe('ok') + expect(notifications.at(-1)?.method).toBe('session.finished') + if (scenario.children > 0) { + expect(notifications.some(n => n.method === 'subagent.started')).toBe(true) + expect(notifications.some(n => n.method === 'subagent.finished')).toBe(true) + } + }) + } +}) diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl new file mode 100644 index 0000000000..3ec8035656 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl @@ -0,0 +1,97 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391"}],"source":{"kind":"user"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Run this exact command with","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"{"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"command"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":": "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"echo"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" d"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"sh"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-s"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"dk"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-proof"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"739"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"1"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":", "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"description"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":": "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"Run"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" the"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" echo"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" command"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" as"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" requested"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific bash command and reply with its stdout only."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":59,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific bash command and reply with its stdout only."},{"type":"tool-call","id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":60,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":61,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_Ry17evSfTr0uJnHhg3X93070","content":[{"type":"text","text":"dsh-sdk-proof-7391\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":62,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":63,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" produced"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":74,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":75,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":76,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":77,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":78,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":79,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":80,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":81,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"d"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":82,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"sh"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":83,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-s"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":84,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"dk"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":85,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-proof"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":86,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":87,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"739"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":88,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"1"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":89,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":90,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"dsh-sdk-proof-7391"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":91,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":92,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":93,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."},{"type":"text","text":"dsh-sdk-proof-7391"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":94,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":95,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/bash-tool/result.expected.json new file mode 100644 index 0000000000..42b553d4a3 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/result.expected.json @@ -0,0 +1 @@ +{"status":"ok","reason":{"kind":"completed"},"finalResponse":"dsh-sdk-proof-7391"} diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl new file mode 100644 index 0000000000..5d0e8aa7a9 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl @@ -0,0 +1,97 @@ +{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"/tmp/sdk-snapshot-bash-tool-ywbuab","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785097395904,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785097395905,"data":{"content":[{"type":"text","text":"Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391"}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785097395907,"data":{"title":"Run this exact command with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785097395908,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785097395909,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785097396437,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1785097396438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785097396657,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785097396679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785097396680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785097396680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785097396680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":12,"time":1785097396680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":13,"time":1785097396681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":14,"time":1785097396705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":15,"time":1785097396730,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":16,"time":1785097396730,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":17,"time":1785097396730,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":18,"time":1785097396755,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":19,"time":1785097396756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} +{"type":"assistant/chunk","seq":20,"time":1785097396780,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}} +{"type":"assistant/chunk","seq":21,"time":1785097396781,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} +{"type":"assistant/chunk","seq":22,"time":1785097396781,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1785097396856,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":24,"time":1785097396857,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":25,"time":1785097396857,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":26,"time":1785097396857,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1785097396881,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":28,"time":1785097396882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1785097396882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":30,"time":1785097396882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1785097396907,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":32,"time":1785097396907,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" d"}}} +{"type":"assistant/chunk","seq":33,"time":1785097396907,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":34,"time":1785097396907,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-s"}}} +{"type":"assistant/chunk","seq":35,"time":1785097396907,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"dk"}}} +{"type":"assistant/chunk","seq":36,"time":1785097396908,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-proof"}}} +{"type":"assistant/chunk","seq":37,"time":1785097396932,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-"}}} +{"type":"assistant/chunk","seq":38,"time":1785097396932,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"739"}}} +{"type":"assistant/chunk","seq":39,"time":1785097396932,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":40,"time":1785097396933,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1785097396957,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":42,"time":1785097396958,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1785097396983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":44,"time":1785097396983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1785097396983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":46,"time":1785097396983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1785097397008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":48,"time":1785097397008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":49,"time":1785097397008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":50,"time":1785097397033,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":51,"time":1785097397034,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" as"}}} +{"type":"assistant/chunk","seq":52,"time":1785097397034,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" requested"}}} +{"type":"assistant/chunk","seq":53,"time":1785097397034,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1785097397059,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":55,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific bash command and reply with its stdout only."}}}} +{"type":"assistant/chunk","seq":56,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}}}} +{"type":"assistant/chunk","seq":57,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":58,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":59,"time":1785097397118,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific bash command and reply with its stdout only."},{"type":"tool-call","id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"} +{"type":"tool/call","seq":60,"time":1785097397119,"data":{"turn":1,"step":1,"callId":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}} +{"type":"tool/result","seq":61,"time":1785097397142,"data":{"turn":1,"step":1,"callId":"call_00_Ry17evSfTr0uJnHhg3X93070","content":[{"type":"text","text":"dsh-sdk-proof-7391\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1785097397145,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":63,"time":1785097397145,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":64,"time":1785097398036,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":65,"time":1785097398037,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":66,"time":1785097398255,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":67,"time":1785097398280,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" produced"}}} +{"type":"assistant/chunk","seq":68,"time":1785097398281,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":69,"time":1785097398281,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}} +{"type":"assistant/chunk","seq":70,"time":1785097398305,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":71,"time":1785097398306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":72,"time":1785097398306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":73,"time":1785097398306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":74,"time":1785097398331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":75,"time":1785097398331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":76,"time":1785097398331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":77,"time":1785097398357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":78,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}} +{"type":"assistant/chunk","seq":79,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":80,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":81,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"d"}}} +{"type":"assistant/chunk","seq":82,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"sh"}}} +{"type":"assistant/chunk","seq":83,"time":1785097398382,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-s"}}} +{"type":"assistant/chunk","seq":84,"time":1785097398382,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"dk"}}} +{"type":"assistant/chunk","seq":85,"time":1785097398382,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-proof"}}} +{"type":"assistant/chunk","seq":86,"time":1785097398382,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-"}}} +{"type":"assistant/chunk","seq":87,"time":1785097398383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"739"}}} +{"type":"assistant/chunk","seq":88,"time":1785097398383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"1"}}} +{"type":"assistant/chunk","seq":89,"time":1785097398408,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."}}}} +{"type":"assistant/chunk","seq":90,"time":1785097398408,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"dsh-sdk-proof-7391"}}}} +{"type":"assistant/chunk","seq":91,"time":1785097398409,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}}}} +{"type":"assistant/chunk","seq":92,"time":1785097398409,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":93,"time":1785097398409,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."},{"type":"text","text":"dsh-sdk-proof-7391"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92],"surfaceOp":"append"} +{"type":"step/end","seq":94,"time":1785097398411,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":95,"time":1785097398412,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl new file mode 100644 index 0000000000..b403171d1d --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl @@ -0,0 +1,175 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim."}],"source":{"kind":"user"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Use"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" probe"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" step"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" step"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"{"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"description"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":": "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"echo"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" probe"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":", "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":74,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"prom"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":75,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"pt"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":76,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":77,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":": "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":78,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":79,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"Reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":80,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":81,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":82,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":":"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":83,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":84,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":85,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":86,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":87,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":88,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":89,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":90,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once with description 'echo probe' and prompt 'Reply with exactly: child answer 42.'\n2. Then reply with the subagent's final answer verbatim.\n\nLet me do this step by step."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":91,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":92,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":93,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":94,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once with description 'echo probe' and prompt 'Reply with exactly: child answer 42.'\n2. Then reply with the subagent's final answer verbatim.\n\nLet me do this step by step."},{"type":"tool-call","id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":95,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}}}} +{"method":"subagent.started","params":{"parentSessionId":"{{sessionId}}","childSessionId":"{{sessionId}}"}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly: child answer","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"subagent.finished","params":{"provider":"spawn","agentId":"{{sessionId}}","parentSessionId":"{{sessionId}}","childSessionId":"{{sessionId}}","status":"ok","stopReason":"completed","lastAssistantMessage":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}]}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":96,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","content":[{"type":"text","text":"child answer 42."}],"isError":false},"sourceEventSeqs":[95],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":97,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":98,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":99,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":100,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":101,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":102,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":103,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replied"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":104,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":105,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":106,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":107,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":108,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":109,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":110,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":111,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":112,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":113,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":114,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":115,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":116,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":117,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":118,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":119,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":120,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":121,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":122,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":123,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":124,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":125,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":126,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":127,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":128,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":129,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":130,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":131,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":132,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent replied with \"child answer 42.\" Now I need to reply with the subagent's final answer verbatim."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":133,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":134,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":135,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":136,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent replied with \"child answer 42.\" Now I need to reply with the subagent's final answer verbatim."},{"type":"text","text":"child answer 42."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}},"sourceEventSeqs":[99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":137,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":138,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/result.expected.json new file mode 100644 index 0000000000..b83a869dee --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/result.expected.json @@ -0,0 +1 @@ +{"status":"ok","reason":{"kind":"completed"},"finalResponse":"child answer 42."} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl new file mode 100644 index 0000000000..05e64e6149 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -0,0 +1,34 @@ +{"type":"session","version":0,"id":"0b7fd85c-9f6f-4d46-b954-363984ce66fb","createdAt":1785097410282,"cwd":"/tmp/sdk-snapshot-subagent-spawn-6fzuBd","parentSession":"sdk-snapshot-subagent","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1785097410283,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785097410283,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785097410283,"data":{"title":"Reply with exactly: child answer","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785097410284,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785097410284,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785097410836,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1785097410836,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785097411011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785097411011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785097411011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785097411035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":12,"time":1785097411036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":13,"time":1785097411036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":14,"time":1785097411036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1785097411036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}} +{"type":"assistant/chunk","seq":16,"time":1785097411061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":17,"time":1785097411061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":18,"time":1785097411062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}} +{"type":"assistant/chunk","seq":19,"time":1785097411062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":20,"time":1785097411113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":1785097411113,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}} +{"type":"assistant/chunk","seq":22,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" answer"}}} +{"type":"assistant/chunk","seq":23,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}} +{"type":"assistant/chunk","seq":24,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}} +{"type":"assistant/chunk","seq":25,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":26,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}} +{"type":"assistant/chunk","seq":27,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} +{"type":"assistant/chunk","seq":28,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":29,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1785097411139,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1785097411143,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1785097411143,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl new file mode 100644 index 0000000000..858c05c3e2 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -0,0 +1,140 @@ +{"type":"session","version":0,"id":"sdk-snapshot-subagent","createdAt":1785097408901,"cwd":"/tmp/sdk-snapshot-subagent-spawn-6fzuBd","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785097408905,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785097408905,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785097408907,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785097408908,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785097408908,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785097409495,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1785097409496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785097409666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785097409691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785097409692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785097409692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785097409692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":12,"time":1785097409692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":13,"time":1785097409692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":14,"time":1785097409716,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Use"}}} +{"type":"assistant/chunk","seq":15,"time":1785097409716,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":16,"time":1785097409717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":17,"time":1785097409717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":18,"time":1785097409717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":19,"time":1785097409717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":20,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":21,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":22,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} +{"type":"assistant/chunk","seq":23,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":24,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":25,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" probe"}}} +{"type":"assistant/chunk","seq":26,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'"}}} +{"type":"assistant/chunk","seq":27,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":28,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":29,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":30,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} +{"type":"assistant/chunk","seq":31,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":32,"time":1785097409799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":33,"time":1785097409799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":34,"time":1785097409799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":35,"time":1785097409800,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":36,"time":1785097409800,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":37,"time":1785097409800,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}} +{"type":"assistant/chunk","seq":38,"time":1785097409820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}} +{"type":"assistant/chunk","seq":39,"time":1785097409821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":40,"time":1785097409821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":41,"time":1785097409821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}} +{"type":"assistant/chunk","seq":42,"time":1785097409849,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":43,"time":1785097409849,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":44,"time":1785097409850,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":45,"time":1785097409850,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":46,"time":1785097409850,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":47,"time":1785097409850,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":48,"time":1785097409873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}} +{"type":"assistant/chunk","seq":49,"time":1785097409874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":50,"time":1785097409874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":51,"time":1785097409874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":52,"time":1785097409874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":53,"time":1785097409874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":54,"time":1785097409899,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":55,"time":1785097409900,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":56,"time":1785097409925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":57,"time":1785097409951,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" step"}}} +{"type":"assistant/chunk","seq":58,"time":1785097409952,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":59,"time":1785097409952,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" step"}}} +{"type":"assistant/chunk","seq":60,"time":1785097409952,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":61,"time":1785097410031,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":62,"time":1785097410031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":63,"time":1785097410056,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":64,"time":1785097410057,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":65,"time":1785097410057,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":66,"time":1785097410057,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":67,"time":1785097410057,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":68,"time":1785097410083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":69,"time":1785097410083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":70,"time":1785097410083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" probe"}}} +{"type":"assistant/chunk","seq":71,"time":1785097410083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":72,"time":1785097410134,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":73,"time":1785097410135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":74,"time":1785097410135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":75,"time":1785097410135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":76,"time":1785097410135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":77,"time":1785097410135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":78,"time":1785097410161,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":79,"time":1785097410162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":80,"time":1785097410162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":81,"time":1785097410162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":82,"time":1785097410162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":83,"time":1785097410187,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" child"}}} +{"type":"assistant/chunk","seq":84,"time":1785097410188,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" answer"}}} +{"type":"assistant/chunk","seq":85,"time":1785097410188,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":86,"time":1785097410188,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"42"}}} +{"type":"assistant/chunk","seq":87,"time":1785097410213,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":88,"time":1785097410214,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1785097410214,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":90,"time":1785097410271,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once with description 'echo probe' and prompt 'Reply with exactly: child answer 42.'\n2. Then reply with the subagent's final answer verbatim.\n\nLet me do this step by step."}}}} +{"type":"assistant/chunk","seq":91,"time":1785097410272,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}}}} +{"type":"assistant/chunk","seq":92,"time":1785097410272,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}}}} +{"type":"assistant/chunk","seq":93,"time":1785097410272,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":94,"time":1785097410276,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once with description 'echo probe' and prompt 'Reply with exactly: child answer 42.'\n2. Then reply with the subagent's final answer verbatim.\n\nLet me do this step by step."},{"type":"tool-call","id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} +{"type":"tool/call","seq":95,"time":1785097410277,"data":{"turn":1,"step":1,"callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}} +{"type":"tool/result","seq":96,"time":1785097411146,"data":{"turn":1,"step":1,"callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","content":[{"type":"text","text":"child answer 42."}],"isError":false},"sourceEventSeqs":[95],"surfaceOp":"append"} +{"type":"step/end","seq":97,"time":1785097411148,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":98,"time":1785097411149,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":99,"time":1785097411681,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":100,"time":1785097411681,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":101,"time":1785097411813,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":102,"time":1785097411839,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":103,"time":1785097411839,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replied"}}} +{"type":"assistant/chunk","seq":104,"time":1785097411865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":105,"time":1785097411866,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":106,"time":1785097411892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}} +{"type":"assistant/chunk","seq":107,"time":1785097411892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":108,"time":1785097411892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":109,"time":1785097411892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}} +{"type":"assistant/chunk","seq":110,"time":1785097411892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":111,"time":1785097411918,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":112,"time":1785097411918,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":113,"time":1785097411919,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":114,"time":1785097411919,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":115,"time":1785097411919,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":116,"time":1785097411944,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":117,"time":1785097411944,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":118,"time":1785097411944,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":119,"time":1785097411972,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":120,"time":1785097411972,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":121,"time":1785097411972,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}} +{"type":"assistant/chunk","seq":122,"time":1785097411973,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":123,"time":1785097411973,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":124,"time":1785097411973,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":125,"time":1785097411996,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":126,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":127,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"child"}}} +{"type":"assistant/chunk","seq":128,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" answer"}}} +{"type":"assistant/chunk","seq":129,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" "}}} +{"type":"assistant/chunk","seq":130,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"42"}}} +{"type":"assistant/chunk","seq":131,"time":1785097412023,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":132,"time":1785097412024,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent replied with \"child answer 42.\" Now I need to reply with the subagent's final answer verbatim."}}}} +{"type":"assistant/chunk","seq":133,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} +{"type":"assistant/chunk","seq":134,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":135,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":136,"time":1785097412026,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent replied with \"child answer 42.\" Now I need to reply with the subagent's final answer verbatim."},{"type":"text","text":"child answer 42."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}},"sourceEventSeqs":[99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135],"surfaceOp":"append"} +{"type":"step/end","seq":137,"time":1785097412028,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":138,"time":1785097412028,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl new file mode 100644 index 0000000000..d924d9c534 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl @@ -0,0 +1,38 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"SD"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"K"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" snapshot"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"SD"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"K"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" snapshot"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."},{"type":"text","text":"SDK snapshot OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":36,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/text-turn/result.expected.json new file mode 100644 index 0000000000..46cd4334e0 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/result.expected.json @@ -0,0 +1 @@ +{"status":"ok","reason":{"kind":"completed"},"finalResponse":"SDK snapshot OK"} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl new file mode 100644 index 0000000000..2b4a390563 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl @@ -0,0 +1,38 @@ +{"type":"session","version":0,"id":"sdk-snapshot-text","createdAt":1785097381464,"cwd":"/tmp/sdk-snapshot-text-turn-OwFEJv","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785097381468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785097381469,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785097381471,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785097381472,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785097381472,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785097381978,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1785097381979,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785097382117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785097382145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785097382172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785097382173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785097382173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":12,"time":1785097382173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":13,"time":1785097382197,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":14,"time":1785097382198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1785097382198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"SD"}}} +{"type":"assistant/chunk","seq":16,"time":1785097382198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"K"}}} +{"type":"assistant/chunk","seq":17,"time":1785097382198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" snapshot"}}} +{"type":"assistant/chunk","seq":18,"time":1785097382224,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" OK"}}} +{"type":"assistant/chunk","seq":19,"time":1785097382224,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":20,"time":1785097382225,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":21,"time":1785097382250,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":22,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":23,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":24,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":25,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":26,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"SD"}}} +{"type":"assistant/chunk","seq":27,"time":1785097382278,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"K"}}} +{"type":"assistant/chunk","seq":28,"time":1785097382278,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" snapshot"}}} +{"type":"assistant/chunk","seq":29,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" OK"}}} +{"type":"assistant/chunk","seq":30,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}}} +{"type":"assistant/chunk","seq":31,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}}} +{"type":"assistant/chunk","seq":32,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":33,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":34,"time":1785097382283,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."},{"type":"text","text":"SDK snapshot OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785097382288,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":36,"time":1785097382288,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/sdk/README.i18n.yaml b/packages/sdk/README.i18n.yaml index bee258c478..e433146312 100644 --- a/packages/sdk/README.i18n.yaml +++ b/packages/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 53995820a575d68bbd3322f82e21fa6d3456b38e -README.zh.md: d3481cab032a9bede9b85ce0f4010566592befbd +README.md: 0dcf3655fc4d20452981f83000f9b9b586ede17b +README.zh.md: 1a692de2f7dc56cc3dbc7e40b8e2369d0bf2e8c5 diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 53995820a5..0dcf3655fc 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -2,15 +2,17 @@ English | [中文](README.zh.md) -Developer tooling for creating, editing, building, and running DeepSeek Harness projects. +Developer tooling for creating, editing, building, and running DeepSeek Harness projects, plus the client SDK stack for driving a harness runtime from another process. -The [feature Agent Note](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md) owns the developer workflow; the [architecture Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the package and project-editing boundaries. +The [feature Agent Note](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md) owns the developer workflow; the [architecture Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the package and project-editing boundaries; the [TypeScript SDK Agent Note](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) owns the client SDK stack. | Package | Role | |---|---| | [`helper`](helper/README.md) | Project aggregate, edit session, builtin features, project documents, templates, package managers, and prompt abstraction | | [`scripts`](scripts/README.md) | The `dsh-sdk` launcher: `start`, `dev`, `build`, and interactive `config` | | [`create-sdk`](create-sdk/README.md) | The `npm create @deepseek-ai/sdk` initializer | +| [`sdk-protocol`](sdk-protocol/README.md) | Shared SDK runtime wire protocol: the newline-delimited JSON-RPC transport + named request/notification types | +| [`sdk-client`](sdk-client/README.md) | TypeScript client SDK: drive a harness runtime subprocess over stdio JSON-RPC (the Python SDK's design twin) | `@deepseek-ai/create-sdk` is the one package-name exception to the repository's `@deepseek-ai/dsh-*` rule: npm's scoped initializer convention requires that name for `npm create @deepseek-ai/sdk`. diff --git a/packages/sdk/README.zh.md b/packages/sdk/README.zh.md index d3481cab03..1a692de2f7 100644 --- a/packages/sdk/README.zh.md +++ b/packages/sdk/README.zh.md @@ -2,15 +2,17 @@ [English](README.md) | 中文 -用于创建、编辑、构建和运行 DeepSeek Harness 项目的开发者工具。 +用于创建、编辑、构建和运行 DeepSeek Harness 项目的开发者工具,外加从另一进程驱动 harness 运行时的客户端 SDK 栈。 -[功能 Agent Note](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)负责开发者工作流;[架构 Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)负责包与项目编辑边界。 +[功能 Agent Note](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)负责开发者工作流;[架构 Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)负责包与项目编辑边界;[TypeScript SDK Agent Note](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)负责客户端 SDK 栈。 | 包 | 职责 | |---|---| | [`helper`](helper/README.md) | 项目聚合、编辑会话、内置功能、项目文档、模板、包管理器与提示词抽象 | | [`scripts`](scripts/README.md) | `dsh-sdk` 启动器:`start`、`dev`、`build` 和交互式 `config` | | [`create-sdk`](create-sdk/README.md) | `npm create @deepseek-ai/sdk` 初始化器 | +| [`sdk-protocol`](sdk-protocol/README.md) | 共享的 SDK 运行时线协议:按换行分帧的 JSON-RPC 传输 + 具名请求/通知类型 | +| [`sdk-client`](sdk-client/README.md) | TypeScript 客户端 SDK:走 stdio JSON-RPC 驱动 harness 运行时子进程(Python SDK 的设计孪生) | `@deepseek-ai/create-sdk` 是仓库 `@deepseek-ai/dsh-*` 命名规则的唯一例外:npm 的 scoped initializer 约定要求使用该名称,才能支持 `npm create @deepseek-ai/sdk`。 diff --git a/packages/sdk/sdk-client/README.i18n.yaml b/packages/sdk/sdk-client/README.i18n.yaml new file mode 100644 index 0000000000..ebb5ca18d5 --- /dev/null +++ b/packages/sdk/sdk-client/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 82e344014ac01120986ee2b4e07ac21192506ff7 +README.zh.md: 6e67f57ab92a75be3600cbdd0ce6bf21832fd2c8 diff --git a/packages/sdk/sdk-client/README.md b/packages/sdk/sdk-client/README.md new file mode 100644 index 0000000000..82e344014a --- /dev/null +++ b/packages/sdk/sdk-client/README.md @@ -0,0 +1,50 @@ +# @deepseek-ai/dsh-sdk-client + +English | [中文](README.zh.md) + +The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides. + +Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-sdk`](../../subagent/subagent-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern. + +## DeepSeekHarness + +```ts +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' + +await using harness = new DeepSeekHarness({ + launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] }, + provider: 'deepseek', + model: 'deepseek-v4-flash', +}) +const result = await harness.run('say hi') +console.log(result.status, result.finalResponse) +``` + +The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (cwd + provider/model route); a failed handshake closes the runtime and resets, so a later call may retry. `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), plus every `session.event` envelope and raw notification observed for that session tree, in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation. + +## HarnessClient + +The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail). + +`close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks the shared stdin-EOF → SIGTERM → SIGKILL [dispose ladder](../../subagent/subagent-subprocess/README.md) (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. It is idempotent, and a closed client refuses reuse. + +`HarnessClientOptions.env` replaces the child environment entirely when given (`undefined` inherits the parent's); callers own credential policy — `buildChildEnv` from `dsh-subagent-subprocess` is the scrub-then-inject helper for isolation-minded launches. + +## Testing + +Keyless unit tests drive a scripted fake runtime subprocess (`tests/fake-runtime.ts`, protocol-only, env-scripted) over real stdio: turn loop, session-tree scoping, timeout/death/malformed-response surfaces, and the dispose ladder. The [SDK snapshot suite](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) drives the real `dsh-jsonrpc-agent` runtime through this client keylessly via `llm-replay`, pinning the notification stream, the turn result, and the persisted logs; `DSH_SNAPSHOT=record` re-records against the live API. + +## Model Experience + +None, as this is a client-process library; the model runs in the spawned runtime, whose experience is owned by the plugins its `cordis.yml` composes. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **No bundled-runtime resolution** — callers name the runtime executable explicitly; packaged-executable discovery stays Python-side until a TypeScript distribution consumer exists. +- **No mid-turn cancel** — the wire has no prompt-cancel method; abandoning a turn means closing the runtime (see the protocol's [Known Limitations](../sdk-protocol/README.md)). +- **One in-flight prompt per session** — a server-side rule this client surfaces as a `JsonRpcResponseError`; independent sessions run concurrently on one runtime. +- **Client→server notifications and server→client requests are unimplemented** on both wire ends; the transport carries them for future approval flows. diff --git a/packages/sdk/sdk-client/README.zh.md b/packages/sdk/sdk-client/README.zh.md new file mode 100644 index 0000000000..6e67f57ab9 --- /dev/null +++ b/packages/sdk/sdk-client/README.zh.md @@ -0,0 +1,50 @@ +# @deepseek-ai/dsh-sdk-client + +[English](README.md) | 中文 + +以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层回合 API,`HarnessClient` 是低层协议客户端。纯库:不在任何 Cordis 上下文注册;它所生成的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。 + +与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费者——[`dsh-subagent-sdk`](../../subagent/subagent-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。 + +## DeepSeekHarness + +```ts +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' + +await using harness = new DeepSeekHarness({ + launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] }, + provider: 'deepseek', + model: 'deepseek-v4-flash', +}) +const result = await harness.run('say hi') +console.log(result.status, result.finalResponse) +``` + +子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(cwd + provider/model 路由);握手失败会关闭运行时并复位,后续调用可以重试。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本),以及该会话树内按线序观察到的全部 `session.event` 封套与原始通知。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。 + +## HarnessClient + +回合 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。错误表面有类型:`JsonRpcResponseError`(线上错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;线上没有取消方法,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。 + +`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走共享的 stdin-EOF → SIGTERM → SIGKILL [处置阶梯](../../subagent/subagent-subprocess/README.md)(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。幂等,已关闭的客户端拒绝复用。 + +`HarnessClientOptions.env` 给定时整体替换子环境(`undefined` 原样继承父环境);凭据策略归调用方——`dsh-subagent-subprocess` 的 `buildChildEnv` 是面向隔离启动的先擦除后注入助手。 + +## 测试 + +免密钥单元测试通过真实 stdio 驱动一个脚本化伪运行时子进程(`tests/fake-runtime.ts`,纯协议、环境变量脚本化):回合循环、会话树范围限定、超时/死亡/畸形响应表面、处置阶梯。[SDK 快照套件](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts)经由 `llm-replay` 免密钥地通过本客户端驱动真实 `dsh-jsonrpc-agent` 运行时,钉住通知流、回合结果与持久化日志;`DSH_SNAPSHOT=record` 对真实 API 重录。 + +## Model Experience + +None, as this is a client-process library; the model runs in the spawned runtime, whose experience is owned by the plugins its `cordis.yml` composes. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **无捆绑运行时解析** —— 调用方显式指定运行时可执行文件;打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费者。 +- **无回合中取消** —— 线上没有 prompt 取消方法;放弃回合意味着关闭运行时(见协议的 [Known Limitations](../sdk-protocol/README.md))。 +- **每会话同时只有一个在途 prompt** —— 服务端规则,本客户端将其呈现为 `JsonRpcResponseError`;相互独立的会话可在同一运行时上并发。 +- **client→server 通知与 server→client 请求**在线两端都未实现;传输层为未来审批流保留了承载能力。 diff --git a/packages/sdk/sdk-client/src/client.ts b/packages/sdk/sdk-client/src/client.ts index f802ee479f..bf852b8eb1 100644 --- a/packages/sdk/sdk-client/src/client.ts +++ b/packages/sdk/sdk-client/src/client.ts @@ -107,13 +107,19 @@ export class NotificationSubscription implements AsyncIterable { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/packages/sdk/sdk-protocol/README.i18n.yaml b/packages/sdk/sdk-protocol/README.i18n.yaml new file mode 100644 index 0000000000..07a2a188ed --- /dev/null +++ b/packages/sdk/sdk-protocol/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 61ffc0e17700d79da14001b389c7c6dcb50ee28d +README.zh.md: 9de816dc588354d04194d5eb99f444409456046e diff --git a/packages/sdk/sdk-protocol/README.md b/packages/sdk/sdk-protocol/README.md new file mode 100644 index 0000000000..61ffc0e177 --- /dev/null +++ b/packages/sdk/sdk-protocol/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-sdk-protocol + +English | [中文](README.zh.md) + +The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delimited JSON-RPC 2.0 transport class plus the named request, result, and notification types both wire ends speak. The server side is the [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) plugin; clients are [`dsh-sdk-client`](../sdk-client/README.md) (TypeScript) and the [Python SDK](../../../python/README.md) (which mirrors these shapes but does not import them). A pure library — no plugin, no Config, no registration. + +## Transport + +`JsonRpcLineTransport` frames JSON-RPC 2.0 over caller-owned byte streams, one compact JSON frame per `\n`-terminated line. Frames with `id` and `method` are requests, `id` alone is a response, `method` alone is a notification; malformed JSON lines are ignored. `start()` attaches stream listeners, `close()` detaches them and rejects pending requests without destroying the streams. Missing request handlers answer `-32601`; handler rejections answer `-32603` with the error message. An error response rejects the pending `request()` with `JsonRpcResponseError`, which preserves the wire `code` and optional `data`. `JsonRpcTransportPeer` is the outbound surface (request/notify) the server class is typed against. + +## Wire types + +`types.ts` names every payload of the protocol served by `HarnessSdkServer`: + +| Direction | Method | Types | +|---|---|---| +| client→server | `initialize` | `InitializeParams` → `InitializeResult` | +| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (answered only after turn settlement) | +| client→server | `shutdown` | no params → `{}` | +| server→client | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) | +| server→client | `session.finished` | `SessionFinishedNotification` (one per accepted prompt) | +| server→client | `subagent.started` | `SubagentStartedNotification` | +| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) | + +`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. + +## Model Experience + +None, as this package defines the client-facing wire protocol; the model-visible surfaces belong to the runtime plugins composed behind the serving [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) entry. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **No protocol-version negotiation** — the handshake carries only `serverInfo.version` (`0.0.1`, unvalidated by clients); pre-release stance, no compatibility promise. +- **No cancel or session-close methods** — a client abandons a turn by closing the runtime process; see the [`dsh-jsonrpc` README](../../ui/jsonrpc/README.md). +- **Server→client requests are dead capability** — the transport supports them, but the server never sends one; the Python SDK's responder surface exists for future approval flows. diff --git a/packages/sdk/sdk-protocol/README.zh.md b/packages/sdk/sdk-protocol/README.zh.md new file mode 100644 index 0000000000..9de816dc58 --- /dev/null +++ b/packages/sdk/sdk-protocol/README.zh.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-sdk-protocol + +[English](README.md) | 中文 + +DeepSeek Harness SDK 运行时的共享线协议:一个按换行分帧的 JSON-RPC 2.0 传输类,加上线两端共同使用的具名请求、结果与通知类型。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者镜像这些形状但不导入它们)。纯库——无插件、无 Config、无注册。 + +## 传输 + +`JsonRpcLineTransport` 在调用方持有的字节流上为 JSON-RPC 2.0 分帧,每行一个紧凑 JSON 帧、以 `\n` 结尾。带 `id` 与 `method` 的帧是请求,仅 `id` 是响应,仅 `method` 是通知;非法 JSON 行被忽略。`start()` 挂接流监听器,`close()` 摘除监听器并拒绝挂起请求、但不销毁流。缺失请求处理器时应答 `-32601`;处理器拒绝则应答携带错误消息的 `-32603`。错误响应会以 `JsonRpcResponseError` 拒绝挂起的 `request()`,保留线上的 `code` 与可选 `data`。`JsonRpcTransportPeer` 是服务器类所依赖的出站表面(request/notify)。 + +## 线类型 + +`types.ts` 为 `HarnessSdkServer` 所服务协议的每个载荷命名: + +| 方向 | 方法 | 类型 | +|---|---|---| +| client→server | `initialize` | `InitializeParams` → `InitializeResult` | +| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(仅在回合尘埃落定后应答) | +| client→server | `shutdown` | 无参数 → `{}` | +| server→client | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) | +| server→client | `session.finished` | `SessionFinishedNotification`(每个被接受的 prompt 一条) | +| server→client | `subagent.started` | `SubagentStartedNotification` | +| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内 run) | + +`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇表是线契约的一部分。`serverInfo.name` 保持线上稳定值 `deepseek-harness-sdk-runtime`。 + +## Model Experience + +None, as this package defines the client-facing wire protocol; the model-visible surfaces belong to the runtime plugins composed behind the serving [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) entry. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **无协议版本协商** —— 握手只携带 `serverInfo.version`(`0.0.1`,客户端不校验);预发布立场,无兼容承诺。 +- **无取消与会话关闭方法** —— 客户端放弃回合的方式是关闭运行时进程;见 [`dsh-jsonrpc` README](../../ui/jsonrpc/README.md)。 +- **server→client 请求是死能力** —— 传输层支持,但服务器从不发送;Python SDK 的应答表面为未来审批流预留。 diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 7be70e0fe5..963cf45554 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 5e3bddc67d213d74766a75da65cc44a21c8bb149 -README.zh.md: 4391809ee83c822fcada25f0bdc021af44be9354 +README.md: 15b05f22d5ab2ed6bdfc4f3725737d62afc8f7c1 +README.zh.md: d331530f60cf584ed906553a5caa00d6a18efbc2 diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 5e3bddc67d..15b05f22d5 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -10,10 +10,11 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | | `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | -| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — | +| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, cwd resolution, isolated config dirs (pure lib; registers nothing) | — | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | +| `subagent-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs). Tests replace only the child boundary with package-local fixtures. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-sdk` backends build on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, child cwd resolution, isolated config dirs). Tests replace only the child boundary with package-local fixtures. The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 4391809ee8..d331530f60 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -10,10 +10,11 @@ subagent seam 允许 agent(智能体)把工作委派给子 agent。与 [bash | `subagent-inprocess/` | 共享进程内运行驱动器(不提供提供方;每次运行使用一个清理 effect) | 无 | | `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents`) | -| `subagent-subprocess/` | 共享进程外机制:环境变量清理、dispose(资源释放)阶梯、隔离配置目录(纯库;不注册任何内容) | 无 | +| `subagent-subprocess/` | 共享进程外机制:环境变量清理、dispose(资源释放)阶梯、cwd 解析、隔离配置目录(纯库;不注册任何内容) | 无 | | `subagent-acp/` | 进程外后端:在派生子进程中运行并通过 ACP(Agent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents`) | +| `subagent-sdk/` | 进程外后端:在派生子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | -接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则构建于 `subagent-subprocess` 库之上(凭据环境变量清理、dispose 阶梯、隔离配置目录)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-sdk` 后端则构建于 `subagent-subprocess` 库之上(凭据环境变量清理、dispose 阶梯、子进程 cwd 解析、隔离配置目录)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 diff --git a/packages/subagent/subagent-sdk/README.i18n.yaml b/packages/subagent/subagent-sdk/README.i18n.yaml new file mode 100644 index 0000000000..b35d54db49 --- /dev/null +++ b/packages/subagent/subagent-sdk/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: bc9b1ed7706d16d0a7463d0b26beb3ccf171ce5b +README.zh.md: 76eb4ffaf9854892e05ad891c7b951a838b89e8e diff --git a/packages/subagent/subagent-sdk/README.md b/packages/subagent/subagent-sdk/README.md new file mode 100644 index 0000000000..bc9b1ed770 --- /dev/null +++ b/packages/subagent/subagent-sdk/README.md @@ -0,0 +1,97 @@ +# @deepseek-ai/dsh-subagent-sdk + +English | [中文](README.zh.md) + +The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a fresh subprocess, driven over stdio JSON-RPC through the [TypeScript SDK client](../../sdk/sdk-client/README.md). It is the second out-of-process backend beside [`subagent-acp`](../subagent-acp/README.md), differing in the wire and the child contract: the ACP backend drives any Agent Client Protocol agent; this backend drives specifically a harness SDK runtime (`dsh-jsonrpc-agent` bin or packaged executable), so the child is a full peer harness — own `cordis.yml`-decided composition, session persistence, model route, and tools. + +## Start and ownership + +`start(request)` resolves the child's working directory, spawns the runtime through `DeepSeekHarness`, and completes the `initialize` handshake (with the configured `provider`/`model` route) before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned. + +The working directory resolves exactly like the ACP backend, through the shared [`subagent-subprocess` helpers](../subagent-subprocess/README.md): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. + +The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider runs one SDK turn and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated so far when the turn was cut short — a partial answer survives cancel and error paths. + +`dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit. + +## Stop-reason mapping + +The child reports its turn outcome as a structured `TurnEndReason` on `session.finished`; the provider maps it into the seam vocabulary. `completed` → `completed`, `max-tokens` → `max-tokens`, `aborted` → `aborted`; everything else — `error`, `rejected`, `interrupted`, `disposed`, a future variant, or a turn that never ran — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting. + +## Capabilities and context + +The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`/`persona` all false) and `inheritsParentContext: false`: the child is a fresh runtime in another process, and the only parent-derived input is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `providerName` | `sdk` | Registry name on `ctx.subagents`. | +| `command` | required | Executable spawned per run (the child runtime bin or packaged exe). | +| `args` | `[]` | Command arguments (typically the child's `cordis.yml` path). | +| `cwd` | parent session cwd | Working-directory override; same validation as [`subagent-acp`](../subagent-acp/README.md). | +| `provider` | `deepseek` | Provider route sent in the child's `initialize`. | +| `model` | `deepseek-v4-flash` | Model sent in the child's `initialize`. | +| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment (e.g. the child's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG`). | +| `shutdownTimeoutMs` | `1000` | Bound on the protocol `shutdown` exchange during dispose. | +| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. | +| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. | + +```yaml +- id: subagent-sdk + name: '@deepseek-ai/dsh-subagent-sdk' + config: + providerName: sdk + command: node + args: ['./packages/examples/jsonrpc-demo/lib/bin.js', './examples/jsonrpc-agent/cordis.yml'] + env: + DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: { provider: sdk, toolName: subagent, maxDepth: 'provider-managed' } +``` + +## Process boundary + +The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. The JSON-RPC wire is the real serialization boundary. + +The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). + +Keyless tests drive the SDK client package's scripted fake runtime over real stdio, including a Loader-composed e2e where the child is a real second harness runtime proving parent-session cwd inheritance end to end (`tests/loader-composition.e2e.ts`). + +## Model Experience + +### Child-agent request + +#### What the model sees + +The child runtime's model receives the standalone task as its user message plus that runtime's own configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. + +#### Token effect + +The child pays for an independent full context and its own multi-step history. These tokens never enter the parent's context. + +#### KV Cache effect + +Independent of the parent request cache. Each SDK child can reuse only prefixes identical under its own provider, model, composition, and history; child steps otherwise grow append-only. + +### Parent tool result, indirectly + +#### What the model sees + +Through `dsh-tool-subagent`, the parent receives only the child's final assistant text (or accumulated partial text) or that consumer's exact stop-reason error, not intermediate messages or tool traffic. + +#### Token effect + +Parent input grows only by the final result or error, which is data-dependent and retained until compaction. This provider adds no parent schema itself. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **A fresh runtime process per run** — no pooling; a harness runtime boots a full plugin tree, so per-run spawn cost is higher than the ACP backend's typical child. +- **No optional start-time capabilities** — the parent cannot enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the child's own `cordis.yml` instead. +- **The child's transcript stays in the child's own session root** — the parent log records only the delegation tool call/result (the seam's child-isolation rule); the streamed `session.event` channel is consumed for output extraction, not bridged into the parent log. +- **Local child processes only** — the resolved cwd is a local path; a remote runtime would need its own backend. diff --git a/packages/subagent/subagent-sdk/README.zh.md b/packages/subagent/subagent-sdk/README.zh.md new file mode 100644 index 0000000000..76eb4ffaf9 --- /dev/null +++ b/packages/subagent/subagent-sdk/README.zh.md @@ -0,0 +1,97 @@ +# @deepseek-ai/dsh-subagent-sdk + +[English](README.md) | 中文 + +SDK provider 把每个子代理作为一个完整的 DeepSeek Harness 运行时跑在全新子进程里,经由 [TypeScript SDK 客户端](../../sdk/sdk-client/README.md)走 stdio JSON-RPC 驱动。它是 [`subagent-acp`](../subagent-acp/README.md) 之外的第二个进程外后端,差异在线协议与子进程契约:ACP 后端能驱动任何 Agent Client Protocol 代理;本后端专门驱动 harness SDK 运行时(`dsh-jsonrpc-agent` bin 或打包可执行文件),因此子进程是一个完整的对等 harness——自有 `cordis.yml` 决定的组成、会话持久化、模型路由与工具。 + +## 启动与所有权 + +`start(request)` 先解析子进程工作目录,经 `DeepSeekHarness` 生成运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由)。因此履行意味着子运行时已就绪、所有权已移交调用方。生成、握手或发布前取消的失败只在子进程被收割之后拒绝;工作目录解析失败在生成任何东西之前拒绝。 + +工作目录的解析与 ACP 后端完全一致,经由共享的 [`subagent-subprocess` 助手](../subagent-subprocess/README.md):设置了 `cwd` 覆盖则用之(加载时校验一次),否则用发起委托的父会话 cwd——绝不用服务器进程自己的 cwd。解析出的路径同时成为子进程 cwd 与其 SDK 会话的工作区 cwd。 + +返回的 run id 铸造于父命名空间;子运行时的会话 id 只存在于子进程内部。发布之后,provider 跑一个 SDK 回合,并从子会话事件中读取答案:最后一条完整 `assistant/message`,或回合被截断时已累积的 `text-delta` 流——部分答案在取消与错误路径上都得以保留。 + +`dispose()` 幂等:先把结果就地定格为 `aborted`(线上没有 prompt 取消方法),再关闭运行时——一次有界的协议 `shutdown` 请求,随后是共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出。 + +## 停止原因映射 + +子进程在 `session.finished` 上以结构化 `TurnEndReason` 报告回合结局;provider 把它映射进接缝词汇表。`completed` → `completed`,`max-tokens` → `max-tokens`,`aborted` → `aborted`;其余一切——`error`、`rejected`、`interrupted`、`disposed`、未来变体、或根本没跑回合——映射为 `error`,不洁终止绝不报告为成功。发布后的传输层失败经 `onError` 诊断汇(接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`;接缝契约禁止 `result` 拒绝。 + +## 能力与上下文 + +Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilter`/`persona` 全为 false),且 `inheritsParentContext: false`:子进程是另一进程里的全新运行时,唯一来自父方的输入是工作区 cwd。基于本 provider 的 `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。 + +## 配置 + +| 键 | 默认 | 含义 | +|---|---|---| +| `providerName` | `sdk` | `ctx.subagents` 上的注册名。 | +| `command` | 必填 | 每次 run 生成的可执行文件(子运行时 bin 或打包 exe)。 | +| `args` | `[]` | 命令参数(通常是子进程的 `cordis.yml` 路径)。 | +| `cwd` | 父会话 cwd | 工作目录覆盖;校验规则与 [`subagent-acp`](../subagent-acp/README.md) 相同。 | +| `provider` | `deepseek` | 写入子进程 `initialize` 的 provider 路由。 | +| `model` | `deepseek-v4-flash` | 写入子进程 `initialize` 的模型。 | +| `env` | `{}` | 在凭据擦除后的父环境之上叠加的显式子环境(例如子进程自己的 `DEEPSEEK_API_KEY`,或 `DSH_CORDIS_CONFIG`)。 | +| `shutdownTimeoutMs` | `1000` | 处置期间协议 `shutdown` 交换的时限。 | +| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限。 | +| `disposeGraceMs` | `3000` | 终止后的退出确认窗口;POSIX 在 SIGTERM 之后、SIGKILL 之前也等待同样时长。 | + +```yaml +- id: subagent-sdk + name: '@deepseek-ai/dsh-subagent-sdk' + config: + providerName: sdk + command: node + args: ['./packages/examples/jsonrpc-demo/lib/bin.js', './examples/jsonrpc-agent/cordis.yml'] + env: + DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: { provider: sdk, toolName: subagent, maxDepth: 'provider-managed' } +``` + +## 进程边界 + +子环境由 [`buildChildEnv`](../subagent-subprocess/README.md) 构建:先移除形似凭据的环境变量,再应用显式 `config.env` 值。JSON-RPC 线就是真实的序列化边界。 + +本包没有默认导出。否则 Cordis loader 解包会隐藏具名 `inject` 元数据;见[事后分析 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 + +免密钥测试通过真实 stdio 驱动 SDK 客户端包的脚本化伪运行时,还包括一个 Loader 组合 e2e:子进程是真实的第二个 harness 运行时,端到端证明父会话 cwd 继承(`tests/loader-composition.e2e.ts`)。 + +## Model Experience + +### Child-agent request + +#### What the model sees + +子运行时的模型收到独立任务作为其用户消息,加上该运行时自己配置的系统提示、工具与全新会话。它收不到任何父对话。本 provider 不宣告可选启动期能力,因此本地服务会拒绝需要 persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略。 + +#### Token effect + +子进程支付一份独立的完整上下文与自己的多步历史。这些 token 绝不进入父上下文。 + +#### KV Cache effect + +独立于父请求缓存。每个 SDK 子进程只能复用在其自身 provider、模型、组成与历史下完全相同的前缀;子步骤在此之外只增不改。 + +### Parent tool result, indirectly + +#### What the model sees + +经由 `dsh-tool-subagent`,父方只收到子进程的最终助手文本(或累积的部分文本),或该消费者精确的停止原因错误——收不到中间消息与工具流量。 + +#### Token effect + +父输入只增长最终结果或错误,其大小依数据而定,保留至压缩。本 provider 自身不给父方增加任何 schema。 + +#### KV Cache effect + +只追加;新可见内容跟在可复用请求前缀之后,不使既有 KV 缓存条目失效。 + +## Known Limitations and Deferred Work + +- **每次 run 一个全新运行时进程** —— 无池化;harness 运行时要启动完整插件树,单次生成成本高于 ACP 后端的典型子进程。 +- **无可选启动期能力** —— 父方无法在子进程内强制 `outputSchema`、深度、工具过滤或 persona;请改为配置子进程自己的 `cordis.yml`。 +- **子进程的转录留在其自己的会话根** —— 父日志只记录委托工具调用/结果(接缝的子隔离规则);流式 `session.event` 通道只用于提取输出,不桥接进父日志。 +- **仅限本地子进程** —— 解析出的 cwd 是本地路径;远程运行时需要自己的后端。 diff --git a/packages/ui/jsonrpc/README.i18n.yaml b/packages/ui/jsonrpc/README.i18n.yaml index 49383b7872..b91eb86bde 100644 --- a/packages/ui/jsonrpc/README.i18n.yaml +++ b/packages/ui/jsonrpc/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 9297147b7a53739c46871b527ce51868eac1e244 -README.zh.md: 62596197b95729215408dfc1496c129ace6cbad4 +README.md: 96dae46c9c6ecce6643bb408a5e57c2db2275a83 +README.zh.md: 2c257b13a15ad3c6c1bf0c4dd44a04e308e8f0b0 diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 9297147b7a..96dae46c9c 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application. +The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; the transport and the named wire types live in [`dsh-sdk-protocol`](../../sdk/sdk-protocol/README.md), shared with the client SDKs; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application. ## Wiring diff --git a/packages/ui/jsonrpc/README.zh.md b/packages/ui/jsonrpc/README.zh.md index 62596197b9..2c257b13a1 100644 --- a/packages/ui/jsonrpc/README.zh.md +++ b/packages/ui/jsonrpc/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkServer`](src/server.ts) 持有协议方法和通知;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。 +`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkServer`](src/server.ts) 持有协议方法和通知;传输与具名线类型位于 [`dsh-sdk-protocol`](../../sdk/sdk-protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。 ## 组装 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8783218d1f..7fc07ffdfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -484,6 +484,9 @@ importers: '@deepseek-ai/dsh-subagent-fork': specifier: workspace:* version: link:../packages/subagent/subagent-fork + '@deepseek-ai/dsh-subagent-sdk': + specifier: workspace:* + version: link:../packages/subagent/subagent-sdk '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:* version: link:../packages/subagent/subagent-spawn diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1d944d31b0..86fc9cc189 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -80,6 +80,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, + 'packages/sdk/sdk-client': { kind: 'none', reason: 'Client-process library; the model surface lives in the spawned runtime\'s composed plugins.' }, + 'packages/sdk/sdk-protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' }, 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, From 441110f6daa16ae0dd712669981dc9a5ed8fe609 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:58:11 +0800 Subject: [PATCH 04/13] refactor(subagent-subprocess): shared provider skeleton for out-of-process backends The duplication gate flagged three ACP/SDK clones; the shared halves move into dsh-subagent-subprocess as provider.ts: NO_START_CAPABILITIES (frozen all-false advertisement), assertPositiveFinite (prefix-parameterized timing validation), settleRunResult (never-reject result settlement with contained onError sink and listener hygiene), and subprocessRunHandle (idempotent dispose publication). Both backends now compose these; the previously unreachable cancelled-rejection branch is directly unit-tested at the library level instead of v8-ignored in each backend. knip learns the subagent-sdk workspace (e2e entry outside vitest unit includes). --- knip.json | 10 ++ packages/subagent/subagent-acp/src/index.ts | 15 +- packages/subagent/subagent-acp/src/run.ts | 60 ++++---- packages/subagent/subagent-sdk/src/index.ts | 17 +-- packages/subagent/subagent-sdk/src/run.ts | 55 +++----- .../subagent-subprocess/README.i18n.yaml | 4 +- .../subagent/subagent-subprocess/README.md | 10 +- .../subagent/subagent-subprocess/README.zh.md | 10 +- .../subagent/subagent-subprocess/package.json | 5 + .../subagent/subagent-subprocess/src/index.ts | 1 + .../subagent-subprocess/src/provider.ts | 129 ++++++++++++++++++ .../tests/subagent-subprocess.spec.ts | 105 ++++++++++++++ .../subagent-subprocess/tsconfig.json | 6 + pnpm-lock.yaml | 9 ++ 14 files changed, 340 insertions(+), 96 deletions(-) create mode 100644 packages/subagent/subagent-subprocess/src/provider.ts diff --git a/knip.json b/knip.json index f49fc986d5..af53422eb6 100644 --- a/knip.json +++ b/knip.json @@ -606,6 +606,16 @@ "src/**/*.ts", "tests/**/*.ts" ] + }, + "packages/subagent/subagent-sdk": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] } } } diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 9d953d529a..d1b22eb4d9 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -10,7 +10,7 @@ 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 { assertPositiveFinite, NO_START_CAPABILITIES, 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' @@ -66,13 +66,6 @@ export const Config: z = z.object({ disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) -/** A dispose grace must be a positive finite number (it bounds the teardown wait). */ -function assertPositiveFinite(name: string, value: number): void { - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`subagent-acp: ${name} must be a positive finite number`) - } -} - /** The shape after schemastery applied the defaults (cwd has none). */ type ResolvedConfig = Required> & Pick @@ -82,7 +75,7 @@ type ResolvedConfig = Required> & Pick * a request needing any of them before `start` runs). */ class AcpProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false } + readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES // Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary. readonly inheritsParentContext = false @@ -110,8 +103,8 @@ class AcpProvider implements SubagentProvider { export function apply(ctx: Context, config: Config): void { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig - assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs) - assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs) + assertPositiveFinite('subagent-acp', 'disposeEofGraceMs', resolved.disposeEofGraceMs) + assertPositiveFinite('subagent-acp', '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-acp', resolved.cwd) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 730505df84..77f089dafd 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -26,7 +26,7 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' +import { buildChildEnv, disposeChildProcess, settleRunResult, spawnFailure, subprocessRunHandle } from '@deepseek-ai/dsh-subagent-subprocess' /** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' @@ -267,48 +267,36 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id') const remoteSessionId = sessionId - const result: Promise = (async (): Promise => { - try { - // Race the remote turn against local cancellation. + // Race the remote turn against local cancellation; the shared settlement + // flattens failures under the seam's never-reject contract. + const result: Promise = settleRunResult({ + attempt: async () => { const prompt = async (): Promise => { // The startup phase cannot fulfill without assigning the session id. const promptResult = await conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) }) return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } } - return await Promise.race([ + return Promise.race([ prompt(), cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })), ]) - } catch (error: unknown) { - // Cover a process 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 | undefined - return { - id, - localAgent: undefined, - result, - dispose(): Promise { - if (disposal !== undefined) return disposal - request.signal.removeEventListener('abort', onAbort) - requestCancel() - // The shared platform-aware ladder awaits exit. ACP normally quiesces from - // stdin EOF, including the final flush, so this backend uses a wider EOF - // grace before process termination escalates. - disposal = disposeProcess() - return disposal }, - } + collectOutput, + cancelled: () => flags.cancelled, + onError: spec.onError, + signal: request.signal, + onAbort, + }) + + // The shared platform-aware ladder awaits exit. ACP normally quiesces from + // stdin EOF, including the final flush, so this backend uses a wider EOF + // grace before process termination escalates. + return subprocessRunHandle({ + id, + result, + signal: request.signal, + onAbort, + requestCancel, + teardown: disposeProcess, + }) } diff --git a/packages/subagent/subagent-sdk/src/index.ts b/packages/subagent/subagent-sdk/src/index.ts index d61fd5c49e..7165ad05ad 100644 --- a/packages/subagent/subagent-sdk/src/index.ts +++ b/packages/subagent/subagent-sdk/src/index.ts @@ -13,7 +13,7 @@ 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 { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess' import { DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, @@ -79,13 +79,6 @@ export const Config: z = z.object({ 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> & Pick @@ -95,7 +88,7 @@ type ResolvedConfig = Required> & Pick * 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 } + readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES // Context contract: an out-of-process SDK child starts fresh — no parent conversation crosses the process boundary. readonly inheritsParentContext = false @@ -125,9 +118,9 @@ class SdkProvider implements SubagentProvider { 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) + assertPositiveFinite('subagent-sdk', 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertPositiveFinite('subagent-sdk', 'disposeEofGraceMs', resolved.disposeEofGraceMs) + assertPositiveFinite('subagent-sdk', '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) diff --git a/packages/subagent/subagent-sdk/src/run.ts b/packages/subagent/subagent-sdk/src/run.ts index 1fedfd20b5..7043a9f0e4 100644 --- a/packages/subagent/subagent-sdk/src/run.ts +++ b/packages/subagent/subagent-sdk/src/run.ts @@ -14,7 +14,7 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk- 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' +import { buildChildEnv, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent-subprocess' /** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */ export interface SdkRunSpec { @@ -175,43 +175,32 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe return text.length > 0 ? [{ type: 'text', text }] : [] } - const result: Promise = (async (): Promise => { - try { + // Race the child turn against local cancellation; the shared settlement + // flattens failures under the seam's never-reject contract. + const result: Promise = settleRunResult({ + attempt: async () => { 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 | undefined - return { - id, - localAgent: undefined, - result, - dispose(): Promise { - 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 }, - } + collectOutput, + cancelled: () => flags.cancelled, + onError: spec.onError, + signal: request.signal, + onAbort, + }) + + // There is no wire-level prompt cancel: dispose settles the result locally, + // then the bounded shutdown request + dispose ladder tears the child down. + return subprocessRunHandle({ + id, + result, + signal: request.signal, + onAbort, + requestCancel, + teardown: () => harness.close(), + }) } diff --git a/packages/subagent/subagent-subprocess/README.i18n.yaml b/packages/subagent/subagent-subprocess/README.i18n.yaml index 35efba2b98..d12eea7139 100644 --- a/packages/subagent/subagent-subprocess/README.i18n.yaml +++ b/packages/subagent/subagent-subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 6ae1778af1ca38a6c49c7f462e536a9c16c7e6bb -README.zh.md: 01847710df7c12ace7f87e45abc8e83958469740 +README.md: b41b5d2b3ca50f5c117e4a0f848c232e83e741b8 +README.zh.md: 5b017f23f71405966d84f409c6d616a8a5db52a3 diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index 6ae1778af1..b41b5d2b3c 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md). +Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md) and the [SDK backend](../subagent-sdk/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, resolve the child's working directory, publish the seam run handle, and isolate the child from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md). Every tunable is a **parameter**: the dispose ladder takes its grace periods per call, the config-dir helper takes an optional pinned path. Defaults live in each consuming plugin's Config (defaulted, validated fields changeable from `cordis.yml`), never in this library. @@ -28,6 +28,14 @@ The two graces (`DisposeLadderGraces`) come from the consuming plugin's `dispose The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child. +### `assertUsableCwd` / `validateConfiguredCwd` / `resolveChildCwd` + +Child working-directory resolution, shared verbatim by the ACP and SDK backends: a configured `cwd` override is validated ONCE at load (`validateConfiguredCwd` — rejects the empty string, resolves a relative path against the harness launch directory, requires an enterable directory), and `resolveChildCwd` applies it per start, else validates the delegating parent session's cwd — never the server process's own cwd, because one server process serves many sessions. `assertUsableCwd` is the underlying probe: absolute, existing, and searchable (`X_OK` — what a subprocess cwd actually needs; a mode-600 directory passes `isDirectory()` but fails spawn with EACCES). Every diagnostic is prefixed with the consuming plugin's name. + +### `NO_START_CAPABILITIES` / `settleRunResult` / `subprocessRunHandle` + +The provider-side skeleton every out-of-process backend shares. `NO_START_CAPABILITIES` is the frozen all-false advertisement (an out-of-process child cannot honor parent-enforced start features, so the service rejects such requests before `start`). `settleRunResult` settles the run result under the seam's never-reject contract: an attempt rejection reads as `aborted` when local cancellation already settled, else flattens to `stopReason: 'error'` through a throw-contained diagnostic sink, always removing the abort listener. `subprocessRunHandle` publishes the seam handle with idempotent dispose: remove the listener, settle local cancellation, then await the backend's teardown to actual exit. + ### `createIsolatedConfigDir(prefix, pinnedPath?)` A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose. diff --git a/packages/subagent/subagent-subprocess/README.zh.md b/packages/subagent/subagent-subprocess/README.zh.md index 01847710df..5b017f23f7 100644 --- a/packages/subagent/subagent-subprocess/README.zh.md +++ b/packages/subagent/subagent-subprocess/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -用于**进程外 subagent 后端** 的共享机制:这类提供方会把外部 agent(智能体)作为子进程派生,例如 [ACP 后端](../subagent-acp/README.md)。这是纯库(无提供方、无注册、无 Config),提供所有「派生 CLI 子进程」后端都需要的机制:阻止父级部署凭据进入子进程、把子进程清理至完全停稳,以及将子进程与宿主用户的磁盘 CLI 状态隔离。设计理由见 [Claude Code / Codex subagent 后端 Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md)。 +用于**进程外 subagent 后端** 的共享机制:这类提供方会把外部 agent(智能体)作为子进程派生,例如 [ACP 后端](../subagent-acp/README.md)和 [SDK 后端](../subagent-sdk/README.md)。这是纯库(无提供方、无注册、无 Config),提供所有「派生 CLI 子进程」后端都需要的机制:阻止父级部署凭据进入子进程、把子进程清理至完全停稳、解析子进程工作目录、发布接缝 run 句柄,以及将子进程与宿主用户的磁盘 CLI 状态隔离。设计理由见 [Claude Code / Codex subagent 后端 Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md)。 每个可调项都是**参数**:dispose(资源释放)阶梯每次调用时接收宽限时间,配置目录辅助函数接收可选的固定路径。默认值位于各个消费插件的 Config 中(带默认值且经过校验的字段,可从 `cordis.yml` 修改),绝不位于本库。 @@ -28,6 +28,14 @@ 退出等待逻辑位于该阶梯内部。无论结算结果如何,它们都会清理自己的 timer 和监听器,因此升级过程不会在子进程上累积监听器。 +### `assertUsableCwd` / `validateConfiguredCwd` / `resolveChildCwd` + +子进程工作目录解析,被 ACP 与 SDK 后端逐字共享:配置的 `cwd` 覆盖在加载时校验一次(`validateConfiguredCwd`——拒绝空字符串、把相对路径按 harness 启动目录解析、要求可进入的目录),`resolveChildCwd` 在每次 start 应用它,否则校验发起委托的父会话 cwd——绝不用服务器进程自己的 cwd,因为一个服务器进程服务多个会话。`assertUsableCwd` 是底层探针:绝对、存在且可搜索(`X_OK`——子进程 cwd 真正需要的权限;mode-600 目录能过 `isDirectory()` 却让 spawn 以 EACCES 失败)。所有诊断都带消费插件名前缀。 + +### `NO_START_CAPABILITIES` / `settleRunResult` / `subprocessRunHandle` + +每个进程外后端共享的 provider 侧骨架。`NO_START_CAPABILITIES` 是冻结的全 false 能力宣告(进程外子进程无法执行父方强制的启动期特性,服务会在 `start` 之前拒绝此类请求)。`settleRunResult` 在接缝的绝不拒绝契约下定格 run 结果:尝试的拒绝在本地取消已定格时读作 `aborted`,否则经吞掉自身异常的诊断汇压平为 `stopReason: 'error'`,并总是移除 abort 监听器。`subprocessRunHandle` 发布幂等 dispose 的接缝句柄:移除监听器、定格本地取消,然后等待后端的拆除直至真正退出。 + ### `createIsolatedConfigDir(prefix, pinnedPath?)` 为外部 CLI 子进程创建每次运行独立的隔离配置目录(`CLAUDE_CONFIG_DIR` / `CODEX_HOME` 式重定向的目标),使子进程行为只取决于部署配置,绝不取决于宿主上任何 `~/.claude` / `~/.codex` 式状态。返回一个 `IsolatedConfigDir` 句柄:`path` 写入子进程环境,`remove()` 在 dispose 时运行。 diff --git a/packages/subagent/subagent-subprocess/package.json b/packages/subagent/subagent-subprocess/package.json index bd573b3c0c..a57b473823 100644 --- a/packages/subagent/subagent-subprocess/package.json +++ b/packages/subagent/subagent-subprocess/package.json @@ -28,10 +28,15 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^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" } } diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index e54df67398..4814284e00 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' export * from './cwd.ts' +export * from './provider.ts' /** * Credential-shaped ambient env vars are NOT forwarded to a child by default diff --git a/packages/subagent/subagent-subprocess/src/provider.ts b/packages/subagent/subagent-subprocess/src/provider.ts new file mode 100644 index 0000000000..32679d2a26 --- /dev/null +++ b/packages/subagent/subagent-subprocess/src/provider.ts @@ -0,0 +1,129 @@ +/** + * Shared provider-side vocabulary for out-of-process subagent backends: the + * no-capabilities advertisement, timing-bound validation for the dispose + * ladder's graces, and the standard run-handle publication that owns dispose + * idempotence and abort-listener hygiene. + * + * @module @deepseek-ai/dsh-subagent-subprocess/provider + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from '@deepseek-ai/dsh-subagent' + +/** + * The capability advertisement of an out-of-process backend: NONE. A child in + * another process cannot honor parent-enforced start features + * (`outputSchema`/`maxDepth`/`toolFilter`/`persona`), so the service rejects a + * request needing any of them before `start` runs — never accepted-then-ignored. + */ +export const NO_START_CAPABILITIES: SubagentCapabilities = Object.freeze({ + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, +}) + +/** + * Assert a configured timing bound is a positive finite number (it bounds a + * teardown or shutdown wait; zero, negative, or NaN would skip or wedge it). + * @param prefix - the consuming plugin's diagnostic prefix (e.g. `subagent-acp`). + * @param name - the config field name, for the diagnostic. + * @param value - the configured value. + */ +export function assertPositiveFinite(prefix: string, name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${prefix}: ${name} must be a positive finite number`) + } +} + +/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */ +function toError(value: unknown): Error { + // The rejecting surfaces (wire clients, spawn error events) only throw + // `Error`s; the `String(value)` arm is a defensive fallback for a non-Error + // throw the typed surfaces cannot produce. + /* v8 ignore next */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** Inputs to {@link settleRunResult}. */ +export interface RunResultSettlement { + /** The turn attempt (typically racing local cancellation); returns the terminal result. */ + attempt: () => Promise + /** Snapshot of the child output streamed so far (a partial answer survives failure). */ + collectOutput: () => ContentBlock[] + /** Whether local cancellation settled (an in-flight rejection then reads as `aborted`). */ + cancelled: () => boolean + /** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */ + onError?: ((error: Error, stopReason: SubagentStopReason) => void) | undefined + /** The request's cancellation signal (the listener is removed at settlement). */ + signal: AbortSignal + /** The abort listener registered on {@link signal} at start. */ + onAbort: () => void +} + +/** + * Settle an out-of-process run result under the seam contract: `result` never + * rejects after publication. A rejection from the attempt resolves as + * `aborted` when cancellation already settled locally, else it is flattened + * to `stopReason: 'error'` through the contained diagnostic sink; the abort + * listener is removed on every path. + * @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring. + * @returns the terminal result (never a rejection). + */ +export async function settleRunResult(parts: RunResultSettlement): Promise { + try { + return await parts.attempt() + } catch (error: unknown) { + // Cover a rejection already queued when cancellation arrives. + if (parts.cancelled()) return { output: parts.collectOutput(), stopReason: 'aborted' } + // Flatten post-publication transport failures while preserving diagnostics. + try { + parts.onError?.(toError(error), 'error') + } catch { + // The diagnostic sink cannot reject the run result. + } + return { output: parts.collectOutput(), stopReason: 'error' } + } finally { + parts.signal.removeEventListener('abort', parts.onAbort) + } +} + +/** Inputs to {@link subprocessRunHandle}. */ +export interface SubprocessRunHandleParts { + /** The parent-scoped run id. */ + id: SubagentRun['id'] + /** The flattened, never-rejecting result (the seam contract). */ + result: Promise + /** The request's cancellation signal (the listener is removed on dispose). */ + signal: AbortSignal + /** The abort listener registered on {@link signal} at start. */ + onAbort: () => void + /** Settle local cancellation so {@link result} resolves without the child. */ + requestCancel: () => void + /** Tear the child process down to quiescence (backend-owned ladder). */ + teardown: () => Promise +} + +/** + * Publish the seam run handle for an out-of-process child. `dispose()` is + * idempotent (one memoized teardown): it removes the abort listener, settles + * local cancellation — there is no assumption the child cooperates — and then + * awaits the backend's teardown to actual exit. + * @param parts - the run identity, result, cancellation wiring, and teardown. + * @returns the seam run handle (`localAgent` is `undefined` for remote runs). + */ +export function subprocessRunHandle(parts: SubprocessRunHandleParts): SubagentRun { + let disposal: Promise | undefined + return { + id: parts.id, + localAgent: undefined, + result: parts.result, + dispose(): Promise { + if (disposal !== undefined) return disposal + parts.signal.removeEventListener('abort', parts.onAbort) + parts.requestCancel() + disposal = parts.teardown() + return disposal + }, + } +} diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index d674937e92..5c87edc99d 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -9,8 +9,12 @@ import { buildChildEnv, createIsolatedConfigDir, disposeChildProcess, + NO_START_CAPABILITIES, + settleRunResult, spawnFailure, + subprocessRunHandle, } from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' // `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm // failures are not portable and disappear under root, so this is the sanctioned filesystem seam. @@ -387,3 +391,104 @@ describe('createIsolatedConfigDir', () => { } }) }) + +describe('NO_START_CAPABILITIES', () => { + it('advertises nothing and is frozen (shared by every out-of-process backend)', () => { + expect(NO_START_CAPABILITIES).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false }) + expect(Object.isFrozen(NO_START_CAPABILITIES)).toBe(true) + }) +}) + +describe('settleRunResult', () => { + const wiring = () => { + const controller = new AbortController() + const onAbort = vi.fn() + controller.signal.addEventListener('abort', onAbort) + return { controller, onAbort } + } + + it('passes a successful attempt through and removes the abort listener', async () => { + const { controller, onAbort } = wiring() + const result = await settleRunResult({ + attempt: async () => ({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }), + collectOutput: () => [], + cancelled: () => false, + signal: controller.signal, + onAbort, + }) + expect(result.stopReason).toBe('completed') + controller.abort() + // The listener was removed at settlement, so the abort never reaches it. + expect(onAbort).not.toHaveBeenCalled() + }) + + it('reads an in-flight rejection as aborted when cancellation already settled', async () => { + const { controller, onAbort } = wiring() + const result = await settleRunResult({ + attempt: async () => { throw new Error('pipe torn mid-cancel') }, + collectOutput: () => [{ type: 'text', text: 'partial' }], + cancelled: () => true, + signal: controller.signal, + onAbort, + }) + expect(result).toEqual({ output: [{ type: 'text', text: 'partial' }], stopReason: 'aborted' }) + }) + + it('flattens a failure through a contained onError sink', async () => { + const { controller, onAbort } = wiring() + const seen: string[] = [] + const result = await settleRunResult({ + attempt: async () => { throw new Error('transport died') }, + collectOutput: () => [], + cancelled: () => false, + onError: (error, stopReason) => { + seen.push(`${stopReason}:${error.message}`) + throw new Error('sink failure must be contained') + }, + signal: controller.signal, + onAbort, + }) + expect(result.stopReason).toBe('error') + expect(seen).toEqual(['error:transport died']) + }) + + it('flattens a failure without a sink', async () => { + const { controller, onAbort } = wiring() + const result = await settleRunResult({ + attempt: async () => { throw new Error('no sink configured') }, + collectOutput: () => [], + cancelled: () => false, + signal: controller.signal, + onAbort, + }) + expect(result.stopReason).toBe('error') + }) +}) + +describe('subprocessRunHandle', () => { + it('publishes an idempotent dispose that cancels locally and awaits teardown', async () => { + const controller = new AbortController() + const onAbort = vi.fn() + controller.signal.addEventListener('abort', onAbort) + const requestCancel = vi.fn() + const teardown = vi.fn(() => Promise.resolve()) + const run = subprocessRunHandle({ + id: SessionId('run-1'), + result: Promise.resolve({ output: [], stopReason: 'completed' }), + signal: controller.signal, + onAbort, + requestCancel, + teardown, + }) + expect(run.localAgent).toBeUndefined() + expect(String(run.id)).toBe('run-1') + const disposal = run.dispose() + expect(run.dispose()).toBe(disposal) + await disposal + expect(requestCancel).toHaveBeenCalledTimes(1) + expect(teardown).toHaveBeenCalledTimes(1) + controller.abort() + // dispose removed the abort listener before cancelling. + expect(onAbort).not.toHaveBeenCalled() + }) +}) diff --git a/packages/subagent/subagent-subprocess/tsconfig.json b/packages/subagent/subagent-subprocess/tsconfig.json index d970a00263..082bd8f821 100644 --- a/packages/subagent/subagent-subprocess/tsconfig.json +++ b/packages/subagent/subagent-subprocess/tsconfig.json @@ -8,6 +8,12 @@ "src" ], "references": [ + { + "path": "../../llm/llm" + }, + { + "path": "../subagent" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fc07ffdfd..a8ef9625e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3821,6 +3821,15 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) From 1ad0f26b928054530ef45c82c830bc7d92c16f68 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:26:38 +0800 Subject: [PATCH 05/13] test(subagent-sdk): make the partial-output test deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aborted-mid-stream case raced the abort against the child's chunk delivery across two pipes and lost under full-suite load. Replace it with a same-pipe ordering probe: the fake streams one text-delta chunk and then answers the prompt with a malformed (non-accepted) result, so frame order guarantees the chunk precedes the failure — the accumulated partial text must survive into the error result. Same code path (collectOutput without a complete assistant/message), no timing window. --- packages/sdk/sdk-client/tests/fake-runtime.ts | 12 +++---- .../subagent-sdk/tests/subagent-sdk.spec.ts | 33 +++++++------------ 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/packages/sdk/sdk-client/tests/fake-runtime.ts b/packages/sdk/sdk-client/tests/fake-runtime.ts index 622f2605a7..0c8a928c34 100644 --- a/packages/sdk/sdk-client/tests/fake-runtime.ts +++ b/packages/sdk/sdk-client/tests/fake-runtime.ts @@ -21,9 +21,9 @@ * 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_STREAM_THEN_MALFORMED`: stream a text chunk for the prompt, then + * answer `{}` (no accepted) — same-pipe ordering makes the chunk arrive + * before the protocol failure (partial-output retention probe). * - `FAKE_IGNORE_EOF` + `FAKE_SIGTERM_FILE`: keep running after stdin EOF; touch the file on SIGTERM (ladder probe). * - `FAKE_TRAP_SIGTERM`: with `FAKE_IGNORE_EOF`, survive SIGTERM too (SIGKILL-rung probe). * - `FAKE_EXIT_BEFORE_INIT`: exit 3 immediately (spawn-then-die probe). @@ -151,10 +151,10 @@ reader.on('line', (line) => { respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }) return case 'session/prompt': { - if (env.FAKE_STREAM_THEN_HANG !== undefined) { + if (env.FAKE_STREAM_THEN_MALFORMED !== 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') + event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } }) + respond({}) return } if (env.FAKE_HANG_PROMPT !== undefined) return diff --git a/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts b/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts index 2198f02693..a2d49de343 100644 --- a/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts +++ b/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts @@ -209,27 +209,18 @@ describe('dsh-subagent-sdk provider', () => { } }) - 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('keeps accumulated streamed text when the turn is cut short before a full message', async () => { + // The fake streams one text-delta chunk and then violates the protocol on + // the same pipe; frame order guarantees the chunk was dispatched before + // the failure settles, so the accumulated partial text (no complete + // assistant/message ever arrived) must survive into the error result. + const ctx = await setup({ FAKE_STREAM_THEN_MALFORMED: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 }) + const run = await ctx.subagents.start('sdk', request()) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(text(result.output)).toBe('streamed then cut short') + await run.dispose() + await ctx.fiber.dispose() }) it('dispose cancels a hung child locally and reaps it', async () => { From 2b31e236f9e81f225a6f5ecad45536a4fe52bfe5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:33:12 +0800 Subject: [PATCH 06/13] chore: converge with master's gates after merge - regenerate docs/module-graph.md for the three SDK packages (the stale graph was the node24/static CI failure) - rewrite the four recorded SDK snapshot session fixtures into master's new canonical packed-row layout via migrate:packed-session-fixtures; keyless replay verified against the packed fixtures --- docs/module-graph.md | 20 +-- .../tests/snapshots/bash-tool/session.jsonl | 75 +---------- .../snapshots/subagent-spawn/session.1.jsonl | 21 +--- .../snapshots/subagent-spawn/session.jsonl | 118 +----------------- .../tests/snapshots/text-turn/session.jsonl | 25 +--- 5 files changed, 23 insertions(+), 236 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index d3aeed4e04..c8836b7a91 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -235,7 +235,6 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_skill --> pkg_invariants - pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants @@ -717,12 +716,6 @@ flowchart TD pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow - pkg_subagent_acp --> pkg_agent - pkg_subagent_acp --> pkg_invariants - pkg_subagent_acp --> pkg_llm - pkg_subagent_acp --> pkg_session - pkg_subagent_acp --> pkg_subagent - pkg_subagent_acp --> pkg_subagent_subprocess pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -730,6 +723,9 @@ flowchart TD pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools + pkg_subagent_subprocess --> pkg_invariants + pkg_subagent_subprocess --> pkg_llm + pkg_subagent_subprocess --> pkg_subagent pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm @@ -801,6 +797,12 @@ flowchart TD pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow + pkg_subagent_acp --> pkg_agent + pkg_subagent_acp --> pkg_invariants + pkg_subagent_acp --> pkg_llm + pkg_subagent_acp --> pkg_session + pkg_subagent_acp --> pkg_subagent + pkg_subagent_acp --> pkg_subagent_subprocess pkg_subagent_fork --> pkg_agent pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session @@ -878,7 +880,6 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | -| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | @@ -1002,8 +1003,8 @@ flowchart TD | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | @@ -1011,6 +1012,7 @@ flowchart TD | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl index 5d0e8aa7a9..dc2c7ee102 100644 --- a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl @@ -5,55 +5,9 @@ {"type":"step/start","seq":3,"time":1785097395908,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785097395909,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097396437,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1785097396438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1785097396657,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1785097396679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1785097396680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1785097396680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1785097396680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":12,"time":1785097396680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":13,"time":1785097396681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":14,"time":1785097396705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":15,"time":1785097396730,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":16,"time":1785097396730,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":17,"time":1785097396730,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":18,"time":1785097396755,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":19,"time":1785097396756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} -{"type":"assistant/chunk","seq":20,"time":1785097396780,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}} -{"type":"assistant/chunk","seq":21,"time":1785097396781,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} -{"type":"assistant/chunk","seq":22,"time":1785097396781,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785097396438,"data":{"turn":1,"step":1,"index":0,"dt":[219,22,1,0,0,0,1,24,25,0,0,25,1,24,1,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," bash"," command"," and"," reply"," with"," its"," stdout"," only","."]}} {"type":"assistant/chunk","seq":23,"time":1785097396856,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":24,"time":1785097396857,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":25,"time":1785097396857,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":26,"time":1785097396857,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":27,"time":1785097396881,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":28,"time":1785097396882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":29,"time":1785097396882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":30,"time":1785097396882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":31,"time":1785097396907,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":32,"time":1785097396907,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" d"}}} -{"type":"assistant/chunk","seq":33,"time":1785097396907,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":34,"time":1785097396907,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-s"}}} -{"type":"assistant/chunk","seq":35,"time":1785097396907,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"dk"}}} -{"type":"assistant/chunk","seq":36,"time":1785097396908,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-proof"}}} -{"type":"assistant/chunk","seq":37,"time":1785097396932,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-"}}} -{"type":"assistant/chunk","seq":38,"time":1785097396932,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"739"}}} -{"type":"assistant/chunk","seq":39,"time":1785097396932,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":40,"time":1785097396933,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1785097396957,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":42,"time":1785097396958,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1785097396983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":44,"time":1785097396983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1785097396983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":46,"time":1785097396983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1785097397008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"Run"}}} -{"type":"assistant/chunk","seq":48,"time":1785097397008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":49,"time":1785097397008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" echo"}}} -{"type":"assistant/chunk","seq":50,"time":1785097397033,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":51,"time":1785097397034,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" as"}}} -{"type":"assistant/chunk","seq":52,"time":1785097397034,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" requested"}}} -{"type":"assistant/chunk","seq":53,"time":1785097397034,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":54,"time":1785097397059,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"}"}}} +{"type":"tool-call-chunks","seq0":24,"time0":1785097396857,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,1,0,0,25,0,0,0,0,1,24,0,0,1,24,1,25,0,0,0,25,0,0,25,1,0,0,25],"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," d","sh","-s","dk","-proof","-","739","1","\"",", ","\"","description","\"",": ","\"","Run"," the"," echo"," command"," as"," requested","\"","}"]}} {"type":"assistant/chunk","seq":55,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific bash command and reply with its stdout only."}}}} {"type":"assistant/chunk","seq":56,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}}}} {"type":"assistant/chunk","seq":57,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}}}} @@ -64,30 +18,9 @@ {"type":"step/end","seq":62,"time":1785097397145,"data":{"turn":1,"step":1}} {"type":"step/start","seq":63,"time":1785097397145,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":64,"time":1785097398036,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":65,"time":1785097398037,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":66,"time":1785097398255,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":67,"time":1785097398280,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" produced"}}} -{"type":"assistant/chunk","seq":68,"time":1785097398281,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":69,"time":1785097398281,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}} -{"type":"assistant/chunk","seq":70,"time":1785097398305,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":71,"time":1785097398306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":72,"time":1785097398306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":73,"time":1785097398306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":74,"time":1785097398331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":75,"time":1785097398331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":76,"time":1785097398331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":77,"time":1785097398357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":78,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}} -{"type":"assistant/chunk","seq":79,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"reasoning-chunks","seq0":65,"time0":1785097398037,"data":{"turn":1,"step":2,"index":0,"dt":[218,25,1,0,24,1,0,0,25,0,0,26,1,0],"texts":["The"," command"," produced"," the"," expected"," output","."," I","'ll"," reply"," with"," just"," that"," stdout","."]}} {"type":"assistant/chunk","seq":80,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":81,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"d"}}} -{"type":"assistant/chunk","seq":82,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"sh"}}} -{"type":"assistant/chunk","seq":83,"time":1785097398382,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-s"}}} -{"type":"assistant/chunk","seq":84,"time":1785097398382,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"dk"}}} -{"type":"assistant/chunk","seq":85,"time":1785097398382,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-proof"}}} -{"type":"assistant/chunk","seq":86,"time":1785097398382,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-"}}} -{"type":"assistant/chunk","seq":87,"time":1785097398383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"739"}}} -{"type":"assistant/chunk","seq":88,"time":1785097398383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"1"}}} +{"type":"text-chunks","seq0":81,"time0":1785097398358,"data":{"turn":1,"step":2,"index":1,"dt":[0,24,0,0,0,1,0],"texts":["d","sh","-s","dk","-proof","-","739","1"]}} {"type":"assistant/chunk","seq":89,"time":1785097398408,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."}}}} {"type":"assistant/chunk","seq":90,"time":1785097398408,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"dsh-sdk-proof-7391"}}}} {"type":"assistant/chunk","seq":91,"time":1785097398409,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 05e64e6149..531cc19c6f 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -5,26 +5,9 @@ {"type":"step/start","seq":3,"time":1785097410284,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785097410284,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097410836,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1785097410836,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1785097411011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1785097411011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1785097411011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1785097411035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":12,"time":1785097411036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":13,"time":1785097411036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":14,"time":1785097411036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":15,"time":1785097411036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}} -{"type":"assistant/chunk","seq":16,"time":1785097411061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":17,"time":1785097411061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":18,"time":1785097411062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}} -{"type":"assistant/chunk","seq":19,"time":1785097411062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785097410836,"data":{"turn":1,"step":1,"index":0,"dt":[149,26,0,0,24,1,0,0,0,25,0,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} {"type":"assistant/chunk","seq":20,"time":1785097411113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1785097411113,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}} -{"type":"assistant/chunk","seq":22,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" answer"}}} -{"type":"assistant/chunk","seq":23,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}} -{"type":"assistant/chunk","seq":24,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}} -{"type":"assistant/chunk","seq":25,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"text-chunks","seq0":21,"time0":1785097411113,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,0,0],"texts":["child"," answer"," ","42","."]}} {"type":"assistant/chunk","seq":26,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}} {"type":"assistant/chunk","seq":27,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} {"type":"assistant/chunk","seq":28,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl index 858c05c3e2..a4a1696bba 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -5,90 +5,9 @@ {"type":"step/start","seq":3,"time":1785097408908,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785097408908,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097409495,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1785097409496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1785097409666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1785097409691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1785097409692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1785097409692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1785097409692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} -{"type":"assistant/chunk","seq":12,"time":1785097409692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":13,"time":1785097409692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":14,"time":1785097409716,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Use"}}} -{"type":"assistant/chunk","seq":15,"time":1785097409716,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":16,"time":1785097409717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":17,"time":1785097409717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":18,"time":1785097409717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":19,"time":1785097409717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":20,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} -{"type":"assistant/chunk","seq":21,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":22,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} -{"type":"assistant/chunk","seq":23,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} -{"type":"assistant/chunk","seq":24,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":25,"time":1785097409743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" probe"}}} -{"type":"assistant/chunk","seq":26,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'"}}} -{"type":"assistant/chunk","seq":27,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":28,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} -{"type":"assistant/chunk","seq":29,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} -{"type":"assistant/chunk","seq":30,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} -{"type":"assistant/chunk","seq":31,"time":1785097409769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":32,"time":1785097409799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":33,"time":1785097409799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":34,"time":1785097409799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":35,"time":1785097409800,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":36,"time":1785097409800,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":37,"time":1785097409800,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}} -{"type":"assistant/chunk","seq":38,"time":1785097409820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}} -{"type":"assistant/chunk","seq":39,"time":1785097409821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":40,"time":1785097409821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":41,"time":1785097409821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}} -{"type":"assistant/chunk","seq":42,"time":1785097409849,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":43,"time":1785097409849,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":44,"time":1785097409850,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":45,"time":1785097409850,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":46,"time":1785097409850,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":47,"time":1785097409850,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":48,"time":1785097409873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}} -{"type":"assistant/chunk","seq":49,"time":1785097409874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":50,"time":1785097409874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":51,"time":1785097409874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":52,"time":1785097409874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":53,"time":1785097409874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":54,"time":1785097409899,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":55,"time":1785097409900,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":56,"time":1785097409925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":57,"time":1785097409951,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" step"}}} -{"type":"assistant/chunk","seq":58,"time":1785097409952,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":59,"time":1785097409952,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" step"}}} -{"type":"assistant/chunk","seq":60,"time":1785097409952,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785097409496,"data":{"turn":1,"step":1,"index":0,"dt":[170,25,1,0,0,0,0,24,0,1,0,0,0,26,0,0,0,0,0,26,0,0,0,0,0,30,0,0,1,0,0,20,1,0,0,28,0,1,0,0,0,23,1,0,0,0,0,25,1,25,26,1,0,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Use"," the"," sub","agent"," tool"," exactly"," once"," with"," description"," '","echo"," probe","'"," and"," prompt"," '","Reply"," with"," exactly",":"," child"," answer"," ","42",".'\n","2","."," Then"," reply"," with"," the"," sub","agent","'s"," final"," answer"," verb","atim",".\n\n","Let"," me"," do"," this"," step"," by"," step","."]}} {"type":"assistant/chunk","seq":61,"time":1785097410031,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":62,"time":1785097410031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":63,"time":1785097410056,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":64,"time":1785097410057,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":65,"time":1785097410057,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":66,"time":1785097410057,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":67,"time":1785097410057,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":68,"time":1785097410083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":69,"time":1785097410083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":70,"time":1785097410083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" probe"}}} -{"type":"assistant/chunk","seq":71,"time":1785097410083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":72,"time":1785097410134,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":73,"time":1785097410135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":74,"time":1785097410135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":75,"time":1785097410135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":76,"time":1785097410135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":77,"time":1785097410135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":78,"time":1785097410161,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":79,"time":1785097410162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":80,"time":1785097410162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":81,"time":1785097410162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":82,"time":1785097410162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":83,"time":1785097410187,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" child"}}} -{"type":"assistant/chunk","seq":84,"time":1785097410188,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" answer"}}} -{"type":"assistant/chunk","seq":85,"time":1785097410188,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" "}}} -{"type":"assistant/chunk","seq":86,"time":1785097410188,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"42"}}} -{"type":"assistant/chunk","seq":87,"time":1785097410213,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":88,"time":1785097410214,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":89,"time":1785097410214,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"}"}}} +{"type":"tool-call-chunks","seq0":62,"time0":1785097410031,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,26,0,0,0,51,1,0,0,0,0,26,1,0,0,0,25,1,0,0,25,1,0],"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","args":["","{","\"","description","\"",": ","\"","echo"," probe","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly",":"," child"," answer"," ","42",".","\"","}"]}} {"type":"assistant/chunk","seq":90,"time":1785097410271,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once with description 'echo probe' and prompt 'Reply with exactly: child answer 42.'\n2. Then reply with the subagent's final answer verbatim.\n\nLet me do this step by step."}}}} {"type":"assistant/chunk","seq":91,"time":1785097410272,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}}}} {"type":"assistant/chunk","seq":92,"time":1785097410272,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}}}} @@ -99,38 +18,9 @@ {"type":"step/end","seq":97,"time":1785097411148,"data":{"turn":1,"step":1}} {"type":"step/start","seq":98,"time":1785097411149,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":99,"time":1785097411681,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":100,"time":1785097411681,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":101,"time":1785097411813,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":102,"time":1785097411839,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":103,"time":1785097411839,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replied"}}} -{"type":"assistant/chunk","seq":104,"time":1785097411865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":105,"time":1785097411866,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":106,"time":1785097411892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}} -{"type":"assistant/chunk","seq":107,"time":1785097411892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":108,"time":1785097411892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":109,"time":1785097411892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}} -{"type":"assistant/chunk","seq":110,"time":1785097411892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} -{"type":"assistant/chunk","seq":111,"time":1785097411918,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":112,"time":1785097411918,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":113,"time":1785097411919,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":114,"time":1785097411919,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":115,"time":1785097411919,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":116,"time":1785097411944,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":117,"time":1785097411944,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":118,"time":1785097411944,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":119,"time":1785097411972,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":120,"time":1785097411972,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":121,"time":1785097411972,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}} -{"type":"assistant/chunk","seq":122,"time":1785097411973,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":123,"time":1785097411973,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":124,"time":1785097411973,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":125,"time":1785097411996,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"reasoning-chunks","seq0":100,"time0":1785097411681,"data":{"turn":1,"step":2,"index":0,"dt":[132,26,0,26,1,26,0,0,0,0,26,0,1,0,0,25,0,0,28,0,0,1,0,0,23],"texts":["The"," sub","agent"," replied"," with"," \"","child"," answer"," ","42",".\""," Now"," I"," need"," to"," reply"," with"," the"," sub","agent","'s"," final"," answer"," verb","atim","."]}} {"type":"assistant/chunk","seq":126,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":127,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"child"}}} -{"type":"assistant/chunk","seq":128,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" answer"}}} -{"type":"assistant/chunk","seq":129,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" "}}} -{"type":"assistant/chunk","seq":130,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"42"}}} -{"type":"assistant/chunk","seq":131,"time":1785097412023,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"text-chunks","seq0":127,"time0":1785097411997,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,26],"texts":["child"," answer"," ","42","."]}} {"type":"assistant/chunk","seq":132,"time":1785097412024,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent replied with \"child answer 42.\" Now I need to reply with the subagent's final answer verbatim."}}}} {"type":"assistant/chunk","seq":133,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} {"type":"assistant/chunk","seq":134,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl index 2b4a390563..db86b76c0a 100644 --- a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl @@ -5,30 +5,9 @@ {"type":"step/start","seq":3,"time":1785097381472,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785097381472,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097381978,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1785097381979,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1785097382117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1785097382145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1785097382172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1785097382173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1785097382173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":12,"time":1785097382173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":13,"time":1785097382197,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":14,"time":1785097382198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":15,"time":1785097382198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"SD"}}} -{"type":"assistant/chunk","seq":16,"time":1785097382198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"K"}}} -{"type":"assistant/chunk","seq":17,"time":1785097382198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" snapshot"}}} -{"type":"assistant/chunk","seq":18,"time":1785097382224,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" OK"}}} -{"type":"assistant/chunk","seq":19,"time":1785097382224,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":20,"time":1785097382225,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":21,"time":1785097382250,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":22,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":23,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":24,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785097381979,"data":{"turn":1,"step":1,"index":0,"dt":[138,28,27,1,0,0,24,1,0,0,0,26,0,1,25,1,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]}} {"type":"assistant/chunk","seq":25,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":26,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"SD"}}} -{"type":"assistant/chunk","seq":27,"time":1785097382278,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"K"}}} -{"type":"assistant/chunk","seq":28,"time":1785097382278,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" snapshot"}}} -{"type":"assistant/chunk","seq":29,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" OK"}}} +{"type":"text-chunks","seq0":26,"time0":1785097382251,"data":{"turn":1,"step":1,"index":1,"dt":[27,0,1],"texts":["SD","K"," snapshot"," OK"]}} {"type":"assistant/chunk","seq":30,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}}} {"type":"assistant/chunk","seq":31,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}}} {"type":"assistant/chunk","seq":32,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}}} From cf2b9e211d82a78f4ef1f545d01311d860214b76 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:48:07 +0800 Subject: [PATCH 07/13] fix(sdk-client): address ds-review-bot findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api: resolve a relative workspace cwd to absolute before the handshake — the child spawns relative to the parent cwd, but the wire cwd is resolved again inside the child, so a relative value double-resolved (worker -> worker/worker). - api: make the documented handshake retry real — HarnessClient.close() is permanent, so a failed initialize now reaps the runtime and swaps in a fresh client; DeepSeekHarness.close() is terminal and stops the respawns. - api: validate session.event envelopes, assistant/message content, and session.finished reasons at the wire boundary — a malformed runtime surfaces as SdkProtocolError instead of type-invalid TurnResult data or a TypeError out of finalResponse. - client: a throwing subscribe() filter fails and detaches only its own subscription (normalized to Error); sibling fan-out and the transport read loop are undisturbed. - client: NotificationSubscription.close() drops its queued notifications, matching its documented contract; runtime-death fail() still leaves already-delivered items drainable. - client: subscribe() after close()/runtime death returns a born-failed subscription so next() rejects instead of parking forever. - client/transport: bounded requests abandon via AbortSignal — the transport drops the pending entry at timeout, so repeated bounded calls against a hung method retain no per-call state. One test per finding; per-file coverage stays 100% on both packages. --- packages/sdk/sdk-client/README.i18n.yaml | 6 +- packages/sdk/sdk-client/README.md | 2 +- packages/sdk/sdk-client/README.zh.md | 2 +- packages/sdk/sdk-client/src/api.ts | 90 ++++++++++-- packages/sdk/sdk-client/src/client.ts | 54 ++++--- packages/sdk/sdk-client/tests/fake-runtime.ts | 35 ++++- .../sdk/sdk-client/tests/sdk-client.spec.ts | 138 +++++++++++++++++- packages/sdk/sdk-protocol/src/transport.ts | 41 +++++- .../sdk/sdk-protocol/tests/transport.spec.ts | 23 +++ 9 files changed, 347 insertions(+), 44 deletions(-) diff --git a/packages/sdk/sdk-client/README.i18n.yaml b/packages/sdk/sdk-client/README.i18n.yaml index ebb5ca18d5..11cdef9d94 100644 --- a/packages/sdk/sdk-client/README.i18n.yaml +++ b/packages/sdk/sdk-client/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 82e344014ac01120986ee2b4e07ac21192506ff7 -README.zh.md: 6e67f57ab92a75be3600cbdd0ce6bf21832fd2c8 +# pnpm run verify-translation-pairing --write packages/sdk/sdk-client/README.md +README.md: 3945f911990fa3df362581b0fb37389110bdd386 +README.zh.md: 3814b88aab1b10c96fdf809565994f29b8e8026b diff --git a/packages/sdk/sdk-client/README.md b/packages/sdk/sdk-client/README.md index 82e344014a..3945f91199 100644 --- a/packages/sdk/sdk-client/README.md +++ b/packages/sdk/sdk-client/README.md @@ -20,7 +20,7 @@ const result = await harness.run('say hi') console.log(result.status, result.finalResponse) ``` -The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (cwd + provider/model route); a failed handshake closes the runtime and resets, so a later call may retry. `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), plus every `session.event` envelope and raw notification observed for that session tree, in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation. +The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), plus every `session.event` envelope and raw notification observed for that session tree, in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation. ## HarnessClient diff --git a/packages/sdk/sdk-client/README.zh.md b/packages/sdk/sdk-client/README.zh.md index 6e67f57ab9..3814b88aab 100644 --- a/packages/sdk/sdk-client/README.zh.md +++ b/packages/sdk/sdk-client/README.zh.md @@ -20,7 +20,7 @@ const result = await harness.run('say hi') console.log(result.status, result.finalResponse) ``` -子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(cwd + provider/model 路由);握手失败会关闭运行时并复位,后续调用可以重试。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本),以及该会话树内按线序观察到的全部 `session.event` 封套与原始通知。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。 +子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本),以及该会话树内按线序观察到的全部 `session.event` 封套与原始通知。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。 ## HarnessClient diff --git a/packages/sdk/sdk-client/src/api.ts b/packages/sdk/sdk-client/src/api.ts index 433a80d8c7..095018b452 100644 --- a/packages/sdk/sdk-client/src/api.ts +++ b/packages/sdk/sdk-client/src/api.ts @@ -8,9 +8,10 @@ */ import { randomUUID } from 'node:crypto' +import { resolve } from 'node:path' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' -import { HarnessClient } from './client.ts' -import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, TurnResult } from './types.ts' +import { HarnessClient, isRecord, SdkProtocolError } from './client.ts' +import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, TurnResult } from './types.ts' /** * Reusable SDK for running DeepSeek Harness agent turns in a runtime @@ -19,33 +20,52 @@ import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, TurnRes * child is reaped. */ export class DeepSeekHarness implements AsyncDisposable { - /** The underlying JSON-RPC client (exposed for low-level access). */ - readonly client: HarnessClient + private clientInstance: HarnessClient + private readonly launch: HarnessClientOptions private readonly cwd: string private readonly provider: string private readonly model: string private initialized: Promise | undefined + private closed = false /** @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.launch = options.launch + this.clientInstance = new HarnessClient(options.launch) + // Absolute before the handshake: the child spawns relative to THIS + // process's cwd, but the wire cwd is resolved again inside the child — a + // relative value would double-resolve (e.g. `worker` → `worker/worker`). + this.cwd = resolve(options.cwd ?? options.launch.cwd ?? process.cwd()) this.provider = options.provider ?? 'deepseek' this.model = options.model ?? 'deepseek-v4-flash' } /** - * Start the subprocess and perform the `initialize` handshake once. + * The underlying JSON-RPC client (exposed for low-level access). A failed + * handshake reaps its runtime and swaps in a fresh instance, so do not + * cache this across a failed {@link start}. + * @returns the client currently owning the runtime subprocess. + */ + get client(): HarnessClient { + return this.clientInstance + } + + /** + * Start the subprocess and perform the `initialize` handshake once. On + * failure the runtime is reaped and a fresh client replaces it + * (`HarnessClient.close` is permanent), so a later call retries with a new + * subprocess — unless {@link close} already ended this harness. * @returns settlement of the (memoized) handshake. */ start(): Promise { this.initialized ??= (async () => { try { - this.client.start() - await this.client.initialize({ cwd: this.cwd, provider: this.provider, model: this.model }) + this.clientInstance.start() + await this.clientInstance.initialize({ cwd: this.cwd, provider: this.provider, model: this.model }) } catch (error) { this.initialized = undefined - await this.client.close() + await this.clientInstance.close() + if (!this.closed) this.clientInstance = new HarnessClient(this.launch) throw error } })() @@ -73,11 +93,13 @@ export class DeepSeekHarness implements AsyncDisposable { } /** - * Shut down and reap the runtime subprocess. Idempotent. + * Shut down and reap the runtime subprocess. Idempotent and terminal — + * a closed harness no longer retries a failed handshake. * @returns settlement of the complete teardown. */ close(): Promise { - return this.client.close() + this.closed = true + return this.clientInstance.close() } /** @@ -128,16 +150,26 @@ export class HarnessSession { 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) + // Wire boundary: the envelope feeds the typed TurnResult, so a + // malformed runtime surfaces as a protocol error, not as type-invalid + // data (or a TypeError out of finalResponse). + const event = validatedSessionEvent(notification.params.event) + notifications.push(notification) + options?.onNotification?.(notification) + events.push(event) + return } if (notification.method === 'session.finished' && notification.params.sessionId === this.id) { + reason = validatedTurnEndReason(notification.params.reason) + notifications.push(notification) + options?.onNotification?.(notification) status = notification.params.status === 'ok' ? 'ok' : 'error' - reason = notification.params.reason as TurnEndReason | undefined finished = true + return } + notifications.push(notification) + options?.onNotification?.(notification) } const accepted = client.prompt(this.id, contentBlocks) // Drain concurrently so observers see progress while the prompt request @@ -175,6 +207,32 @@ export function normalizeInput(input: string | ContentBlock[]): ContentBlock[] { return typeof input === 'string' ? [{ type: 'text', text: input }] : input } +/** Validate a wire `session.event` envelope to the shape the typed result exposes. */ +function validatedSessionEvent(value: unknown): SessionEvent { + if (!isRecord(value) || typeof value.type !== 'string') { + throw new SdkProtocolError(`session.event carried no event envelope: ${JSON.stringify(value)}`) + } + // The one variant this module reads into (finalResponse) must carry + // kind-tagged content blocks; other variants pass through under their + // envelope shape. + if (value.type === 'assistant/message') { + const content = isRecord(value.data) ? value.data.content : undefined + if (!Array.isArray(content) || !content.every(block => isRecord(block) && typeof block.type === 'string')) { + throw new SdkProtocolError(`assistant/message event carried malformed content: ${JSON.stringify(value)}`) + } + } + return value as unknown as SessionEvent +} + +/** Validate a wire `session.finished` reason (absent, or a kind-tagged record). */ +function validatedTurnEndReason(value: unknown): TurnEndReason | undefined { + if (value === undefined) return undefined + if (!isRecord(value) || typeof value.kind !== 'string') { + throw new SdkProtocolError(`session.finished carried a malformed reason: ${JSON.stringify(value)}`) + } + return value as unknown as TurnEndReason +} + /** * Extract the concatenated text of the last assistant message. * @param events - the turn's `session.event` payloads in wire order. diff --git a/packages/sdk/sdk-client/src/client.ts b/packages/sdk/sdk-client/src/client.ts index bf852b8eb1..447ea75d5a 100644 --- a/packages/sdk/sdk-client/src/client.ts +++ b/packages/sdk/sdk-client/src/client.ts @@ -81,8 +81,9 @@ export class NotificationSubscription implements AsyncIterable { const queued = this.state.queue.shift() @@ -104,11 +105,15 @@ export class NotificationSubscription implements AsyncIterable { + abandon.abort(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for the DeepSeek Harness runtime`)) + }, timeout) try { - return await Promise.race([ - pending, - new Promise((_, 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) - }), - ]) + return await transport.request(method, params ?? {}, abandon.signal) } finally { clearTimeout(timer) } @@ -303,12 +315,18 @@ export class HarnessClient { /** * Subscribe to server notifications. * @param filter - optional predicate; omitted means every notification. - * @returns the subscription handle; close it to stop delivery. + * @returns the subscription handle; close it to stop delivery. After + * {@link close} or runtime death the handle is born failed — there is no + * producer left, so `next()` rejects instead of waiting forever. */ subscribe(filter?: NotificationFilter): NotificationSubscription { const id = String(this.subscriptionSerial++) const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined } const subscription = new NotificationSubscription(state, () => { this.subscriptions.delete(id) }) + if (this.closeTask !== undefined || this.exitCode !== undefined || this.spawnError !== undefined) { + subscription.fail(this.closedError('DeepSeek Harness runtime closed')) + return subscription + } this.subscriptions.set(id, subscription) return subscription } diff --git a/packages/sdk/sdk-client/tests/fake-runtime.ts b/packages/sdk/sdk-client/tests/fake-runtime.ts index 0c8a928c34..c07085f3dd 100644 --- a/packages/sdk/sdk-client/tests/fake-runtime.ts +++ b/packages/sdk/sdk-client/tests/fake-runtime.ts @@ -16,6 +16,16 @@ * - `FAKE_MALFORMED`: `initialize` returns `{}` (no serverInfo); `prompt` returns `{}` (no accepted). * - `FAKE_MALFORMED_PROMPT`: `initialize` is normal; only `prompt` returns `{}` (no accepted). * - `FAKE_INIT_ERROR`: `initialize` answers a JSON-RPC error response with code 7. + * - `FAKE_INIT_ERROR_ONCE_FILE`: fail `initialize` (code 7) only when this + * marker file does NOT exist yet, creating it — so the first runtime + * process fails the handshake and a respawned one succeeds (retry probe). + * - `FAKE_ECHO_CWD_IN_INIT`: reply `serverInfo.version` = this process's cwd + * (wire-visible spawn-cwd probe). + * - `FAKE_MALFORMED_EVENT`: the turn's `session.event` carries a number as + * the event; `FAKE_MALFORMED_MESSAGE`: assistant/message content is not an + * array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data + * member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare + * string (wire-validation probes). * - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe). * - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize` * arrives, then poll for the GO file before answering (deterministic @@ -78,8 +88,20 @@ function assistantText(): string { function runTurn(sessionId: string): void { const text = assistantText() + if (env.FAKE_MALFORMED_EVENT !== undefined) { + notify('session.event', { sessionId, event: 42 }) + return + } event(sessionId, 'turn/start', { turn: 0 }) event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text } }) + if (env.FAKE_MALFORMED_MESSAGE !== undefined) { + event(sessionId, 'assistant/message', { turn: 0, step: 0, content: 'not-an-array' }) + return + } + if (env.FAKE_MESSAGE_WITHOUT_DATA !== undefined) { + notify('session.event', { sessionId, event: { type: 'assistant/message', seq: seq++, time: 0 } }) + return + } event(sessionId, 'assistant/message', { turn: 0, step: 0, @@ -110,7 +132,9 @@ function runTurn(sessionId: string): void { notify('session.finished', { sessionId, status: env.FAKE_STATUS ?? 'ok', - ...(reasonKind === 'none' ? {} : { reason: { kind: reasonKind } }), + ...(env.FAKE_MALFORMED_REASON !== undefined + ? { reason: 'not-a-record' } + : reasonKind === 'none' ? {} : { reason: { kind: reasonKind } }), }) } @@ -144,10 +168,19 @@ reader.on('line', (line) => { write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted init failure', data: { hint: 'fake' } } }) return } + if (env.FAKE_INIT_ERROR_ONCE_FILE !== undefined && !existsSync(env.FAKE_INIT_ERROR_ONCE_FILE)) { + writeFileSync(env.FAKE_INIT_ERROR_ONCE_FILE, 'failed-once\n') + write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted first-boot failure' } }) + return + } if (env.FAKE_MALFORMED !== undefined) { respond({}) return } + if (env.FAKE_ECHO_CWD_IN_INIT !== undefined) { + respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: process.cwd() } }) + return + } respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }) return case 'session/prompt': { diff --git a/packages/sdk/sdk-client/tests/sdk-client.spec.ts b/packages/sdk/sdk-client/tests/sdk-client.spec.ts index 3feff693f4..171c0f0655 100644 --- a/packages/sdk/sdk-client/tests/sdk-client.spec.ts +++ b/packages/sdk/sdk-client/tests/sdk-client.spec.ts @@ -5,9 +5,9 @@ * and session-tree scoping, error surfaces, timeouts, and the dispose ladder. */ -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { @@ -122,6 +122,31 @@ describe('DeepSeekHarness', () => { expect(records).toEqual([{ cwd: dir, provider: 'custom-provider', model: 'custom-model' }]) }) + it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => { + // vitest workers forbid chdir, so derive a RELATIVE path from the real + // process cwd to a temp worker dir; resolution is lexical either way. + const dir = await tempDir('sdk-client-relcwd-') + const recordFile = join(dir, 'init.jsonl') + const inner = join(dir, 'worker') + await mkdir(inner) + const relativeCwd = relative(process.cwd(), inner) + expect(isAbsolute(relativeCwd)).toBe(false) + const harness = new DeepSeekHarness({ + launch: fakeLaunch({ FAKE_RECORD_INIT: recordFile, FAKE_ECHO_CWD_IN_INIT: '1' }, { cwd: relativeCwd }), + }) + cleanups.push(() => harness.close()) + await harness.start() + const identity = await harness.client.initialize({ cwd: inner, provider: 'p', model: 'm' }) + await harness.close() + // The child spawned under the temp worker dir (its physical cwd)... + expect(identity.serverInfo.version).toBe(await realpath(inner)) + // ...and the handshake wire cwd went out ABSOLUTE, so the child cannot + // re-resolve a relative string into dir/worker/worker. + const records = (await readFile(recordFile, 'utf8')).trim().split('\n') + .map(line => (JSON.parse(line) as { cwd: string }).cwd) + expect(records).toEqual([resolvePath(relativeCwd), inner]) + }) + it('propagates a JSON-RPC error response from initialize and closes the runtime', async () => { const harness = harnessWith({ FAKE_INIT_ERROR: '1' }) const failure = await harness.run('boom').then( @@ -134,6 +159,23 @@ describe('DeepSeekHarness', () => { await expect(harness.run('later')).rejects.toThrow() }) + it('retries a failed handshake with a fresh runtime process', async () => { + const dir = await tempDir('sdk-client-retry-') + const marker = join(dir, 'first-boot-failed') + const harness = harnessWith({ FAKE_INIT_ERROR_ONCE_FILE: marker, FAKE_TEXT: 'second boot answer' }) + const firstClient = harness.client + // First start: the scripted runtime fails the handshake and is reaped. + await expect(harness.start()).rejects.toThrow('scripted first-boot failure') + // Retry spawns a NEW subprocess through a fresh client (close is permanent). + const result = await harness.run('again') + expect(harness.client).not.toBe(firstClient) + expect(result.status).toBe('ok') + expect(result.finalResponse).toBe('second boot answer') + await harness.close() + // close() is terminal: a handshake failure after it must not respawn. + await expect(harness.run('after-close')).rejects.toThrow(TransportClosedError) + }) + it('rejects a malformed initialize result as a protocol error', async () => { const harness = harnessWith({ FAKE_MALFORMED: '1' }) await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError) @@ -162,6 +204,22 @@ describe('HarnessClient', () => { await client.close() }) + it('a timed-out request leaves no pending transport state', async () => { + const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' })) + cleanups.push(() => client.close()) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + for (let round = 0; round < 3; round++) { + await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('x') }, 50)) + .rejects.toThrow(RequestTimeoutError) + } + // Abandonment removed each pending entry at its timeout; a hung method + // retains nothing per call. (Private map read is the observable here — + // no wire surface reports transport bookkeeping.) + const transport = (client as unknown as { transport: { pending: Map } }).transport + expect(transport.pending.size).toBe(0) + await client.close() + }) + it('applies the client-wide request timeout when no per-call bound is given', async () => { const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }, { requestTimeoutMs: 400 })) cleanups.push(() => client.close()) @@ -255,6 +313,10 @@ describe('HarnessClient', () => { expect(finished.method).toBe('session.finished') expect(finishedOnly.tryNext()).toBeUndefined() + // A bare unbounded request with omitted params sends `{}` on the wire. + const identity = await client.request('initialize') as { serverInfo: { name: string } } + expect(identity.serverInfo.name).toBe('deepseek-harness-sdk-runtime') + // Async iteration consumes queued items and then parks. const collected: string[] = [] for await (const notification of all) { @@ -269,6 +331,56 @@ describe('HarnessClient', () => { await client.close() }) + it('contains a throwing filter to its own subscription', async () => { + const client = new HarnessClient(fakeLaunch()) + cleanups.push(() => client.close()) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + + const broken = client.subscribe(() => { throw new Error('filter exploded') }) + // A non-Error throw is normalized rather than crashing dispatch. + const brokenNonError = client.subscribe(() => { throw 'string boom' }) + const healthy = client.subscribe(n => n.method === 'session.finished') + await client.prompt('filter-contain', normalizeInput('go')) + + // The sibling subscription and the read loop are undisturbed. + expect((await healthy.next()).method).toBe('session.finished') + // Each broken subscription failed with ITS OWN error and detached. + await expect(broken.next()).rejects.toThrow('filter exploded') + await expect(brokenNonError.next()).rejects.toThrow('string boom') + healthy.close() + await client.close() + }) + + it('close() drops queued notifications; runtime death keeps them drainable', async () => { + const client = new HarnessClient(fakeLaunch()) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + const closed = client.subscribe() + const drainable = client.subscribe() + await client.prompt('queue-drop', normalizeInput('go')) + expect(closed.tryNext()).toBeDefined() + closed.close() + // Manual close drops the rest of the queue outright. + expect(closed.tryNext()).toBeUndefined() + await expect(closed.next()).rejects.toThrow('notification subscription closed') + // Runtime teardown, by contrast, only stops FUTURE delivery: what was + // already delivered before close() stays drainable. + await client.close() + expect(drainable.tryNext()).toBeDefined() + }) + + it('subscriptions created after termination are born failed', async () => { + const client = new HarnessClient(fakeLaunch()) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + await client.close() + // No producer can ever feed this subscription; next() must not park forever. + await expect(client.subscribe().next()).rejects.toThrow(TransportClosedError) + + const dead = new HarnessClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1' })) + cleanups.push(() => dead.close()) + await dead.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).catch(() => {}) + await expect(dead.subscribe().next()).rejects.toThrow(TransportClosedError) + }) + it('closes subscriptions with the runtime and rejects parked waiters', async () => { const client = new HarnessClient(fakeLaunch()) await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) @@ -310,6 +422,28 @@ describe('HarnessClient', () => { }) }) +describe('wire payload validation', () => { + it('rejects a non-object session.event envelope as a protocol error', async () => { + const harness = harnessWith({ FAKE_MALFORMED_EVENT: '1' }) + await expect(harness.run('bad-event')).rejects.toThrow(SdkProtocolError) + }) + + it('rejects an assistant/message without a content array as a protocol error', async () => { + const harness = harnessWith({ FAKE_MALFORMED_MESSAGE: '1' }) + await expect(harness.run('bad-message')).rejects.toThrow(SdkProtocolError) + }) + + it('rejects an assistant/message without a data member as a protocol error', async () => { + const harness = harnessWith({ FAKE_MESSAGE_WITHOUT_DATA: '1' }) + await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError) + }) + + it('rejects a malformed session.finished reason as a protocol error', async () => { + const harness = harnessWith({ FAKE_MALFORMED_REASON: '1' }) + await expect(harness.run('bad-reason')).rejects.toThrow(SdkProtocolError) + }) +}) + describe('stderr tail bound', () => { it('keeps only the newest lines up to the limit', async () => { const manyLines = Array.from({ length: 450 }, (_, i) => `line-${i}`).join('\n') diff --git a/packages/sdk/sdk-protocol/src/transport.ts b/packages/sdk/sdk-protocol/src/transport.ts index a1291f019b..36574f46bf 100644 --- a/packages/sdk/sdk-protocol/src/transport.ts +++ b/packages/sdk/sdk-protocol/src/transport.ts @@ -109,15 +109,47 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { this.notificationHandler = handler } - request(method: string, params: object): Promise { + /** + * Send a request and await its response. + * @param method - the JSON-RPC method name. + * @param params - the request parameters object. + * @param signal - optional abandonment signal: aborting removes the pending + * entry (no state is retained for a response that may never come) and + * rejects with the signal's reason. + * @returns the result; rejects per {@link JsonRpcTransportPeer.request}. + */ + request(method: string, params: object, signal?: AbortSignal): Promise { const id = `req_${randomUUID().replaceAll('-', '')}` const message = { jsonrpc: '2.0', id, method, params } return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }) + let detach = (): void => {} + if (signal !== undefined) { + if (signal.aborted) { + reject(abortError(signal.reason)) + return + } + const onAbort = (): void => { + this.pending.delete(id) + reject(abortError(signal.reason)) + } + signal.addEventListener('abort', onAbort, { once: true }) + detach = () => { signal.removeEventListener('abort', onAbort) } + } + this.pending.set(id, { + resolve: (value) => { + detach() + resolve(value) + }, + reject: (error) => { + detach() + reject(error) + }, + }) try { this.write(message) } catch (error) { this.pending.delete(id) + detach() reject(error instanceof Error ? error : new Error(String(error))) } }) @@ -240,3 +272,8 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { function objectParams(params: unknown): Record { return params && typeof params === 'object' && !Array.isArray(params) ? params as Record : {} } + +/** Normalize an abort reason into the rejection Error (a non-Error reason is stringified). */ +function abortError(reason: unknown): Error { + return reason instanceof Error ? reason : new Error(`JSON-RPC request aborted: ${String(reason)}`) +} diff --git a/packages/sdk/sdk-protocol/tests/transport.spec.ts b/packages/sdk/sdk-protocol/tests/transport.spec.ts index 7eedb12867..a07324a6fc 100644 --- a/packages/sdk/sdk-protocol/tests/transport.spec.ts +++ b/packages/sdk/sdk-protocol/tests/transport.spec.ts @@ -60,6 +60,29 @@ describe('JsonRpcLineTransport', () => { b.close() }) + it('rejects immediately on a pre-aborted signal without registering pending state', async () => { + const { b } = transportPair() + b.start() + const controller = new AbortController() + controller.abort(new Error('already gone')) + await expect(b.request('never-sent', {}, controller.signal)).rejects.toThrow('already gone') + expect((b as unknown as { pending: Map }).pending.size).toBe(0) + b.close() + }) + + it('abandons a pending request on abort, stringifying a non-Error reason', async () => { + const { b } = transportPair() + b.start() + const controller = new AbortController() + const pending = b.request('never-answered', {}, controller.signal) + controller.abort('plain-string-reason') + await expect(pending).rejects.toThrow('JSON-RPC request aborted: plain-string-reason') + // The abandonment removed the pending entry — nothing is retained for a + // response that may never come. + expect((b as unknown as { pending: Map }).pending.size).toBe(0) + b.close() + }) + it('preserves structured error data from an error response frame', async () => { const { aToB, bToA, b } = transportPair() b.start() From 6c8bf0a522bcdd6462d7f1adcabf739e33d0c821 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:25:18 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20address=20human=20review=20?= =?UTF-8?q?=E2=80=94=20default=20providerName=20dsh-sdk;=20raise=20composi?= =?UTF-8?q?tion-e2e=20deadline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subagent-sdk: the default registry name becomes `dsh-sdk` (the bare `sdk` read ambiguously in configs); READMEs, config catalog, fixture, and suites follow. The Loader fixture now omits providerName to exercise the shipped default end to end. - loader-composition.e2e: two full harness runtimes boot in sequence, so the default 30s loader-smoke window times out under host load; raise the subprocess deadline to 120s with matching vitest headroom (the real-model.e2e precedent). --- docs/config-catalog.md | 2 +- .../fixtures/subagent/subagent-sdk/cordis.yml | 5 +-- .../subagent/subagent-sdk/README.i18n.yaml | 6 ++-- packages/subagent/subagent-sdk/README.md | 6 ++-- packages/subagent/subagent-sdk/README.zh.md | 6 ++-- packages/subagent/subagent-sdk/src/index.ts | 4 +-- .../tests/loader-composition.e2e.ts | 10 ++++-- .../subagent-sdk/tests/subagent-sdk.spec.ts | 35 ++++++++++--------- 8 files changed, 42 insertions(+), 32 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index da0bb41007..f4f0e6e691 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1353,7 +1353,7 @@ Requires: `subagents` ```ts config-catalog /** Config: how to spawn and drive the child SDK runtime process. */ export interface Config { - /** Provider name on `ctx.subagents` (default `sdk`). */ + /** Provider name on `ctx.subagents` (default `dsh-sdk`). */ providerName: string /** The executable to spawn for each run (the child runtime bin or packaged exe). */ command: string diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml index a1e95fcddd..6511a0c3a4 100644 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml @@ -12,10 +12,11 @@ - id: subagent name: '@deepseek-ai/dsh-subagent' +# providerName is omitted: the composition exercises the shipped default +# (`dsh-sdk`) through the real Loader. - id: subagent-sdk name: '@deepseek-ai/dsh-subagent-sdk' config: - providerName: sdk command: !!js process.env.DSH_TEST_CHILD_COMMAND args: !!js JSON.parse(process.env.DSH_TEST_CHILD_ARGS ?? '[]') provider: mock @@ -25,7 +26,7 @@ - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: - provider: sdk + provider: dsh-sdk toolName: subagent # The SDK backend advertises no depthLimit: the child harness owns its own # recursion budget, so the local numeric default cannot apply here. diff --git a/packages/subagent/subagent-sdk/README.i18n.yaml b/packages/subagent/subagent-sdk/README.i18n.yaml index b35d54db49..9e531c0406 100644 --- a/packages/subagent/subagent-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-sdk/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: bc9b1ed7706d16d0a7463d0b26beb3ccf171ce5b -README.zh.md: 76eb4ffaf9854892e05ad891c7b951a838b89e8e +# pnpm run verify-translation-pairing --write packages/subagent/subagent-sdk/README.md +README.md: 92c31e4823c4f8a3b2526441ab01dc732fcecbdd +README.zh.md: 3610bfcf93ddd2f458640e6149d398f0f7f4b173 diff --git a/packages/subagent/subagent-sdk/README.md b/packages/subagent/subagent-sdk/README.md index bc9b1ed770..92c31e4823 100644 --- a/packages/subagent/subagent-sdk/README.md +++ b/packages/subagent/subagent-sdk/README.md @@ -26,7 +26,7 @@ The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/ | Key | Default | Meaning | |---|---|---| -| `providerName` | `sdk` | Registry name on `ctx.subagents`. | +| `providerName` | `dsh-sdk` | Registry name on `ctx.subagents`. | | `command` | required | Executable spawned per run (the child runtime bin or packaged exe). | | `args` | `[]` | Command arguments (typically the child's `cordis.yml` path). | | `cwd` | parent session cwd | Working-directory override; same validation as [`subagent-acp`](../subagent-acp/README.md). | @@ -41,14 +41,14 @@ The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/ - id: subagent-sdk name: '@deepseek-ai/dsh-subagent-sdk' config: - providerName: sdk + providerName: dsh-sdk command: node args: ['./packages/examples/jsonrpc-demo/lib/bin.js', './examples/jsonrpc-agent/cordis.yml'] env: DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' - config: { provider: sdk, toolName: subagent, maxDepth: 'provider-managed' } + config: { provider: dsh-sdk, toolName: subagent, maxDepth: 'provider-managed' } ``` ## Process boundary diff --git a/packages/subagent/subagent-sdk/README.zh.md b/packages/subagent/subagent-sdk/README.zh.md index 76eb4ffaf9..3610bfcf93 100644 --- a/packages/subagent/subagent-sdk/README.zh.md +++ b/packages/subagent/subagent-sdk/README.zh.md @@ -26,7 +26,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte | 键 | 默认 | 含义 | |---|---|---| -| `providerName` | `sdk` | `ctx.subagents` 上的注册名。 | +| `providerName` | `dsh-sdk` | `ctx.subagents` 上的注册名。 | | `command` | 必填 | 每次 run 生成的可执行文件(子运行时 bin 或打包 exe)。 | | `args` | `[]` | 命令参数(通常是子进程的 `cordis.yml` 路径)。 | | `cwd` | 父会话 cwd | 工作目录覆盖;校验规则与 [`subagent-acp`](../subagent-acp/README.md) 相同。 | @@ -41,14 +41,14 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte - id: subagent-sdk name: '@deepseek-ai/dsh-subagent-sdk' config: - providerName: sdk + providerName: dsh-sdk command: node args: ['./packages/examples/jsonrpc-demo/lib/bin.js', './examples/jsonrpc-agent/cordis.yml'] env: DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' - config: { provider: sdk, toolName: subagent, maxDepth: 'provider-managed' } + config: { provider: dsh-sdk, toolName: subagent, maxDepth: 'provider-managed' } ``` ## 进程边界 diff --git a/packages/subagent/subagent-sdk/src/index.ts b/packages/subagent/subagent-sdk/src/index.ts index 7165ad05ad..b4fdcabadc 100644 --- a/packages/subagent/subagent-sdk/src/index.ts +++ b/packages/subagent/subagent-sdk/src/index.ts @@ -27,7 +27,7 @@ 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`). */ + /** Provider name on `ctx.subagents` (default `dsh-sdk`). */ providerName: string /** The executable to spawn for each run (the child runtime bin or packaged exe). */ command: string @@ -67,7 +67,7 @@ export interface Config { } export const Config: z = z.object({ - providerName: z.string().default('sdk'), + providerName: z.string().default('dsh-sdk'), command: z.string().required(), args: z.array(z.string()).default([]), cwd: z.string(), diff --git a/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts b/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts index 4fe435b311..8e6a499d4e 100644 --- a/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts @@ -15,7 +15,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { type SessionEvent } from '@deepseek-ai/dsh-session' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { resolveExampleLaunch, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' const fixtureDir = new URL('../../../../examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/', import.meta.url) const driver = fileURLToPath(new URL('driver.ts', fixtureDir)) @@ -60,6 +60,10 @@ describe('SDK subagent cwd inheritance through a real cordis.yml', () => { libBinScript: driver, configPath, tsconfigPath: repoTsconfig, + // Two complete harness runtimes boot in sequence (driver, then the SDK + // child); from-source tsx boots under load need more than the default + // 30s window. + processTimeoutMs: 120_000, env: { DSH_TEST_CHILD_COMMAND: childLaunch.command, DSH_TEST_CHILD_ARGS: JSON.stringify(childLaunch.args), @@ -97,5 +101,7 @@ describe('SDK subagent cwd inheritance through a real cordis.yml', () => { expect(childEvents.some(event => event.type === 'user/message')).toBe(true) const childAnswers = childEvents.filter(event => event.type === 'assistant/message') expect(childAnswers.length).toBeGreaterThan(0) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + // 15s of vitest headroom past the subprocess deadline, mirroring + // LOADER_SMOKE_TEST_TIMEOUT_MS's margin over the default window. + }, 135_000) }) diff --git a/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts b/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts index a2d49de343..872b375e27 100644 --- a/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts +++ b/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts @@ -37,8 +37,11 @@ function request(text = 'p', signal = new AbortController().signal) { async function setup(fakeEnv: Record = {}, config: Partial = {}) { const ctx = new Context() await ctx.plugin(SubagentService) + // The Config type models the post-validation shape, so the default registry + // name is stated here; the Loader-composition fixture omits providerName and + // exercises the schemastery default end to end. await ctx.plugin(sdk, { - providerName: 'sdk', + providerName: 'dsh-sdk', command: process.execPath, args: [fakeRuntime], provider: 'fake-provider', @@ -84,7 +87,7 @@ describe('sdkStopReason', () => { 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')) + const run = await ctx.subagents.start('dsh-sdk', request('do X')) expect(run.localAgent).toBeUndefined() const result = await run.result expect(result.stopReason).toBe('completed') @@ -94,7 +97,7 @@ describe('dsh-subagent-sdk provider', () => { expect(run.dispose()).toBe(disposal) await disposal - const nextRun = await ctx.subagents.start('sdk', request('again')) + const nextRun = await ctx.subagents.start('dsh-sdk', request('again')) expect(nextRun.id).not.toBe(run.id) await nextRun.result await nextRun.dispose() @@ -106,7 +109,7 @@ describe('dsh-subagent-sdk provider', () => { const recordFile = join(tmp, 'init.jsonl') try { const ctx = await setup({ FAKE_RECORD_INIT: recordFile }) - const run = await ctx.subagents.start('sdk', request()) + const run = await ctx.subagents.start('dsh-sdk', request()) await run.result await run.dispose() const { readFileSync } = await import('node:fs') @@ -126,7 +129,7 @@ describe('dsh-subagent-sdk provider', () => { DEEPSEEK_API_KEY: 'explicit-child-key', FAKE_TEXT: 'done', }) - const run = await ctx.subagents.start('sdk', request()) + const run = await ctx.subagents.start('dsh-sdk', request()) const result = await run.result const answer = text(result.output) expect(answer).toContain('DSH_TEST_AMBIENT_SECRET_KEY=\n') @@ -140,7 +143,7 @@ describe('dsh-subagent-sdk provider', () => { 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()) + const run = await ctx.subagents.start('dsh-sdk', request()) expect((await run.result).stopReason).toBe('max-tokens') await run.dispose() await ctx.fiber.dispose() @@ -148,7 +151,7 @@ describe('dsh-subagent-sdk provider', () => { 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 run = await ctx.subagents.start('dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('error') expect(text(result.output)).toBe('partial answer') @@ -158,7 +161,7 @@ describe('dsh-subagent-sdk provider', () => { 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()) + const run = await ctx.subagents.start('dsh-sdk', request()) expect((await run.result).stopReason).toBe('error') await run.dispose() await ctx.fiber.dispose() @@ -167,7 +170,7 @@ describe('dsh-subagent-sdk provider', () => { 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)) + const run = await ctx.subagents.start('dsh-sdk', request('p', controller.signal)) controller.abort('test') const result = await run.result expect(result.stopReason).toBe('aborted') @@ -215,7 +218,7 @@ describe('dsh-subagent-sdk provider', () => { // the failure settles, so the accumulated partial text (no complete // assistant/message ever arrived) must survive into the error result. const ctx = await setup({ FAKE_STREAM_THEN_MALFORMED: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 }) - const run = await ctx.subagents.start('sdk', request()) + const run = await ctx.subagents.start('dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('error') expect(text(result.output)).toBe('streamed then cut short') @@ -225,7 +228,7 @@ describe('dsh-subagent-sdk provider', () => { 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()) + const run = await ctx.subagents.start('dsh-sdk', request()) await run.dispose() expect((await run.result).stopReason).toBe('aborted') await ctx.fiber.dispose() @@ -260,7 +263,7 @@ describe('dsh-subagent-sdk provider', () => { 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( + const failure = await ctx.subagents.start('dsh-sdk', request()).then( () => { throw new Error('start unexpectedly succeeded') }, (error: unknown) => error, ) @@ -318,10 +321,10 @@ describe('dsh-subagent-sdk provider', () => { 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()) + const run = await ctx.subagents.start('dsh-sdk', request()) expect((await run.result).stopReason).toBe('error') expect(warnings).toHaveLength(1) - expect(warnings[0]).toContain('subagent-sdk "sdk": child run failed (error)') + expect(warnings[0]).toContain('subagent-sdk "dsh-sdk": child run failed (error)') await run.dispose() await ctx.fiber.dispose() }) @@ -379,7 +382,7 @@ describe('dsh-subagent-sdk provider', () => { 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 run = await ctx.subagents.start('dsh-sdk', request()) const result = await run.result const { realpathSync } = await import('node:fs') expect(text(result.output)).toContain(`cwd=${realpathSync(tmp)}`) @@ -393,7 +396,7 @@ describe('dsh-subagent-sdk provider', () => { 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 })) + await expect(ctx.subagents.start('dsh-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() }) From ed8f9c2808a06972c035ec60017dee00fd62cd1b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:17:10 +0800 Subject: [PATCH 09/13] refactor: rename the backend to dsh-subagent-dsh-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group's convention is package suffix == provider default (subagent-acp/'acp', subagent-spawn/'spawn', subagent-fork/'fork'), and the provider default became dsh-sdk in the last review round — so the package follows: @deepseek-ai/dsh-subagent-dsh-sdk at packages/subagent/subagent-dsh-sdk, plugin name subagent-dsh-sdk, diagnostics prefixed subagent-dsh-sdk:. The dsh echo has precedent (dsh-llm-deepseek). Directory, fixture path, knip/tsconfig/examples registrations, catalogs, READMEs (en+zh), and the Agent Note follow; the sdk-client dispose ladder moves to its own module (src/dispose.ts) with the deterministic FakeChild tier tests restored alongside it. --- ...ipt-sdk-and-sdk-subagent-backend.i18n.yaml | 6 +- ...typescript-sdk-and-sdk-subagent-backend.md | 16 +- ...escript-sdk-and-sdk-subagent-backend.zh.md | 16 +- docs/config-catalog.md | 34 +-- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 8 +- docs/module-graph.md | 38 ++- .../child-mock-llm.ts | 0 .../child.cordis.yml | 0 .../cordis.yml | 4 +- .../driver.ts | 0 .../mock-delegating-llm.ts | 0 examples/package.json | 2 +- knip.json | 8 +- packages/sdk/sdk-client/README.i18n.yaml | 4 +- packages/sdk/sdk-client/README.md | 6 +- packages/sdk/sdk-client/README.zh.md | 6 +- packages/sdk/sdk-client/src/client.ts | 86 +------ packages/sdk/sdk-client/src/dispose.ts | 99 ++++++++ packages/sdk/sdk-client/tests/dispose.spec.ts | 231 ++++++++++++++++++ packages/subagent/README.i18n.yaml | 6 +- packages/subagent/README.md | 4 +- packages/subagent/README.zh.md | 4 +- .../README.i18n.yaml | 6 +- .../README.md | 10 +- .../README.zh.md | 10 +- .../package.json | 2 +- .../src/index.ts | 16 +- .../src/invariant.ts | 8 +- .../src/run.ts | 2 +- .../tests/loader-composition.e2e.ts | 6 +- .../tests/subagent-dsh-sdk.spec.ts} | 14 +- .../tsconfig.json | 0 .../subagent/tests/out-of-process.spec.ts | 13 +- pnpm-lock.yaml | 86 +++---- tsconfig.host.json | 2 +- 37 files changed, 521 insertions(+), 242 deletions(-) rename examples/jsonrpc-agent/tests/fixtures/subagent/{subagent-sdk => subagent-dsh-sdk}/child-mock-llm.ts (100%) rename examples/jsonrpc-agent/tests/fixtures/subagent/{subagent-sdk => subagent-dsh-sdk}/child.cordis.yml (100%) rename examples/jsonrpc-agent/tests/fixtures/subagent/{subagent-sdk => subagent-dsh-sdk}/cordis.yml (95%) rename examples/jsonrpc-agent/tests/fixtures/subagent/{subagent-sdk => subagent-dsh-sdk}/driver.ts (100%) rename examples/jsonrpc-agent/tests/fixtures/subagent/{subagent-sdk => subagent-dsh-sdk}/mock-delegating-llm.ts (100%) create mode 100644 packages/sdk/sdk-client/src/dispose.ts create mode 100644 packages/sdk/sdk-client/tests/dispose.spec.ts rename packages/subagent/{subagent-sdk => subagent-dsh-sdk}/README.i18n.yaml (69%) rename packages/subagent/{subagent-sdk => subagent-dsh-sdk}/README.md (88%) rename packages/subagent/{subagent-sdk => subagent-dsh-sdk}/README.zh.md (88%) rename packages/subagent/{subagent-sdk => subagent-dsh-sdk}/package.json (97%) rename packages/subagent/{subagent-sdk => subagent-dsh-sdk}/src/index.ts (89%) rename packages/subagent/{subagent-sdk => subagent-dsh-sdk}/src/invariant.ts (84%) rename packages/subagent/{subagent-sdk => subagent-dsh-sdk}/src/run.ts (99%) rename packages/subagent/{subagent-sdk => subagent-dsh-sdk}/tests/loader-composition.e2e.ts (96%) rename packages/subagent/{subagent-sdk/tests/subagent-sdk.spec.ts => subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts} (97%) rename packages/subagent/{subagent-sdk => subagent-dsh-sdk}/tsconfig.json (100%) diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml index 730b72dba0..84c91ba40c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: b0a5461b00c76e06a75d9ac4bd8cde9778a26ce8 -2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 856db82f6d47ee686216c8ef5f91048493a90801 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md +2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: a1481bb9e2c3abfc3111dce8a1c38835436e3e13 +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 64a55f6aa0cb4b9a4a5efc625ece6a0800217f52 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md index b0a5461b00..a1481bb9e2 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md @@ -13,9 +13,9 @@ The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-jsonrpc`, the [single-exe Three packages, layered exactly like the existing Python stack, plus one seam registration: - **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/sdk-protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). -- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/sdk-client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Session-tree scoping from `subagent.started` lineage edges is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `buildChildEnv` is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown reuses `disposeChildProcess` instead of hand-rolled terminate/kill. -- **`@deepseek-ai/dsh-subagent-sdk`** (`packages/subagent/subagent-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`. -- **`dsh-subagent-subprocess` grows a third shared concern**: child cwd resolution (`assertUsableCwd`/`validateConfiguredCwd`/`resolveChildCwd`), extracted from `subagent-acp` when the SDK backend needed the identical config-override-else-parent-session-cwd policy, prefix-parameterized for diagnostics. +- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/sdk-client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Session-tree scoping from `subagent.started` lineage edges is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). +- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`. +- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. `dsh-jsonrpc` keeps serving unchanged (the wire is byte-identical); `dsh-jsonrpc-agent-pkg` (the Python runtime closure) gains the `dsh-sdk-protocol` dependency line. @@ -23,18 +23,18 @@ Three packages, layered exactly like the existing Python stack, plus one seam re Four tiers, per [testing policy](../../../../docs/testing.md): -- **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-sdk` drives the same fake through the real provider. 100% per-file coverage on all three packages. -- **Keyless Loader composition** — `subagent-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/`) where the child is a REAL second harness runtime with its own cordis.yml; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd. The child launch resolves through `resolveExampleLaunch`, so src/lib modes both hold. +- **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-dsh-sdk` drives the same fake through the real provider. 100% per-file coverage on all three packages. +- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`) where the child is a REAL second harness runtime with its own cordis.yml; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd. The child launch resolves through `resolveExampleLaunch`, so src/lib modes both hold. - **Keyless snapshot** — `examples/jsonrpc-agent/tests/sdk.snapshot.ts` is the jsonrpc example's first snapshot suite: the real `dsh-jsonrpc-agent` runtime driven through the real `dsh-sdk-client`, replaying recorded fixtures via `llm-replay` behind the new `cordis.snapshot.yml` overlay (passed explicitly through `DSH_CORDIS_CONFIG`; the jsonrpc bin performs no snapshot config swap of its own). Three scenarios — text turn, bash tool, spawn subagent — each pinning the normalized notification stream, the SDK turn result, and the persisted parent+child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side. - **With-key e2e** — the snapshot suite's `DSH_SNAPSHOT=record` mode is the live-API path (it produced the committed fixtures); the composition e2e needs no key by design. ## Alternatives considered -**Import wire types from `dsh-jsonrpc` instead of extracting a protocol package.** Makes every SDK consumer (including `subagent-sdk`, which must not serve JSON-RPC) depend on the server plugin and its `dsh-agent`/`dsh-llm-deepseek` peer set, and leaves the notification payloads anonymous. The capability-seam rule (interface/implementation/consumer as separate packages) already names this shape; the transport is genuinely two-sided. +**Import wire types from `dsh-jsonrpc` instead of extracting a protocol package.** Makes every SDK consumer (including `subagent-dsh-sdk`, which must not serve JSON-RPC) depend on the server plugin and its `dsh-agent`/`dsh-llm-deepseek` peer set, and leaves the notification payloads anonymous. The capability-seam rule (interface/implementation/consumer as separate packages) already names this shape; the transport is genuinely two-sided. -**Have `subagent-sdk` speak raw JSON-RPC without the client SDK.** Duplicates the request/notification pairing, subscription fan-out, timeout, and teardown logic the SDK exists to own; the user's ask was explicitly a backend that *uses* the SDK, and the layering earns its keep by making the backend ~200 lines of policy over a reusable client. +**Have `subagent-dsh-sdk` speak raw JSON-RPC without the client SDK.** Duplicates the request/notification pairing, subscription fan-out, timeout, and teardown logic the SDK exists to own; the user's ask was explicitly a backend that *uses* the SDK, and the layering earns its keep by making the backend ~200 lines of policy over a reusable client. -**Fold the SDK backend into `subagent-acp` with a transport switch.** The two backends share the subprocess lifecycle but nothing about the wire (ACP SDK connection vs harness JSON-RPC), the child contract (any ACP agent vs a harness runtime), or the result extraction (`agent_message_chunk` accumulation vs session-event reading). A config discriminant would bury two protocols in one package; the shared parts are exactly what `subagent-subprocess` already holds, so that library grew instead. +**Fold the SDK backend into `subagent-acp` with a transport switch.** The two backends share the subprocess lifecycle but nothing about the wire (ACP SDK connection vs harness JSON-RPC), the child contract (any ACP agent vs a harness runtime), or the result extraction (`agent_message_chunk` accumulation vs session-event reading). A config discriminant would bury two protocols in one package; the genuinely shared provider-side parts moved into the subagent seam's `out-of-process.ts`, and the process mechanics live in the `dsh-subprocess` seam. **Give the TS SDK bundled-runtime resolution parity with Python.** Python's carrier resolution exists to ship wheels to users without Node. A TypeScript consumer definitionally has Node and (in-repo) the workspace; inventing a distribution story with no consumer violates the require-current-need rule. Deferred until a real npm-distribution consumer appears. diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md index 856db82f6d..64a55f6aa0 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -13,9 +13,9 @@ stdio JSON-RPC 服务表面(`@deepseek-ai/dsh-jsonrpc`,见[单文件可执 三个包,分层与既有 Python 栈完全一致,外加一个接缝注册: - **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/sdk-protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 -- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/sdk-client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(生成、分帧、通知扇出、有类型的错误表面、经共享处置阶梯关闭至静止)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费者的发行问题);`env` 整体替换而非合并(凭据策略归调用方;`buildChildEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除复用 `disposeChildProcess` 而不是手写 terminate/kill。 -- **`@deepseek-ai/dsh-subagent-sdk`**(`packages/subagent/subagent-sdk/`)—— 第二个进程外 `SubagentProvider`,以 `subagent-acp` 的同胞结构组织:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样的经 `onError` 汇把结果压平为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未跑回合就尘埃落定的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 -- **`dsh-subagent-subprocess` 增长出第三个共享关注点**:子进程 cwd 解析(`assertUsableCwd`/`validateConfiguredCwd`/`resolveChildCwd`),在 SDK 后端需要与 `subagent-acp` 完全相同的"配置覆盖、否则父会话 cwd"策略时从后者提取,以前缀参数化诊断信息。 +- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/sdk-client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(生成、分帧、通知扇出、有类型的错误表面、经共享处置阶梯关闭至静止)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费者的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess 接缝的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 +- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,以 `subagent-acp` 的同胞结构组织:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样的经 `onError` 汇把结果压平为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未跑回合就尘埃落定的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 +- **subagent 接缝增长出 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境擦除、进程树拆除)属于 `dsh-subprocess` 接缝;`subagent-acp` 经 `ctx.subprocess` 生成子进程,本后端则经 SDK 客户端生成(subprocess README 记载的 SDK 托管传输例外)并自行应用接缝的 `scrubbedParentEnv()`。 `dsh-jsonrpc` 的服务不变(线上字节完全一致);`dsh-jsonrpc-agent-pkg`(Python 运行时闭包)增加 `dsh-sdk-protocol` 一行依赖。 @@ -23,18 +23,18 @@ stdio JSON-RPC 服务表面(`@deepseek-ai/dsh-jsonrpc`,见[单文件可执 四层,依[测试政策](../../../../docs/testing.md): -- **免密钥单元** —— `sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-sdk` 经真实 provider 驱动同一伪运行时。三个包全部 100% 逐文件覆盖。 -- **免密钥 Loader 组合** —— `subagent-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/`),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml;断言父工具结果与子进程自己持久化的转录都携带父会话 cwd。子启动经 `resolveExampleLaunch` 解析,src/lib 两种模式都成立。 +- **免密钥单元** —— `sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-dsh-sdk` 经真实 provider 驱动同一伪运行时。三个包全部 100% 逐文件覆盖。 +- **免密钥 Loader 组合** —— `subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml;断言父工具结果与子进程自己持久化的转录都携带父会话 cwd。子启动经 `resolveExampleLaunch` 解析,src/lib 两种模式都成立。 - **免密钥快照** —— `examples/jsonrpc-agent/tests/sdk.snapshot.ts` 是 jsonrpc 示例的第一个快照套件:真实 `dsh-jsonrpc-agent` 运行时经真实 `dsh-sdk-client` 驱动,在新的 `cordis.snapshot.yml` 覆盖层后经 `llm-replay` 回放已录制夹具(经 `DSH_CORDIS_CONFIG` 显式传入;jsonrpc bin 自身不做快照配置切换)。三个场景——文本回合、bash 工具、spawn 子代理——各自钉住规范化通知流、SDK 回合结果与持久化的父+子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。 - **带密钥 e2e** —— 快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交夹具由它产出);组合 e2e 设计上无需密钥。 ## Alternatives considered -**从 `dsh-jsonrpc` 导入线类型而不是提取协议包。** 会让每个 SDK 消费者(包括绝不能提供 JSON-RPC 服务的 `subagent-sdk`)依赖服务器插件及其 `dsh-agent`/`dsh-llm-deepseek` peer 集合,且通知载荷仍然匿名。能力接缝规则(接口/实现/消费者三包分立)已经点名了这种形态;这个传输是货真价实的双边物。 +**从 `dsh-jsonrpc` 导入线类型而不是提取协议包。** 会让每个 SDK 消费者(包括绝不能提供 JSON-RPC 服务的 `subagent-dsh-sdk`)依赖服务器插件及其 `dsh-agent`/`dsh-llm-deepseek` peer 集合,且通知载荷仍然匿名。能力接缝规则(接口/实现/消费者三包分立)已经点名了这种形态;这个传输是货真价实的双边物。 -**让 `subagent-sdk` 直说裸 JSON-RPC、绕开客户端 SDK。** 会复制 SDK 存在意义所在的请求/通知配对、订阅扇出、超时与拆除逻辑;用户的要求明确是一个*使用* SDK 的后端,分层的回报是后端成为可复用客户端之上约 200 行的纯策略。 +**让 `subagent-dsh-sdk` 直说裸 JSON-RPC、绕开客户端 SDK。** 会复制 SDK 存在意义所在的请求/通知配对、订阅扇出、超时与拆除逻辑;用户的要求明确是一个*使用* SDK 的后端,分层的回报是后端成为可复用客户端之上约 200 行的纯策略。 -**把 SDK 后端折进 `subagent-acp`、用传输开关区分。** 两个后端共享子进程生命周期,但线协议(ACP SDK 连接 vs harness JSON-RPC)、子进程契约(任意 ACP 代理 vs harness 运行时)、结果提取(`agent_message_chunk` 累积 vs 会话事件读取)毫无共享。配置判别子会把两个协议埋进一个包;共享部分恰好就是 `subagent-subprocess` 已持有的,于是让那个库生长。 +**把 SDK 后端折进 `subagent-acp`、用传输开关区分。** 两个后端共享子进程生命周期,但线协议(ACP SDK 连接 vs harness JSON-RPC)、子进程契约(任意 ACP 代理 vs harness 运行时)、结果提取(`agent_message_chunk` 累积 vs 会话事件读取)毫无共享。配置判别子会把两个协议埋进一个包;真正共享的 provider 侧部分移入 subagent 接缝的 `out-of-process.ts`,进程机制则住在 `dsh-subprocess` 接缝。 **给 TS SDK 与 Python 对等的捆绑运行时解析。** Python 的载体解析是为了给没有 Node 的用户发 wheel。TypeScript 消费者定义上就有 Node 且(仓库内)有工作区;为不存在的消费者发明发行故事违反"要求当前需求"规则。推迟到真实 npm 发行消费者出现。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b29f1d7299..7dcbc697a6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1332,23 +1332,9 @@ export interface Config { export type PermissionPolicy = 'allow' | 'reject' ``` -Source: [`packages/subagent/subagent-acp/src/index.ts:20`](../packages/subagent/subagent-acp/src/index.ts) +Source: [`packages/subagent/subagent-acp/src/index.ts:21`](../packages/subagent/subagent-acp/src/index.ts) -## `@deepseek-ai/dsh-subagent-fork` - -Requires: `subagents` - -```ts config-catalog -/** Config: the registry name to register the provider under. */ -export interface Config { - /** Provider name on `ctx.subagents` (default `fork`). */ - providerName: string -} -``` - -Source: [`packages/subagent/subagent-fork/src/index.ts:25`](../packages/subagent/subagent-fork/src/index.ts) - -## `@deepseek-ai/dsh-subagent-sdk` +## `@deepseek-ai/dsh-subagent-dsh-sdk` Requires: `subagents` @@ -1395,7 +1381,21 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-sdk/src/index.ts:29`](../packages/subagent/subagent-sdk/src/index.ts) +Source: [`packages/subagent/subagent-dsh-sdk/src/index.ts:29`](../packages/subagent/subagent-dsh-sdk/src/index.ts) + +## `@deepseek-ai/dsh-subagent-fork` + +Requires: `subagents` + +```ts config-catalog +/** Config: the registry name to register the provider under. */ +export interface Config { + /** Provider name on `ctx.subagents` (default `fork`). */ + providerName: string +} +``` + +Source: [`packages/subagent/subagent-fork/src/index.ts:25`](../packages/subagent/subagent-fork/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ef2f196dbf..2ba616e366 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -797,7 +797,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -814,7 +814,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:113`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:114`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -829,7 +829,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:119`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:120`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -851,7 +851,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:130`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:131`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c91c8a11d5..152308b392 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1581,7 +1581,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:181`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d042a9e17a..c78ca1850f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -42,10 +42,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 0c90a30dba..4f3b75a735 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -61,6 +61,7 @@ flowchart TD subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] pkg_subagent_spawn["subagent-spawn"] @@ -203,6 +204,8 @@ flowchart TD subgraph group_sdk["packages/sdk"] pkg_helper["helper"] pkg_scripts["scripts"] + pkg_sdk_client["sdk-client"] + pkg_sdk_protocol["sdk-protocol"] pkg_telemetry["telemetry"] end subgraph group_storage["packages/storage"] @@ -752,13 +755,6 @@ flowchart TD pkg_hooks_claude --> pkg_session_persistence pkg_hooks_claude --> pkg_subagent pkg_hooks_claude --> pkg_tools - pkg_jsonrpc --> pkg_agent - pkg_jsonrpc --> pkg_invariants - pkg_jsonrpc --> pkg_llm - pkg_jsonrpc --> pkg_llm_deepseek - pkg_jsonrpc --> pkg_scope - pkg_jsonrpc --> pkg_session - pkg_jsonrpc --> pkg_subagent pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_commands @@ -797,6 +793,10 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_tasks pkg_agent_spine_demo --> pkg_tools pkg_agent_spine_demo --> pkg_workspace_context + pkg_sdk_protocol --> pkg_invariants + pkg_sdk_protocol --> pkg_llm + pkg_sdk_protocol --> pkg_session + pkg_sdk_protocol --> pkg_subagent pkg_tool_ralph --> pkg_agent pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm @@ -820,6 +820,14 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_jsonrpc --> pkg_agent + pkg_jsonrpc --> pkg_invariants + pkg_jsonrpc --> pkg_llm + pkg_jsonrpc --> pkg_llm_deepseek + pkg_jsonrpc --> pkg_scope + pkg_jsonrpc --> pkg_sdk_protocol + pkg_jsonrpc --> pkg_session + pkg_jsonrpc --> pkg_subagent pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -858,6 +866,17 @@ flowchart TD pkg_tui_demo --> pkg_tui pkg_tui_demo --> pkg_user_interaction pkg_tui_demo --> pkg_workspace_context + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess ``` | Package | Group | Depends on | @@ -998,13 +1017,16 @@ flowchart TD | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | +| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts similarity index 100% rename from examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts rename to examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child.cordis.yml similarity index 100% rename from examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml rename to examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child.cordis.yml diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml similarity index 95% rename from examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml rename to examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml index 6511a0c3a4..817196414c 100644 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml @@ -14,8 +14,8 @@ # providerName is omitted: the composition exercises the shipped default # (`dsh-sdk`) through the real Loader. -- id: subagent-sdk - name: '@deepseek-ai/dsh-subagent-sdk' +- id: subagent-dsh-sdk + name: '@deepseek-ai/dsh-subagent-dsh-sdk' config: command: !!js process.env.DSH_TEST_CHILD_COMMAND args: !!js JSON.parse(process.env.DSH_TEST_CHILD_ARGS ?? '[]') diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts similarity index 100% rename from examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts rename to examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts similarity index 100% rename from examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts rename to examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts diff --git a/examples/package.json b/examples/package.json index 198b228fa1..47cb4831dd 100644 --- a/examples/package.json +++ b/examples/package.json @@ -48,7 +48,7 @@ "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", - "@deepseek-ai/dsh-subagent-sdk": "workspace:*", + "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", diff --git a/knip.json b/knip.json index 359f04488b..3032b3daa2 100644 --- a/knip.json +++ b/knip.json @@ -38,9 +38,9 @@ "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", - "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts", - "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts", - "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" ], @@ -600,7 +600,7 @@ "tests/**/*.ts" ] }, - "packages/subagent/subagent-sdk": { + "packages/subagent/subagent-dsh-sdk": { "entry": [ "tests/**/*.spec.ts", "tests/**/*.e2e.ts" diff --git a/packages/sdk/sdk-client/README.i18n.yaml b/packages/sdk/sdk-client/README.i18n.yaml index 11cdef9d94..357d034ba8 100644 --- a/packages/sdk/sdk-client/README.i18n.yaml +++ b/packages/sdk/sdk-client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/sdk-client/README.md -README.md: 3945f911990fa3df362581b0fb37389110bdd386 -README.zh.md: 3814b88aab1b10c96fdf809565994f29b8e8026b +README.md: e2aaf08212307bfac0c73b5e838679a7a750a92a +README.zh.md: cbefae59d95cc0cb9d89145ad3f2ee3248822714 diff --git a/packages/sdk/sdk-client/README.md b/packages/sdk/sdk-client/README.md index 3945f91199..e2aaf08212 100644 --- a/packages/sdk/sdk-client/README.md +++ b/packages/sdk/sdk-client/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides. -Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-sdk`](../../subagent/subagent-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern. +Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern. ## DeepSeekHarness @@ -26,9 +26,9 @@ The subprocess starts lazily on first use and stays owned by the instance across The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail). -`close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks the shared stdin-EOF → SIGTERM → SIGKILL [dispose ladder](../../subagent/subagent-subprocess/README.md) (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. It is idempotent, and a closed client refuses reuse. +`close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse. -`HarnessClientOptions.env` replaces the child environment entirely when given (`undefined` inherits the parent's); callers own credential policy — `buildChildEnv` from `dsh-subagent-subprocess` is the scrub-then-inject helper for isolation-minded launches. +`HarnessClientOptions.env` replaces the child environment entirely when given (`undefined` inherits the parent's); callers own credential policy — `scrubbedParentEnv` from `dsh-subprocess` is the shared scrub base for isolation-minded launches. ## Testing diff --git a/packages/sdk/sdk-client/README.zh.md b/packages/sdk/sdk-client/README.zh.md index 3814b88aab..cbefae59d9 100644 --- a/packages/sdk/sdk-client/README.zh.md +++ b/packages/sdk/sdk-client/README.zh.md @@ -4,7 +4,7 @@ 以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层回合 API,`HarnessClient` 是低层协议客户端。纯库:不在任何 Cordis 上下文注册;它所生成的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。 -与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费者——[`dsh-subagent-sdk`](../../subagent/subagent-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。 +与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费者——[`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。 ## DeepSeekHarness @@ -26,9 +26,9 @@ console.log(result.status, result.finalResponse) 回合 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。错误表面有类型:`JsonRpcResponseError`(线上错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;线上没有取消方法,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。 -`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走共享的 stdin-EOF → SIGTERM → SIGKILL [处置阶梯](../../subagent/subagent-subprocess/README.md)(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。幂等,已关闭的客户端拒绝复用。 +`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该接缝记载的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。 -`HarnessClientOptions.env` 给定时整体替换子环境(`undefined` 原样继承父环境);凭据策略归调用方——`dsh-subagent-subprocess` 的 `buildChildEnv` 是面向隔离启动的先擦除后注入助手。 +`HarnessClientOptions.env` 给定时整体替换子环境(`undefined` 原样继承父环境);凭据策略归调用方——`dsh-subprocess` 的 `scrubbedParentEnv` 是面向隔离启动的共享擦除基底。 ## 测试 diff --git a/packages/sdk/sdk-client/src/client.ts b/packages/sdk/sdk-client/src/client.ts index 3bbcc58069..9cf5f1f3d1 100644 --- a/packages/sdk/sdk-client/src/client.ts +++ b/packages/sdk/sdk-client/src/client.ts @@ -21,6 +21,7 @@ import { type SessionPromptParams, } from '@deepseek-ai/dsh-sdk-protocol' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { disposeRuntimeProcess } from './dispose.ts' import type { HarnessClientOptions, HarnessNotification, NotificationFilter } from './types.ts' /** Retained stderr lines used to diagnose an unexpected runtime death. */ @@ -447,91 +448,6 @@ export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -/** - * Race the child's exit against a timer. Neither outcome leaves anything - * behind on the child: the exit listener is removed on timeout and the timer - * is cleared on exit, so the ladder's tiers never accumulate listeners. - */ -function exitsWithin(child: ChildProcess, ms: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) - return new Promise((resolve) => { - const onExit = (): void => { - clearTimeout(timer) - resolve(true) - } - // `.unref()` so a pending grace timer never keeps the parent's loop alive. - const timer = setTimeout(() => { - child.removeListener('exit', onExit) - resolve(false) - }, ms).unref() - child.once('exit', onExit) - }) -} - -/** Force-terminate the runtime and reject if no exit edge arrives within the grace. */ -function forceTerminateWithin(child: ChildProcess, ms: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise((resolve, reject) => { - let accepted = false - let settled = false - const cleanup = (): void => { - clearTimeout(timer) - child.off('exit', onExit) - child.off('error', onError) - } - const settle = (complete: () => void): void => { - if (settled) return - settled = true - cleanup() - complete() - } - const onExit = (): void => { settle(resolve) } - const onError = (error: Error): void => { settle(() => { reject(error) }) } - child.once('exit', onExit) - child.once('error', onError) - const timer = setTimeout(() => { - const disposition = accepted ? 'accepted' : 'refused' - settle(() => { - reject(new Error(`runtime process did not exit within ${ms}ms after SIGKILL was ${disposition}`)) - }) - }, ms).unref() - try { - accepted = child.kill('SIGKILL') - if (child.exitCode !== null || child.signalCode !== null) settle(resolve) - } catch (error: unknown) { - settle(() => { reject(new Error('SIGKILL failed', { cause: error })) }) - } - }) -} - -/** - * Tear the runtime down to quiescence, resolving only after exit: close stdin - * and allow cooperative flush, then use the host's graceful and forced - * termination semantics. POSIX sends `SIGTERM` before `SIGKILL`; Windows - * skips directly to forced termination because Node maps both signals to - * `TerminateProcess`. - * @throws When forced termination errors or the child does not report exit - * within `disposeGraceMs`. - */ -async function disposeRuntimeProcess( - child: ChildProcess, - graces: { disposeEofGraceMs: number; disposeGraceMs: number }, - platform: NodeJS.Platform = process.platform, -): Promise { - // Already gone: nothing to reap. - if (child.exitCode !== null || child.signalCode !== null) return - // 1. Close stdin and allow cooperative teardown and durable-state flush. - child.stdin?.end() - if (await exitsWithin(child, graces.disposeEofGraceMs)) return - // 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate. - if (platform !== 'win32') { - child.kill('SIGTERM') - if (await exitsWithin(child, graces.disposeGraceMs)) return - } - // 3. Force-kill and await a bounded exit edge. - await forceTerminateWithin(child, graces.disposeGraceMs) -} - /** 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 */ diff --git a/packages/sdk/sdk-client/src/dispose.ts b/packages/sdk/sdk-client/src/dispose.ts new file mode 100644 index 0000000000..4f53da2469 --- /dev/null +++ b/packages/sdk/sdk-client/src/dispose.ts @@ -0,0 +1,99 @@ +/** + * Private teardown ladder for the runtime subprocess: stdin EOF (cooperative + * quiesce), then SIGTERM, then SIGKILL, resolving only after the process has + * actually exited. The SDK client runs OUTSIDE any harness context, so it + * cannot ride the `dsh-subprocess` service — this module is the seam's + * documented exception for SDK-managed transports. + * + * @module @deepseek-ai/dsh-sdk-client/dispose + */ + +import type { ChildProcess } from 'node:child_process' + +/** + * Race the child's exit against a timer. Neither outcome leaves anything + * behind on the child: the exit listener is removed on timeout and the timer + * is cleared on exit, so the ladder's tiers never accumulate listeners. + */ +function exitsWithin(child: ChildProcess, ms: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) + return new Promise((resolve) => { + const onExit = (): void => { + clearTimeout(timer) + resolve(true) + } + // `.unref()` so a pending grace timer never keeps the parent's loop alive. + const timer = setTimeout(() => { + child.removeListener('exit', onExit) + resolve(false) + }, ms).unref() + child.once('exit', onExit) + }) +} + +/** Force-terminate the runtime and reject if no exit edge arrives within the grace. */ +function forceTerminateWithin(child: ChildProcess, ms: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() + return new Promise((resolve, reject) => { + let accepted = false + let settled = false + const cleanup = (): void => { + clearTimeout(timer) + child.off('exit', onExit) + child.off('error', onError) + } + const settle = (complete: () => void): void => { + if (settled) return + settled = true + cleanup() + complete() + } + const onExit = (): void => { settle(resolve) } + const onError = (error: Error): void => { settle(() => { reject(error) }) } + child.once('exit', onExit) + child.once('error', onError) + const timer = setTimeout(() => { + const disposition = accepted ? 'accepted' : 'refused' + settle(() => { + reject(new Error(`runtime process did not exit within ${ms}ms after SIGKILL was ${disposition}`)) + }) + }, ms).unref() + try { + accepted = child.kill('SIGKILL') + if (child.exitCode !== null || child.signalCode !== null) settle(resolve) + } catch (error: unknown) { + settle(() => { reject(new Error('SIGKILL failed', { cause: error })) }) + } + }) +} + +/** + * Tear the runtime down to quiescence, resolving only after exit: close stdin + * and allow cooperative flush, then use the host's graceful and forced + * termination semantics. POSIX sends `SIGTERM` before `SIGKILL`; Windows + * skips directly to forced termination because Node maps both signals to + * `TerminateProcess`. + * @param child - the runtime child process to tear down. + * @param graces - the EOF and termination-confirmation windows (ms). + * @param platform - the host platform, injectable for unit coverage. + * @throws When forced termination errors or the child does not report exit + * within `disposeGraceMs`. + */ +export async function disposeRuntimeProcess( + child: ChildProcess, + graces: { disposeEofGraceMs: number; disposeGraceMs: number }, + platform: NodeJS.Platform = process.platform, +): Promise { + // Already gone: nothing to reap. + if (child.exitCode !== null || child.signalCode !== null) return + // 1. Close stdin and allow cooperative teardown and durable-state flush. + child.stdin?.end() + if (await exitsWithin(child, graces.disposeEofGraceMs)) return + // 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate. + if (platform !== 'win32') { + child.kill('SIGTERM') + if (await exitsWithin(child, graces.disposeGraceMs)) return + } + // 3. Force-kill and await a bounded exit edge. + await forceTerminateWithin(child, graces.disposeGraceMs) +} diff --git a/packages/sdk/sdk-client/tests/dispose.spec.ts b/packages/sdk/sdk-client/tests/dispose.spec.ts new file mode 100644 index 0000000000..9546e6f29b --- /dev/null +++ b/packages/sdk/sdk-client/tests/dispose.spec.ts @@ -0,0 +1,231 @@ +/** + * Deterministic ladder coverage against a scriptable fake child: each + * escalation tier's timing is driven exactly (the client suite exercises the + * same ladder against real subprocesses end to end). + */ + +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' +import { disposeRuntimeProcess } from '../src/dispose.ts' + +/** What fells a scripted {@link FakeChild}. */ +type LethalTrigger = 'eof' | NodeJS.Signals + +/** Per-scenario script for a {@link FakeChild}. */ +interface FakeChildScript { + /** + * The one trigger that makes the child exit (SIGKILL always does, + * uncatchable, like a real process). Omitted: only SIGKILL fells it. + */ + diesOn?: LethalTrigger + /** Delay (ms) between the lethal trigger and the exit event. */ + delayMs?: number + /** Complete the scripted exit inside the triggering call. */ + synchronousExit?: boolean + /** `false` models a child spawned without a stdin pipe. */ + stdin?: boolean +} + +/** + * A scriptable stand-in for a ChildProcess carrying exactly the surface the + * ladder reads: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the + * `exit` event. + */ +class FakeChild extends EventEmitter { + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + readonly kills: NodeJS.Signals[] = [] + stdinEnded = false + readonly stdin: { end: () => void } | null + + constructor(private readonly script: FakeChildScript = {}) { + super() + this.stdin = script.stdin === false + ? null + : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } } + } + + kill(signal: NodeJS.Signals): boolean { + this.kills.push(signal) + this.maybeDie(signal) + return true + } + + private maybeDie(trigger: LethalTrigger): void { + // SIGKILL is uncatchable — it always fells the child; any other trigger + // only when the scenario scripts it as the lethal one. + if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return + const exit = (): void => { + if (trigger === 'eof') this.exitCode = 0 + else this.signalCode = trigger + this.emit('exit', this.exitCode, this.signalCode) + } + if (this.script.synchronousExit === true) exit() + else setTimeout(exit, this.script.delayMs ?? 0) + } +} + +/** The ladder takes a real ChildProcess; the fake carries the read surface. */ +function asChild(fake: FakeChild): ChildProcess { + return fake as unknown as ChildProcess +} + +describe('disposeRuntimeProcess', () => { + it('returns immediately for an already-exited child (no EOF, no signals)', async () => { + const fake = new FakeChild() + fake.exitCode = 0 + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.stdinEnded).toBe(false) + expect(fake.kills).toEqual([]) + }) + + it('returns immediately for a child already dead by signal', async () => { + const fake = new FakeChild() + fake.signalCode = 'SIGKILL' + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.stdinEnded).toBe(false) + expect(fake.kills).toEqual([]) + }) + + it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => { + const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 }) + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.stdinEnded).toBe(true) + expect(fake.kills).toEqual([]) + expect(fake.exitCode).toBe(0) + }) + + it('recognizes a child that exits synchronously on stdin EOF', async () => { + const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true }) + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.exitCode).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') + expect(fake.stdinEnded).toBe(true) + expect(fake.kills).toEqual(['SIGTERM']) + expect(fake.signalCode).toBe('SIGTERM') + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('recognizes a child that exits synchronously on SIGTERM', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true }) + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') + expect(fake.kills).toEqual(['SIGTERM']) + expect(fake.signalCode).toBe('SIGTERM') + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => { + const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux') + expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) + // Quiescence, not a request: at resolution the child has ACTUALLY exited + // (the exit event landed, despite the scripted post-SIGKILL delay). + expect(fake.signalCode).toBe('SIGKILL') + }) + + it('recognizes a child already gone when the final exit wait begins', async () => { + const fake = new FakeChild({ synchronousExit: true }) + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux') + expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) + expect(fake.signalCode).toBe('SIGKILL') + }) + + it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => { + const fake = new FakeChild() + vi.spyOn(fake, 'kill').mockImplementation((signal) => { + fake.kills.push(signal) + queueMicrotask(() => { + if (marker === 'exitCode') fake.exitCode = 0 + else fake.signalCode = 'SIGTERM' + }) + return true + }) + + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux') + expect(fake.kills).toEqual(['SIGTERM']) + }) + + it('walks the ladder for a child spawned without a stdin pipe', async () => { + const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') + expect(fake.kills).toEqual(['SIGTERM']) + }) + + it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) + await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32') + expect(fake.kills).toEqual(['SIGKILL']) + expect(fake.signalCode).toBe('SIGKILL') + }) + + it('propagates a forced-termination error without waiting for the grace', async () => { + const fake = new FakeChild() + const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) + vi.spyOn(fake, 'kill').mockImplementation((signal) => { + fake.kills.push(signal) + fake.emit('error', failure) + return false + }) + + await expect(disposeRuntimeProcess( + asChild(fake), + { disposeEofGraceMs: 1, disposeGraceMs: 1000 }, + 'win32', + )).rejects.toBe(failure) + expect(fake.kills).toEqual(['SIGKILL']) + expect(fake.listenerCount('error')).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('wraps a synchronous forced-termination exception and removes its listeners', async () => { + const fake = new FakeChild() + const failure = new Error('invalid signal state') + vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure }) + + await expect(disposeRuntimeProcess( + asChild(fake), + { disposeEofGraceMs: 1, disposeGraceMs: 1000 }, + 'win32', + )).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure }) + expect(fake.listenerCount('error')).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('bounds a refused forced termination that produces no error or exit', async () => { + const fake = new FakeChild() + vi.spyOn(fake, 'kill').mockImplementation((signal) => { + fake.kills.push(signal) + return false + }) + + await expect(disposeRuntimeProcess( + asChild(fake), + { disposeEofGraceMs: 1, disposeGraceMs: 10 }, + 'win32', + )).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was refused') + expect(fake.listenerCount('error')).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('bounds an accepted forced termination that never reports exit', async () => { + const fake = new FakeChild() + vi.spyOn(fake, 'kill').mockImplementation((signal) => { + fake.kills.push(signal) + return true + }) + + await expect(disposeRuntimeProcess( + asChild(fake), + { disposeEofGraceMs: 1, disposeGraceMs: 10 }, + 'win32', + )).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was accepted') + expect(fake.listenerCount('error')).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) +}) diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 17e68d1607..379c0fb498 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 8414836efd756f60258566ae3e4e00de2d4110d7 -README.zh.md: d32228495cd6c57398c88cea92ce168ecf278188 +# pnpm run verify-translation-pairing --write packages/subagent/README.md +README.md: fed0c3d6b252f5eeb8355c3b544066765999120a +README.zh.md: 45f3c83f57613c16ba00063c9da9ac720a57727e diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 518c16a51e..fed0c3d6b2 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -11,9 +11,9 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | -| `subagent-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | +| `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 7c5c2be72d..45f3c83f57 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -11,9 +11,9 @@ subagent seam 允许 agent(智能体)把工作委派给子 agent。与 [bash | `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents`) | | `subagent-acp/` | 进程外后端:在派生子进程中运行并通过 ACP(Agent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents`) | -| `subagent-sdk/` | 进程外后端:在派生子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | +| `subagent-dsh-sdk/` | 进程外后端:在派生子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | -接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 diff --git a/packages/subagent/subagent-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml similarity index 69% rename from packages/subagent/subagent-sdk/README.i18n.yaml rename to packages/subagent/subagent-dsh-sdk/README.i18n.yaml index 9e531c0406..c294bfac0a 100644 --- a/packages/subagent/subagent-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/subagent/subagent-sdk/README.md -README.md: 92c31e4823c4f8a3b2526441ab01dc732fcecbdd -README.zh.md: 3610bfcf93ddd2f458640e6149d398f0f7f4b173 +# pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md +README.md: 904b70f4d197f1d5082521b519dde89b165324ef +README.zh.md: f5879e7ae0924ac5ec2786b115bd4b9f9215c9da diff --git a/packages/subagent/subagent-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md similarity index 88% rename from packages/subagent/subagent-sdk/README.md rename to packages/subagent/subagent-dsh-sdk/README.md index 92c31e4823..904b70f4d1 100644 --- a/packages/subagent/subagent-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -1,4 +1,4 @@ -# @deepseek-ai/dsh-subagent-sdk +# @deepseek-ai/dsh-subagent-dsh-sdk English | [中文](README.zh.md) @@ -8,7 +8,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a `start(request)` resolves the child's working directory, spawns the runtime through `DeepSeekHarness`, and completes the `initialize` handshake (with the configured `provider`/`model` route) before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned. -The working directory resolves exactly like the ACP backend, through the shared [`subagent-subprocess` helpers](../subagent-subprocess/README.md): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. +The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider runs one SDK turn and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated so far when the turn was cut short — a partial answer survives cancel and error paths. @@ -38,8 +38,8 @@ The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/ | `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. | ```yaml -- id: subagent-sdk - name: '@deepseek-ai/dsh-subagent-sdk' +- id: subagent-dsh-sdk + name: '@deepseek-ai/dsh-subagent-dsh-sdk' config: providerName: dsh-sdk command: node @@ -53,7 +53,7 @@ The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/ ## Process boundary -The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. The JSON-RPC wire is the real serialization boundary. +The child environment is the [`dsh-subprocess`](../../subprocess/README.md) seam's `scrubbedParentEnv()` base — ambient credential-shaped and `DSH_*` names dropped — with explicit `config.env` values merged after the scrub. The child is spawned by the SDK client rather than through `ctx.subprocess` (the subprocess README's documented exception for SDK-managed transports), which is why this backend applies the scrub itself. The JSON-RPC wire is the real serialization boundary. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md similarity index 88% rename from packages/subagent/subagent-sdk/README.zh.md rename to packages/subagent/subagent-dsh-sdk/README.zh.md index 3610bfcf93..f5879e7ae0 100644 --- a/packages/subagent/subagent-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -1,4 +1,4 @@ -# @deepseek-ai/dsh-subagent-sdk +# @deepseek-ai/dsh-subagent-dsh-sdk [English](README.md) | 中文 @@ -8,7 +8,7 @@ SDK provider 把每个子代理作为一个完整的 DeepSeek Harness 运行时 `start(request)` 先解析子进程工作目录,经 `DeepSeekHarness` 生成运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由)。因此履行意味着子运行时已就绪、所有权已移交调用方。生成、握手或发布前取消的失败只在子进程被收割之后拒绝;工作目录解析失败在生成任何东西之前拒绝。 -工作目录的解析与 ACP 后端完全一致,经由共享的 [`subagent-subprocess` 助手](../subagent-subprocess/README.md):设置了 `cwd` 覆盖则用之(加载时校验一次),否则用发起委托的父会话 cwd——绝不用服务器进程自己的 cwd。解析出的路径同时成为子进程 cwd 与其 SDK 会话的工作区 cwd。 +工作目录的解析与 ACP 后端完全一致,经由接缝共享的进程外助手([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖则用之(加载时校验一次),否则用发起委托的父会话 cwd——绝不用服务器进程自己的 cwd。解析出的路径同时成为子进程 cwd 与其 SDK 会话的工作区 cwd。 返回的 run id 铸造于父命名空间;子运行时的会话 id 只存在于子进程内部。发布之后,provider 跑一个 SDK 回合,并从子会话事件中读取答案:最后一条完整 `assistant/message`,或回合被截断时已累积的 `text-delta` 流——部分答案在取消与错误路径上都得以保留。 @@ -38,8 +38,8 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte | `disposeGraceMs` | `3000` | 终止后的退出确认窗口;POSIX 在 SIGTERM 之后、SIGKILL 之前也等待同样时长。 | ```yaml -- id: subagent-sdk - name: '@deepseek-ai/dsh-subagent-sdk' +- id: subagent-dsh-sdk + name: '@deepseek-ai/dsh-subagent-dsh-sdk' config: providerName: dsh-sdk command: node @@ -53,7 +53,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte ## 进程边界 -子环境由 [`buildChildEnv`](../subagent-subprocess/README.md) 构建:先移除形似凭据的环境变量,再应用显式 `config.env` 值。JSON-RPC 线就是真实的序列化边界。 +子环境以 [`dsh-subprocess`](../../subprocess/README.md) 接缝的 `scrubbedParentEnv()` 为基底——移除形似凭据与 `DSH_*` 的环境变量——再在擦除之后合并显式 `config.env` 值。子进程由 SDK 客户端生成而非经 `ctx.subprocess`(subprocess README 记载的 SDK 托管传输例外),因此本后端自行应用该擦除。JSON-RPC 线就是真实的序列化边界。 本包没有默认导出。否则 Cordis loader 解包会隐藏具名 `inject` 元数据;见[事后分析 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subagent/subagent-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json similarity index 97% rename from packages/subagent/subagent-sdk/package.json rename to packages/subagent/subagent-dsh-sdk/package.json index ff9090c4de..52df9c033c 100644 --- a/packages/subagent/subagent-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-subagent-sdk", + "name": "@deepseek-ai/dsh-subagent-dsh-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, diff --git a/packages/subagent/subagent-sdk/src/index.ts b/packages/subagent/subagent-dsh-sdk/src/index.ts similarity index 89% rename from packages/subagent/subagent-sdk/src/index.ts rename to packages/subagent/subagent-dsh-sdk/src/index.ts index 37473ba9d0..829207e652 100644 --- a/packages/subagent/subagent-sdk/src/index.ts +++ b/packages/subagent/subagent-dsh-sdk/src/index.ts @@ -7,7 +7,7 @@ * `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 + * @module @deepseek-ai/dsh-subagent-dsh-sdk */ import type { Context } from 'cordis' @@ -22,7 +22,7 @@ import { type SdkRunSpec, } from './run.ts' -export const name = 'subagent-sdk' +export const name = 'subagent-dsh-sdk' export const inject = ['subagents'] /** Config: how to spawn and drive the child SDK runtime process. */ @@ -98,7 +98,7 @@ class SdkProvider implements SubagentProvider { const spec: SdkRunSpec = { command: this.config.command, args: this.config.args, - cwd: resolveChildCwd('subagent-sdk', this.config.cwd, request.parent.session.header.cwd), + cwd: resolveChildCwd('subagent-dsh-sdk', this.config.cwd, request.parent.session.header.cwd), provider: this.config.provider, model: this.config.model, env: this.config.env, @@ -108,7 +108,7 @@ class SdkProvider implements SubagentProvider { 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}`) + this.ctx.logger.warn(`subagent-dsh-sdk "${this.name}": child run failed (${stopReason}): ${error.message}`) }, } return startSdkRun(request, spec) @@ -118,12 +118,12 @@ class SdkProvider implements SubagentProvider { export function apply(ctx: Context, config: Config): void { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig - assertPositiveFinite('subagent-sdk', 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) - assertPositiveFinite('subagent-sdk', 'disposeEofGraceMs', resolved.disposeEofGraceMs) - assertPositiveFinite('subagent-sdk', 'disposeGraceMs', resolved.disposeGraceMs) + assertPositiveFinite('subagent-dsh-sdk', 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertPositiveFinite('subagent-dsh-sdk', 'disposeEofGraceMs', resolved.disposeEofGraceMs) + assertPositiveFinite('subagent-dsh-sdk', '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 configuredCwd = validateConfiguredCwd('subagent-dsh-sdk', resolved.cwd) const validated: ResolvedConfig = configuredCwd === undefined ? resolved : { ...resolved, cwd: configuredCwd } diff --git a/packages/subagent/subagent-sdk/src/invariant.ts b/packages/subagent/subagent-dsh-sdk/src/invariant.ts similarity index 84% rename from packages/subagent/subagent-sdk/src/invariant.ts rename to packages/subagent/subagent-dsh-sdk/src/invariant.ts index d47322706a..2aca0413ba 100644 --- a/packages/subagent/subagent-sdk/src/invariant.ts +++ b/packages/subagent/subagent-dsh-sdk/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-sdk`. - * @module @deepseek-ai/dsh-subagent-sdk/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-dsh-sdk`. + * @module @deepseek-ai/dsh-subagent-dsh-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' +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-dsh-sdk' /** Cordis companion plugin name. */ -export const name = 'subagent-sdk-invariant' +export const name = 'subagent-dsh-sdk-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/subagent/subagent-sdk/src/run.ts b/packages/subagent/subagent-dsh-sdk/src/run.ts similarity index 99% rename from packages/subagent/subagent-sdk/src/run.ts rename to packages/subagent/subagent-dsh-sdk/src/run.ts index 45360f54c9..7c337d4465 100644 --- a/packages/subagent/subagent-sdk/src/run.ts +++ b/packages/subagent/subagent-dsh-sdk/src/run.ts @@ -8,7 +8,7 @@ * the subprocess seam's documented exception for SDK-managed transports — * so this driver applies the seam's shared env scrub itself. * - * @module @deepseek-ai/dsh-subagent-sdk/run + * @module @deepseek-ai/dsh-subagent-dsh-sdk/run */ import { randomUUID } from 'node:crypto' diff --git a/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts b/packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts similarity index 96% rename from packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts rename to packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts index 8e6a499d4e..f0b5a17e83 100644 --- a/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts @@ -17,7 +17,7 @@ import { describe, expect, it } from 'vitest' import { type SessionEvent } from '@deepseek-ai/dsh-session' import { resolveExampleLaunch, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -const fixtureDir = new URL('../../../../examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/', import.meta.url) +const fixtureDir = new URL('../../../../examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/', import.meta.url) const driver = fileURLToPath(new URL('driver.ts', fixtureDir)) const configPath = fileURLToPath(new URL('cordis.yml', fixtureDir)) const childConfigPath = fileURLToPath(new URL('child.cordis.yml', fixtureDir)) @@ -54,8 +54,8 @@ describe('SDK subagent cwd inheritance through a real cordis.yml', () => { let childEvents: SessionEvent[] = [] let workspace = '' const { stderr } = await runLoaderSmoke({ - label: 'sdk-subagent cwd composition smoke', - tempDirPrefix: 'sdk-subagent-cwd-e2e-', + label: 'dsh-sdk-subagent cwd composition smoke', + tempDirPrefix: 'dsh-sdk-subagent-cwd-e2e-', binScript: driver, libBinScript: driver, configPath, diff --git a/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts similarity index 97% rename from packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts rename to packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index 872b375e27..bc97495218 100644 --- a/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -84,7 +84,7 @@ describe('sdkStopReason', () => { }) }) -describe('dsh-subagent-sdk provider', () => { +describe('dsh-subagent-dsh-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('dsh-sdk', request('do X')) @@ -105,7 +105,7 @@ describe('dsh-subagent-sdk provider', () => { }) it('initializes the child with the configured provider/model and the parent cwd', async () => { - const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-init-')) + const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-init-')) const recordFile = join(tmp, 'init.jsonl') try { const ctx = await setup({ FAKE_RECORD_INIT: recordFile }) @@ -185,7 +185,7 @@ describe('dsh-subagent-sdk provider', () => { // 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 tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-midcancel-')) const ready = join(tmp, 'ready') const go = join(tmp, 'go') try { @@ -235,7 +235,7 @@ describe('dsh-subagent-sdk provider', () => { }) it('rejects WITHOUT spawning when the signal is already aborted', async () => { - const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-preabort-')) + const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-preabort-')) const sentinel = join(tmp, 'spawned') try { const controller = new AbortController() @@ -324,7 +324,7 @@ describe('dsh-subagent-sdk provider', () => { const run = await ctx.subagents.start('dsh-sdk', request()) expect((await run.result).stopReason).toBe('error') expect(warnings).toHaveLength(1) - expect(warnings[0]).toContain('subagent-sdk "dsh-sdk": child run failed (error)') + expect(warnings[0]).toContain('subagent-dsh-sdk "dsh-sdk": child run failed (error)') await run.dispose() await ctx.fiber.dispose() }) @@ -379,7 +379,7 @@ describe('dsh-subagent-sdk provider', () => { }) it('uses a validated config cwd override instead of the parent session cwd', async () => { - const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-cwd-')) + const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-cwd-')) try { const ctx = await setup({ FAKE_ECHO_CWD: '1', FAKE_TEXT: 'done' }, { cwd: tmp }) const run = await ctx.subagents.start('dsh-sdk', request()) @@ -402,7 +402,7 @@ describe('dsh-subagent-sdk provider', () => { }) it('keeps named plugin exports with no default export (loader shape)', () => { - expect(sdk.name).toBe('subagent-sdk') + expect(sdk.name).toBe('subagent-dsh-sdk') expect(sdk.inject).toEqual(['subagents']) expect(typeof sdk.apply).toBe('function') expect(typeof sdk.Config).toBe('function') diff --git a/packages/subagent/subagent-sdk/tsconfig.json b/packages/subagent/subagent-dsh-sdk/tsconfig.json similarity index 100% rename from packages/subagent/subagent-sdk/tsconfig.json rename to packages/subagent/subagent-dsh-sdk/tsconfig.json diff --git a/packages/subagent/subagent/tests/out-of-process.spec.ts b/packages/subagent/subagent/tests/out-of-process.spec.ts index cea7380112..0d3307ca51 100644 --- a/packages/subagent/subagent/tests/out-of-process.spec.ts +++ b/packages/subagent/subagent/tests/out-of-process.spec.ts @@ -4,7 +4,7 @@ * under their never-reject and idempotence contracts. */ -import { chmodSync, mkdtempSync, rmSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -43,6 +43,17 @@ describe('child cwd resolution', () => { expect(() => assertUsableCwd('p', 'config cwd', join(tmpdir(), 'dsh-no-such-dir-xyz'))).toThrow('not an accessible directory') }) + it('rejects an existing path that is a file, not a directory', () => { + const tmp = mkdtempSync(join(tmpdir(), 'oop-file-')) + const file = join(tmp, 'plain.txt') + try { + writeFileSync(file, 'not a dir\n') + expect(() => assertUsableCwd('p', 'config cwd', file)).toThrow('not an accessible directory') + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + // Windows ACLs do not expose the POSIX directory search-bit state this fixture creates. it.skipIf(process.platform === 'win32')('rejects a directory without search permission', () => { // statSync().isDirectory() is true for a mode-600 directory, but a diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e32cb1a6c..a653204980 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -508,12 +508,12 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-dsh-sdk': + specifier: workspace:* + version: link:../packages/subagent/subagent-dsh-sdk '@deepseek-ai/dsh-subagent-fork': specifier: workspace:* version: link:../packages/subagent/subagent-fork - '@deepseek-ai/dsh-subagent-sdk': - specifier: workspace:* - version: link:../packages/subagent/subagent-sdk '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:* version: link:../packages/subagent/subagent-spawn @@ -3848,6 +3848,46 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/subagent/subagent-dsh-sdk: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-sdk-client': + specifier: workspace:^ + version: link:../../sdk/sdk-client + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/sdk-protocol + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/subagent/subagent-fork: dependencies: schemastery: @@ -3921,46 +3961,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/subagent/subagent-sdk: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke - '@deepseek-ai/dsh-sdk-client': - specifier: workspace:^ - version: link:../../sdk/sdk-client - '@deepseek-ai/dsh-sdk-protocol': - specifier: workspace:^ - version: link:../../sdk/sdk-protocol - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../subagent - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/subagent/subagent-spawn: dependencies: schemastery: diff --git a/tsconfig.host.json b/tsconfig.host.json index 0bf649bb87..29fbc40a8b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -143,7 +143,7 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, - { "path": "./packages/subagent/subagent-sdk" }, + { "path": "./packages/subagent/subagent-dsh-sdk" }, { "path": "./packages/tasks/tasks" }, { "path": "./packages/tasks/tasks-local" }, { "path": "./packages/tasks/tool-tasks" }, From bb096b9dd152e0f1b2eeb33951185d239a40cd44 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:28:28 +0800 Subject: [PATCH 10/13] chore: retrigger CI (push event for 4839a6889 spawned no workflows) From cb12fa7b904402492c98141de757818477972ec1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:08:16 +0800 Subject: [PATCH 11/13] fix(subagent): align SDK stop reasons after master merge --- packages/subagent/subagent-dsh-sdk/README.i18n.yaml | 4 ++-- packages/subagent/subagent-dsh-sdk/README.md | 2 +- packages/subagent/subagent-dsh-sdk/README.zh.md | 2 +- packages/subagent/subagent-dsh-sdk/src/run.ts | 2 +- .../subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts | 3 ++- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index c294bfac0a..e5c410ea74 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md -README.md: 904b70f4d197f1d5082521b519dde89b165324ef -README.zh.md: f5879e7ae0924ac5ec2786b115bd4b9f9215c9da +README.md: 95ddd154c8262e8854280e74618bdd9be9c938c0 +README.zh.md: c10145f34cd785d10f4b660a5f6414455ad42464 diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 904b70f4d1..95ddd154c8 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -16,7 +16,7 @@ The returned run id is minted in the parent namespace; the child runtime's sessi ## Stop-reason mapping -The child reports its turn outcome as a structured `TurnEndReason` on `session.finished`; the provider maps it into the seam vocabulary. `completed` → `completed`, `max-tokens` → `max-tokens`, `aborted` → `aborted`; everything else — `error`, `rejected`, `interrupted`, `disposed`, a future variant, or a turn that never ran — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting. +The child reports its turn outcome as a structured `TurnEndReason` on `session.finished`; the provider maps it into the seam vocabulary. `completed` → `completed`, `max-tokens` → `max-tokens`, `aborted` → `aborted`; everything else — `error`, `interrupted`, `disposed`, a future variant, or a turn that never ran — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting. ## Capabilities and context diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index f5879e7ae0..c10145f34c 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -16,7 +16,7 @@ SDK provider 把每个子代理作为一个完整的 DeepSeek Harness 运行时 ## 停止原因映射 -子进程在 `session.finished` 上以结构化 `TurnEndReason` 报告回合结局;provider 把它映射进接缝词汇表。`completed` → `completed`,`max-tokens` → `max-tokens`,`aborted` → `aborted`;其余一切——`error`、`rejected`、`interrupted`、`disposed`、未来变体、或根本没跑回合——映射为 `error`,不洁终止绝不报告为成功。发布后的传输层失败经 `onError` 诊断汇(接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`;接缝契约禁止 `result` 拒绝。 +子进程在 `session.finished` 上以结构化 `TurnEndReason` 报告回合结局;provider 把它映射进接缝词汇表。`completed` → `completed`,`max-tokens` → `max-tokens`,`aborted` → `aborted`;其余一切——`error`、`interrupted`、`disposed`、未来变体、或根本没跑回合——映射为 `error`,不洁终止绝不报告为成功。发布后的传输层失败经 `onError` 诊断汇(接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`;接缝契约禁止 `result` 拒绝。 ## 能力与上下文 diff --git a/packages/subagent/subagent-dsh-sdk/src/run.ts b/packages/subagent/subagent-dsh-sdk/src/run.ts index 7c337d4465..8080bf2183 100644 --- a/packages/subagent/subagent-dsh-sdk/src/run.ts +++ b/packages/subagent/subagent-dsh-sdk/src/run.ts @@ -81,7 +81,7 @@ export function sdkStopReason(reason: TurnEndReason | undefined): SubagentStopRe return 'max-tokens' case 'aborted': return 'aborted' - // error / rejected / interrupted / disposed / a future merged variant / + // error / 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: diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index bc97495218..a6b980bf45 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -75,7 +75,8 @@ describe('sdkStopReason', () => { 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') + expect(sdkStopReason({ kind: 'interrupted' })).toBe('error') + expect(sdkStopReason({ kind: 'disposed' })).toBe('error') }) it('treats an absent or unknown reason as an error', () => { From fe22273e283ca61a28d2033ec2efc5b7eab5e351 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:41:27 +0800 Subject: [PATCH 12/13] fix(agent-loop): await final session flush --- ...-21-semantic-session-checkpoints.i18n.yaml | 6 +- ...2026-07-21-semantic-session-checkpoints.md | 8 +- ...6-07-21-semantic-session-checkpoints.zh.md | 8 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 11 ++- packages/core/agent-loop/tests/agent.spec.ts | 73 +++++++++++++++++++ .../tests/subagent-inprocess.spec.ts | 3 +- 9 files changed, 100 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml index 89c1e8e8c1..0f80e5ff22 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-21-semantic-session-checkpoints.md: 0034cde40e5b07bda1573ca39fb7d51816006140 -2026-07-21-semantic-session-checkpoints.zh.md: 3351221d7eeaf1353b4adb0fa4c4dc324ec33da5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md +2026-07-21-semantic-session-checkpoints.md: 927a4c5d6d2aad5dea460ea29f97c1686e9d5398 +2026-07-21-semantic-session-checkpoints.zh.md: 6454b496aa8c03c172d6a4bc969e43e8dbca2430 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md index 0034cde40e..927a4c5d6d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md @@ -10,11 +10,11 @@ Persistence buffered every synchronous `session/event` until the loop's final tu ## Decision -`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. It flushes at `agent/post-step` after the assistant message and ordered results are recorded. The loop's existing final `turn/end` checkpoint remains the closing boundary. +`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. At `agent/step`, it flushes pending prompt input or the preceding response/result batch before the next request is derived. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. The loop's final `turn/end` checkpoint remains the closing boundary and settles before another queued turn or idle observation. -Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/post-step` listeners join this checkpoint; the loop-owned assistant message and ordered results always precede the event. +Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/step` listeners precede this checkpoint; prompt input and the preceding loop-owned assistant message and ordered results are already in the log. -Checkpoint failure and cancellation are fail-closed at effect boundaries. A rejected request checkpoint prevents adapter dispatch; a rejected tool checkpoint becomes an error result without invoking the tool body. If cancellation lands while the tool checkpoint is pending, the policy rechecks the signal and returns the canonical `ABORTED_BEFORE_DISPATCH` result. A rejected post-step checkpoint stops continuation before another model request. Persistence serialization continues to belong to the coordinator, so concurrent tool checkpoints cannot duplicate event sequences. +Checkpoint failure and cancellation are fail-closed at effect boundaries. A rejected request checkpoint prevents adapter dispatch; a rejected tool checkpoint becomes an error result without invoking the tool body. If cancellation lands while the tool checkpoint is pending, the policy rechecks the signal and returns the canonical `ABORTED_BEFORE_DISPATCH` result. A rejected between-step checkpoint closes the turn before another model request. A rejected final turn checkpoint is reported live and does not prevent later queued work. Persistence serialization continues to belong to the coordinator, so concurrent tool checkpoints cannot duplicate event sequences. The ACP app owns its bridge, checkpoint policy, and persistence backend in one ordered Cordis effect. Cordis unloads sibling plugin effects concurrently, so independent mounts would let persistence detach while bridge teardown was still closing an interrupted turn. The composite lifecycle unloads the bridge first, waits for its agents to quiesce and flush the real `step/end` and `turn/end`, then removes checkpoint scheduling and persistence. @@ -26,4 +26,4 @@ Flushing every event or streaming chunk minimizes loss but turns local append an ## Consequences -Hard-crash recovery retains the complete model request, durable tool intent, and complete settled step at the nearest semantic boundary while allowing partial streaming chunks since the previous boundary to remain lossy. Default CLI, TUI, ACP, Python SDK runtime, headless persistence tests, and JSON-RPC compositions mount the policy with their persistence backend. Unit tests cover ordering, cancellation during a checkpoint, fail-closed behavior, nested dispatch, disposal, and Loader shape; a real child process killed with `SIGKILL` proves request and tool-intent recovery through JSONL, and the shared persistence contract proves both recovery classifications across backends. The crash harness waits for the expected marker contents rather than path existence, so open-before-write visibility cannot trigger the kill early. Keyless ACP snapshots prove both that retry-risk guidance reaches resumed history and the next model turn and that graceful cancellation persists the loop's real closing boundaries. +Hard-crash recovery retains the complete model request, durable tool intent, and complete settled step at the nearest semantic boundary while allowing partial streaming chunks since the previous boundary to remain lossy. Default CLI, TUI, ACP, Python SDK runtime, headless persistence tests, and JSON-RPC compositions mount the policy with their persistence backend. Unit tests cover ordering, cancellation during a checkpoint, fail-closed behavior, nested dispatch, disposal, Loader shape, and final-checkpoint ordering and failure containment; a real child process killed with `SIGKILL` proves request and tool-intent recovery through JSONL, and the shared persistence contract proves both recovery classifications across backends. The crash harness waits for the expected marker contents rather than path existence, so open-before-write visibility cannot trigger the kill early. Keyless ACP and SDK snapshots prove that retry-risk guidance reaches resumed history and the next model turn, graceful cancellation persists the loop's real closing boundaries, and SDK shutdown observes the complete persisted turn. diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md index 3351221d7e..6454b496aa 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md @@ -10,11 +10,11 @@ Status: implemented ## 决策 -`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。它还会在 `agent/post-step` 时刷新会话,此时模型消息与按序结果都已记录。现有的最终 `turn/end` 检查点仍是轮次的收尾边界。 +`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。在 `agent/step` 时,该插件会在推导下一个请求前刷新待持久化的提示词输入或前一批响应/结果。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。循环的最终 `turn/end` 检查点仍是轮次的收尾边界,并会在处理另一个已排队轮次或观察到空闲状态之前完成。 -持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/post-step` 监听器追加的事件是否会纳入本检查点;循环自身记录的助手消息与有序结果始终先于该事件。 +持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/step` 监听器追加的事件是否先于本检查点;提示词输入以及前一批由循环自身记录的助手消息与有序结果都已在日志中。 -检查点失败与取消在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体。如果在工具检查点等待期间收到取消,策略会重新检查信号,并返回标准的 `ABORTED_BEFORE_DISPATCH` 结果。步骤后检查点被拒绝时,系统会在发起下一个模型请求前停止继续执行。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序号。 +检查点失败与取消在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体。如果在工具检查点等待期间收到取消,策略会重新检查信号,并返回标准的 `ABORTED_BEFORE_DISPATCH` 结果。步骤间检查点被拒绝时,系统会在发起下一个模型请求前结束该轮次。轮次的最终检查点被拒绝时,系统会实时报告该失败,但不会阻止后续排队工作。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序号。 ACP(Agent Client Protocol)应用在一个有序 Cordis effect 中统一持有其桥接层、检查点策略与持久化后端。Cordis 会并发卸载同级插件的 effect;如果分别加载,桥接层仍在为被中断的轮次收尾时,持久化后端就可能已经卸载。组合生命周期会先卸载桥接层,等待其各 agent 达到静止,并刷新真实的 `step/end` 与 `turn/end`,再移除检查点调度与持久化。 @@ -26,4 +26,4 @@ ACP(Agent Client Protocol)应用在一个有序 Cordis effect 中统一持 ## 后果 -发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。崩溃 harness 会等待预期的标记内容,而不是仅等待路径存在,因此文件在写入前因打开而可见时,不会导致该 harness 提前终止子进程。无密钥 ACP 快照既证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,也证明取消流程正常收尾时,系统会持久化由循环实际生成的闭合边界。 +发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状,以及最终检查点的顺序与故障隔离;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。崩溃 harness 会等待预期的标记内容,而不是仅等待路径存在,因此文件在写入前因打开而可见时,不会导致该 harness 提前终止子进程。无密钥 ACP 与 SDK 快照证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,取消流程正常收尾时系统会持久化由循环实际生成的闭合边界,且 SDK 关闭流程会观察到已完整持久化的轮次。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e940800cf9..5fd3078fd6 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 3b0f72400ab1e6a9157aed2966b4a17c36e0c3ac -architecture.zh.md: fa21c82a686d88a9bbec02180feae1dd0dbf4e47 +architecture.md: f00a8fd8f7fbda05579fa63cc4f09b29500af289 +architecture.zh.md: b5d9507c02f752b23ab41a77a6fb5c266c577b66 diff --git a/docs/architecture.md b/docs/architecture.md index 3b0f72400a..f00a8fd8f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -143,7 +143,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw **Model-visible ⟺ logged**: messages at `step/start` plus the folded `request/header` reconstruct every request; package-owned `dsh-agent-loop/invariant` can assert this through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede adapter dispatch, top-level tool dispatch, and the next request's `agent/step`. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). +Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede each request and top-level tool dispatch, then follow `turn/end` before another queued turn or idle observation. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). `ctx.sessions.appendOutOfBand()` adds plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index fa21c82a68..b5d9507c02 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -143,7 +143,7 @@ idle inject: **模型可见 ⟺ 已记录**:`step/start` 时的消息与折叠后的 `request/header` 可以重建每个请求;该包的 `dsh-agent-loop/invariant` 可通过 `ctx.invariants` 断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于适配器分发前、顶层工具分发前,以及下一次请求的 `agent/step`。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 +持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于每次请求与顶层工具分发之前,并在 `turn/end` 之后、处理另一个已排队轮次或观察到空闲状态之前执行。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 `ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 2d25185733..4385ae3429 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -190,7 +190,7 @@ export class ReactLoopAgent implements Agent { // but the waiter must not gamble quiescence on that: a future escape // still counts as settled activity. /* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */ - while (this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) { + while (this.busy || this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) { await this.done.catch(() => undefined) } } @@ -422,6 +422,15 @@ export class ReactLoopAgent implements Agent { signal.removeEventListener('abort', cancelRetry) } + if (opened) { + try { + await this.loopCtx.sessions.flush(this.session) + } catch (error: unknown) { + this.loopCtx.logger.warn(`agent "${this.id}": session/flush failed at turn ${turn}: ${errorChain(error)}`) + emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) + } + } + if (retry) { await this.run({ kind: 'retry' }) } else { diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index e0aa4467dc..0562e8bbe3 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -88,6 +88,79 @@ describe('Agent', () => { expect(statuses).toEqual(['running', 'idle']) }) + it('awaits the turn-end checkpoint before claiming the next queued turn', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const firstFlush = Promise.withResolvers() + const flushedTurns: number[] = [] + ctx.on('session/flush', async (session) => { + const turnEnd = session.events.findLast(event => event.type === 'turn/end') + flushedTurns.push(turnEnd?.data.turn ?? 0) + if (turnEnd?.data.turn === 1) await firstFlush.promise + }) + + send(agent, 'first') + send(agent, 'second') + + await vi.waitFor(() => { expect(flushedTurns).toEqual([1]) }) + expect(adapter.requests).toHaveLength(1) + firstFlush.resolve(undefined) + await agent.whenIdle() + + expect(adapter.requests).toHaveLength(2) + expect(flushedTurns).toEqual([1, 2]) + }) + + it('keeps whenIdle pending through the final turn checkpoint', async () => { + const ctx = await harness(new MockAdapter([textResponse('done')])) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const flush = Promise.withResolvers() + let flushStarted = false + ctx.on('session/flush', () => { + flushStarted = true + return flush.promise + }) + + send(agent, 'go') + await vi.waitFor(() => { expect(flushStarted).toBe(true) }) + let idleSettled = false + const idle = agent.whenIdle().then(() => { idleSettled = true }) + await Promise.resolve() + expect(idleSettled).toBe(false) + + flush.resolve(undefined) + await idle + expect(agent.status).toBe('idle') + }) + + it('reports a rejected turn-end checkpoint and continues queued work', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(adapter) + const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const failure = new Error('disk unavailable') + const errors: { turn: number; step: number; error: unknown }[] = [] + let flushes = 0 + ctx.on('session/flush', () => { + flushes += 1 + if (flushes === 1) throw failure + }) + ctx.on('agent/error', (subject, turn, step, error) => { + if (subject === agent) errors.push({ turn, step, error }) + }) + + send(agent, 'first') + send(agent, 'second') + await agent.whenIdle() + + expect(adapter.requests).toHaveLength(2) + expect(flushes).toBe(2) + expect(errors).toEqual([{ turn: 1, step: 1, error: failure }]) + expect(warning).toHaveBeenCalledWith(expect.stringContaining('session/flush failed at turn 1: disk unavailable')) + warning.mockRestore() + }) + it('whenIdle() resolves immediately without active work', async () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index d2ef7e1643..6f084028c8 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -79,8 +79,9 @@ describe('startInProcessRun', () => { const result = await run.result const child = ctx.agents.get(run.id)! + expect(injected).toBe(true) expect(child.session.events.findLast(event => event.type === 'turn/end')) - .toMatchObject({ data: { reason: { kind: 'max-tokens' } } }) + .toMatchObject({ data: { reason: { kind: 'completed' } } }) expect(result.stopReason).toBe('max-tokens') await run.dispose() }) From c8814d7347337692db9c7de23f3c302e80aed236 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:13:09 +0800 Subject: [PATCH 13/13] test(acp): await goal cancellation persistence --- .../acp-agent/tests/goal-snapshots/goal-session/input.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/input.json b/examples/acp-agent/tests/goal-snapshots/goal-session/input.json index 563e3bd1c6..1301b9d264 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/input.json +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/input.json @@ -8,6 +8,7 @@ "waitForText": "GOAL ROUND ONE" }, { "op": "waitForTurnStart", "minimumTurn": 3 }, - { "op": "cancel", "waitForFile": { "path": ".dsh-snapshot-goal-cancel-ready" } } + { "op": "cancel", "waitForFile": { "path": ".dsh-snapshot-goal-cancel-ready" } }, + { "op": "waitForTurnEnd" } ] }