fix(sdk-client): address ds-review-bot findings
- 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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
# 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;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write packages/sdk/sdk-client/README.md
|
||||||
README.md: 82e344014ac01120986ee2b4e07ac21192506ff7
|
README.md: 3945f911990fa3df362581b0fb37389110bdd386
|
||||||
README.zh.md: 6e67f57ab92a75be3600cbdd0ce6bf21832fd2c8
|
README.zh.md: 3814b88aab1b10c96fdf809565994f29b8e8026b
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const result = await harness.run('say hi')
|
|||||||
console.log(result.status, result.finalResponse)
|
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
|
## HarnessClient
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const result = await harness.run('say hi')
|
|||||||
console.log(result.status, result.finalResponse)
|
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
|
## HarnessClient
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||||
import { HarnessClient } from './client.ts'
|
import { HarnessClient, isRecord, SdkProtocolError } from './client.ts'
|
||||||
import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, TurnResult } from './types.ts'
|
import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, TurnResult } from './types.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable SDK for running DeepSeek Harness agent turns in a runtime
|
* Reusable SDK for running DeepSeek Harness agent turns in a runtime
|
||||||
@@ -19,33 +20,52 @@ import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, TurnRes
|
|||||||
* child is reaped.
|
* child is reaped.
|
||||||
*/
|
*/
|
||||||
export class DeepSeekHarness implements AsyncDisposable {
|
export class DeepSeekHarness implements AsyncDisposable {
|
||||||
/** The underlying JSON-RPC client (exposed for low-level access). */
|
private clientInstance: HarnessClient
|
||||||
readonly client: HarnessClient
|
private readonly launch: HarnessClientOptions
|
||||||
private readonly cwd: string
|
private readonly cwd: string
|
||||||
private readonly provider: string
|
private readonly provider: string
|
||||||
private readonly model: string
|
private readonly model: string
|
||||||
private initialized: Promise<void> | undefined
|
private initialized: Promise<void> | undefined
|
||||||
|
private closed = false
|
||||||
|
|
||||||
/** @param options - runtime launch spec plus the session route (cwd/provider/model). */
|
/** @param options - runtime launch spec plus the session route (cwd/provider/model). */
|
||||||
constructor(options: DeepSeekHarnessOptions) {
|
constructor(options: DeepSeekHarnessOptions) {
|
||||||
this.client = new HarnessClient(options.launch)
|
this.launch = options.launch
|
||||||
this.cwd = options.cwd ?? options.launch.cwd ?? process.cwd()
|
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.provider = options.provider ?? 'deepseek'
|
||||||
this.model = options.model ?? 'deepseek-v4-flash'
|
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.
|
* @returns settlement of the (memoized) handshake.
|
||||||
*/
|
*/
|
||||||
start(): Promise<void> {
|
start(): Promise<void> {
|
||||||
this.initialized ??= (async () => {
|
this.initialized ??= (async () => {
|
||||||
try {
|
try {
|
||||||
this.client.start()
|
this.clientInstance.start()
|
||||||
await this.client.initialize({ cwd: this.cwd, provider: this.provider, model: this.model })
|
await this.clientInstance.initialize({ cwd: this.cwd, provider: this.provider, model: this.model })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.initialized = undefined
|
this.initialized = undefined
|
||||||
await this.client.close()
|
await this.clientInstance.close()
|
||||||
|
if (!this.closed) this.clientInstance = new HarnessClient(this.launch)
|
||||||
throw error
|
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.
|
* @returns settlement of the complete teardown.
|
||||||
*/
|
*/
|
||||||
close(): Promise<void> {
|
close(): Promise<void> {
|
||||||
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 subscription = client.subscribeSessionTree(this.id)
|
||||||
const collect = (notification: HarnessNotification): void => {
|
const collect = (notification: HarnessNotification): void => {
|
||||||
notifications.push(notification)
|
|
||||||
options?.onNotification?.(notification)
|
|
||||||
if (notification.method === 'session.event' && notification.params.sessionId === this.id) {
|
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) {
|
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'
|
status = notification.params.status === 'ok' ? 'ok' : 'error'
|
||||||
reason = notification.params.reason as TurnEndReason | undefined
|
|
||||||
finished = true
|
finished = true
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
notifications.push(notification)
|
||||||
|
options?.onNotification?.(notification)
|
||||||
}
|
}
|
||||||
const accepted = client.prompt(this.id, contentBlocks)
|
const accepted = client.prompt(this.id, contentBlocks)
|
||||||
// Drain concurrently so observers see progress while the prompt request
|
// 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
|
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.
|
* Extract the concatenated text of the last assistant message.
|
||||||
* @param events - the turn's `session.event` payloads in wire order.
|
* @param events - the turn's `session.event` payloads in wire order.
|
||||||
|
|||||||
@@ -81,8 +81,9 @@ export class NotificationSubscription implements AsyncIterable<HarnessNotificati
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Await the next matching notification.
|
* Await the next matching notification.
|
||||||
* @returns the notification; rejects once the runtime is closed or the
|
* @returns the notification; after the runtime died, drains what was
|
||||||
* subscription itself is closed.
|
* already delivered and then rejects; after {@link close}, rejects
|
||||||
|
* immediately (the queue is dropped).
|
||||||
*/
|
*/
|
||||||
next(): Promise<HarnessNotification> {
|
next(): Promise<HarnessNotification> {
|
||||||
const queued = this.state.queue.shift()
|
const queued = this.state.queue.shift()
|
||||||
@@ -104,11 +105,15 @@ export class NotificationSubscription implements AsyncIterable<HarnessNotificati
|
|||||||
/** Detach from the client; queued items drop and pending waiters reject. */
|
/** Detach from the client; queued items drop and pending waiters reject. */
|
||||||
close(): void {
|
close(): void {
|
||||||
this.unsubscribe()
|
this.unsubscribe()
|
||||||
|
// The drop is part of this method's contract; a runtime-death fail() keeps
|
||||||
|
// the queue so already-delivered notifications remain drainable.
|
||||||
|
this.state.queue.length = 0
|
||||||
this.fail(new TransportClosedError('notification subscription closed'))
|
this.fail(new TransportClosedError('notification subscription closed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject pending and future waits (delivery stops; the first failure wins).
|
* Reject pending and future waits (delivery stops; the first failure wins).
|
||||||
|
* Already-queued notifications remain drainable via {@link next}/{@link tryNext}.
|
||||||
* @param error - the terminal failure delivered to waiters.
|
* @param error - the terminal failure delivered to waiters.
|
||||||
*/
|
*/
|
||||||
fail(error: Error): void {
|
fail(error: Error): void {
|
||||||
@@ -117,11 +122,22 @@ export class NotificationSubscription implements AsyncIterable<HarnessNotificati
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deliver one notification to a waiter or the queue when the filter matches.
|
* Deliver one notification to a waiter or the queue when the filter
|
||||||
|
* matches. A throwing filter fails only THIS subscription (detached, the
|
||||||
|
* throw becomes its terminal error) — it never disturbs sibling
|
||||||
|
* subscriptions or the transport's read loop, mirroring the Python client.
|
||||||
* @param notification - the wire notification to deliver.
|
* @param notification - the wire notification to deliver.
|
||||||
*/
|
*/
|
||||||
push(notification: HarnessNotification): void {
|
push(notification: HarnessNotification): void {
|
||||||
if (this.state.filter !== undefined && !this.state.filter(notification)) return
|
let matches: boolean
|
||||||
|
try {
|
||||||
|
matches = this.state.filter === undefined || this.state.filter(notification)
|
||||||
|
} catch (error) {
|
||||||
|
this.unsubscribe()
|
||||||
|
this.fail(error instanceof Error ? error : new Error(String(error)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!matches) return
|
||||||
const waiter = this.state.waiters.shift()
|
const waiter = this.state.waiters.shift()
|
||||||
if (waiter !== undefined) waiter.resolve(notification)
|
if (waiter !== undefined) waiter.resolve(notification)
|
||||||
else this.state.queue.push(notification)
|
else this.state.queue.push(notification)
|
||||||
@@ -273,22 +289,18 @@ export class HarnessClient {
|
|||||||
const transport = this.transport
|
const transport = this.transport
|
||||||
/* v8 ignore next -- start() either sets the transport or throws */
|
/* v8 ignore next -- start() either sets the transport or throws */
|
||||||
if (transport === undefined) throw new TransportClosedError('DeepSeek Harness runtime is not running')
|
if (transport === undefined) throw new TransportClosedError('DeepSeek Harness runtime is not running')
|
||||||
const pending = transport.request(method, params ?? {})
|
|
||||||
const timeout = timeoutMs ?? this.options.requestTimeoutMs
|
const timeout = timeoutMs ?? this.options.requestTimeoutMs
|
||||||
try {
|
try {
|
||||||
if (timeout === undefined) return await pending
|
if (timeout === undefined) return await transport.request(method, params ?? {})
|
||||||
let timer: NodeJS.Timeout | undefined
|
// The abort signal makes the timeout an abandonment: the transport drops
|
||||||
|
// its pending entry, so repeated bounded requests against a hung method
|
||||||
|
// retain no per-call state (the server-side work still runs to close).
|
||||||
|
const abandon = new AbortController()
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
abandon.abort(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for the DeepSeek Harness runtime`))
|
||||||
|
}, timeout)
|
||||||
try {
|
try {
|
||||||
return await Promise.race([
|
return await transport.request(method, params ?? {}, abandon.signal)
|
||||||
pending,
|
|
||||||
new Promise<never>((_, reject) => {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
// The abandoned wire promise settles on close; keep it handled.
|
|
||||||
pending.catch(() => {})
|
|
||||||
reject(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for the DeepSeek Harness runtime`))
|
|
||||||
}, timeout)
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
@@ -303,12 +315,18 @@ export class HarnessClient {
|
|||||||
/**
|
/**
|
||||||
* Subscribe to server notifications.
|
* Subscribe to server notifications.
|
||||||
* @param filter - optional predicate; omitted means every notification.
|
* @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 {
|
subscribe(filter?: NotificationFilter): NotificationSubscription {
|
||||||
const id = String(this.subscriptionSerial++)
|
const id = String(this.subscriptionSerial++)
|
||||||
const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined }
|
const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined }
|
||||||
const subscription = new NotificationSubscription(state, () => { this.subscriptions.delete(id) })
|
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)
|
this.subscriptions.set(id, subscription)
|
||||||
return subscription
|
return subscription
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,16 @@
|
|||||||
* - `FAKE_MALFORMED`: `initialize` returns `{}` (no serverInfo); `prompt` returns `{}` (no accepted).
|
* - `FAKE_MALFORMED`: `initialize` returns `{}` (no serverInfo); `prompt` returns `{}` (no accepted).
|
||||||
* - `FAKE_MALFORMED_PROMPT`: `initialize` is normal; only `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`: `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_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
|
||||||
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
|
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
|
||||||
* arrives, then poll for the GO file before answering (deterministic
|
* arrives, then poll for the GO file before answering (deterministic
|
||||||
@@ -78,8 +88,20 @@ function assistantText(): string {
|
|||||||
|
|
||||||
function runTurn(sessionId: string): void {
|
function runTurn(sessionId: string): void {
|
||||||
const text = assistantText()
|
const text = assistantText()
|
||||||
|
if (env.FAKE_MALFORMED_EVENT !== undefined) {
|
||||||
|
notify('session.event', { sessionId, event: 42 })
|
||||||
|
return
|
||||||
|
}
|
||||||
event(sessionId, 'turn/start', { turn: 0 })
|
event(sessionId, 'turn/start', { turn: 0 })
|
||||||
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text } })
|
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', {
|
event(sessionId, 'assistant/message', {
|
||||||
turn: 0,
|
turn: 0,
|
||||||
step: 0,
|
step: 0,
|
||||||
@@ -110,7 +132,9 @@ function runTurn(sessionId: string): void {
|
|||||||
notify('session.finished', {
|
notify('session.finished', {
|
||||||
sessionId,
|
sessionId,
|
||||||
status: env.FAKE_STATUS ?? 'ok',
|
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' } } })
|
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted init failure', data: { hint: 'fake' } } })
|
||||||
return
|
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) {
|
if (env.FAKE_MALFORMED !== undefined) {
|
||||||
respond({})
|
respond({})
|
||||||
return
|
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' } })
|
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
|
||||||
return
|
return
|
||||||
case 'session/prompt': {
|
case 'session/prompt': {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
* and session-tree scoping, error surfaces, timeouts, and the dispose ladder.
|
* 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 { 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 { fileURLToPath } from 'node:url'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
@@ -122,6 +122,31 @@ describe('DeepSeekHarness', () => {
|
|||||||
expect(records).toEqual([{ cwd: dir, provider: 'custom-provider', model: 'custom-model' }])
|
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 () => {
|
it('propagates a JSON-RPC error response from initialize and closes the runtime', async () => {
|
||||||
const harness = harnessWith({ FAKE_INIT_ERROR: '1' })
|
const harness = harnessWith({ FAKE_INIT_ERROR: '1' })
|
||||||
const failure = await harness.run('boom').then(
|
const failure = await harness.run('boom').then(
|
||||||
@@ -134,6 +159,23 @@ describe('DeepSeekHarness', () => {
|
|||||||
await expect(harness.run('later')).rejects.toThrow()
|
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 () => {
|
it('rejects a malformed initialize result as a protocol error', async () => {
|
||||||
const harness = harnessWith({ FAKE_MALFORMED: '1' })
|
const harness = harnessWith({ FAKE_MALFORMED: '1' })
|
||||||
await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError)
|
await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError)
|
||||||
@@ -162,6 +204,22 @@ describe('HarnessClient', () => {
|
|||||||
await client.close()
|
await client.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('a timed-out request leaves no pending transport state', async () => {
|
||||||
|
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }))
|
||||||
|
cleanups.push(() => client.close())
|
||||||
|
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||||
|
for (let round = 0; round < 3; round++) {
|
||||||
|
await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('x') }, 50))
|
||||||
|
.rejects.toThrow(RequestTimeoutError)
|
||||||
|
}
|
||||||
|
// Abandonment removed each pending entry at its timeout; a hung method
|
||||||
|
// retains nothing per call. (Private map read is the observable here —
|
||||||
|
// no wire surface reports transport bookkeeping.)
|
||||||
|
const transport = (client as unknown as { transport: { pending: Map<string, unknown> } }).transport
|
||||||
|
expect(transport.pending.size).toBe(0)
|
||||||
|
await client.close()
|
||||||
|
})
|
||||||
|
|
||||||
it('applies the client-wide request timeout when no per-call bound is given', async () => {
|
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 }))
|
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }, { requestTimeoutMs: 400 }))
|
||||||
cleanups.push(() => client.close())
|
cleanups.push(() => client.close())
|
||||||
@@ -255,6 +313,10 @@ describe('HarnessClient', () => {
|
|||||||
expect(finished.method).toBe('session.finished')
|
expect(finished.method).toBe('session.finished')
|
||||||
expect(finishedOnly.tryNext()).toBeUndefined()
|
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.
|
// Async iteration consumes queued items and then parks.
|
||||||
const collected: string[] = []
|
const collected: string[] = []
|
||||||
for await (const notification of all) {
|
for await (const notification of all) {
|
||||||
@@ -269,6 +331,56 @@ describe('HarnessClient', () => {
|
|||||||
await client.close()
|
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 () => {
|
it('closes subscriptions with the runtime and rejects parked waiters', async () => {
|
||||||
const client = new HarnessClient(fakeLaunch())
|
const client = new HarnessClient(fakeLaunch())
|
||||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
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', () => {
|
describe('stderr tail bound', () => {
|
||||||
it('keeps only the newest lines up to the limit', async () => {
|
it('keeps only the newest lines up to the limit', async () => {
|
||||||
const manyLines = Array.from({ length: 450 }, (_, i) => `line-${i}`).join('\n')
|
const manyLines = Array.from({ length: 450 }, (_, i) => `line-${i}`).join('\n')
|
||||||
|
|||||||
@@ -109,15 +109,47 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
|||||||
this.notificationHandler = handler
|
this.notificationHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
request(method: string, params: object): Promise<unknown> {
|
/**
|
||||||
|
* Send a request and await its response.
|
||||||
|
* @param method - the JSON-RPC method name.
|
||||||
|
* @param params - the request parameters object.
|
||||||
|
* @param signal - optional abandonment signal: aborting removes the pending
|
||||||
|
* entry (no state is retained for a response that may never come) and
|
||||||
|
* rejects with the signal's reason.
|
||||||
|
* @returns the result; rejects per {@link JsonRpcTransportPeer.request}.
|
||||||
|
*/
|
||||||
|
request(method: string, params: object, signal?: AbortSignal): Promise<unknown> {
|
||||||
const id = `req_${randomUUID().replaceAll('-', '')}`
|
const id = `req_${randomUUID().replaceAll('-', '')}`
|
||||||
const message = { jsonrpc: '2.0', id, method, params }
|
const message = { jsonrpc: '2.0', id, method, params }
|
||||||
return new Promise((resolve, reject) => {
|
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 {
|
try {
|
||||||
this.write(message)
|
this.write(message)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.pending.delete(id)
|
this.pending.delete(id)
|
||||||
|
detach()
|
||||||
reject(error instanceof Error ? error : new Error(String(error)))
|
reject(error instanceof Error ? error : new Error(String(error)))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -240,3 +272,8 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
|||||||
function objectParams(params: unknown): Record<string, unknown> {
|
function objectParams(params: unknown): Record<string, unknown> {
|
||||||
return params && typeof params === 'object' && !Array.isArray(params) ? params as Record<string, unknown> : {}
|
return params && typeof params === 'object' && !Array.isArray(params) ? params as Record<string, unknown> : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalize an abort reason into the rejection Error (a non-Error reason is stringified). */
|
||||||
|
function abortError(reason: unknown): Error {
|
||||||
|
return reason instanceof Error ? reason : new Error(`JSON-RPC request aborted: ${String(reason)}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,29 @@ describe('JsonRpcLineTransport', () => {
|
|||||||
b.close()
|
b.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects immediately on a pre-aborted signal without registering pending state', async () => {
|
||||||
|
const { b } = transportPair()
|
||||||
|
b.start()
|
||||||
|
const controller = new AbortController()
|
||||||
|
controller.abort(new Error('already gone'))
|
||||||
|
await expect(b.request('never-sent', {}, controller.signal)).rejects.toThrow('already gone')
|
||||||
|
expect((b as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0)
|
||||||
|
b.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('abandons a pending request on abort, stringifying a non-Error reason', async () => {
|
||||||
|
const { b } = transportPair()
|
||||||
|
b.start()
|
||||||
|
const controller = new AbortController()
|
||||||
|
const pending = b.request('never-answered', {}, controller.signal)
|
||||||
|
controller.abort('plain-string-reason')
|
||||||
|
await expect(pending).rejects.toThrow('JSON-RPC request aborted: plain-string-reason')
|
||||||
|
// The abandonment removed the pending entry — nothing is retained for a
|
||||||
|
// response that may never come.
|
||||||
|
expect((b as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0)
|
||||||
|
b.close()
|
||||||
|
})
|
||||||
|
|
||||||
it('preserves structured error data from an error response frame', async () => {
|
it('preserves structured error data from an error response frame', async () => {
|
||||||
const { aToB, bToA, b } = transportPair()
|
const { aToB, bToA, b } = transportPair()
|
||||||
b.start()
|
b.start()
|
||||||
|
|||||||
Reference in New Issue
Block a user