2026-07-11 14:08:12 +08:00
|
|
|
import { createServer } from 'node:http'
|
|
|
|
|
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
|
|
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
|
|
import { join } from 'node:path'
|
|
|
|
|
import { tmpdir } from 'node:os'
|
|
|
|
|
import { PassThrough, Writable } from 'node:stream'
|
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.
Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.
The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.
Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:06 +08:00
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
2026-08-18 17:36:59 +08:00
|
|
|
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
2026-07-15 15:57:57 +08:00
|
|
|
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
feat(sdk): launch TypeScript clients through dsh profiles
Replace the TypeScript SDK's public arbitrary command/argv launch surface with the same-version dsh CLI, a named profile, ordered per-launch patches, optional process cwd, and explicit Harness home selection. Installed consumers use the built CLI; clean source checkouts use the package's src/bin.ts through an absolute tsx/esm loader and a source-only patch that omits build-generated Typert loading.
Materialize explicit or inherited environments at spawn time, keep profile-internal patches below caller patches, and resolve every caller-relative path before the child starts. The SDK subagent provider validates its optional CLI and patch files at plugin load and requires an isolated absolute child home.
Treat JSON-RPC initialize as the Loader-owned readiness point: Loader settlement joins entry imports, fiber lifecycle work, and synchronous effect registration, so the server needs no scheduler tick. A delayed profile entry registers a private adapter before initialize resolves, proving caller-supplied plugin routes are visible without fallback.
Update sdk-app ownership, server diagnostics, TypeScript SDK examples, nested-loader coverage, and session-upload replay layering together. The following commit contains only the regenerated SDK transcripts, keeping this API and lifecycle change directly reviewable.
2026-08-23 01:47:42 +08:00
|
|
|
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
2026-08-13 00:36:22 +08:00
|
|
|
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
2026-08-24 13:10:12 +08:00
|
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
2026-07-11 14:08:12 +08:00
|
|
|
import * as jsonrpc from '../src/index.ts'
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-09 15:34:32 +08:00
|
|
|
* Mount the real namespace plugin with in-memory stdio and exit hooks. Covers
|
2026-07-14 00:40:36 +08:00
|
|
|
* the full transport/server path, response-before-exit shutdown exactly once,
|
|
|
|
|
* and bare-fiber disposal without process exit.
|
2026-07-11 14:08:12 +08:00
|
|
|
*/
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
/** One ordered frame, write completion, or exit observation. */
|
2026-07-11 14:08:12 +08:00
|
|
|
type WireEvent =
|
|
|
|
|
| { kind: 'frame'; frame: Record<string, unknown> }
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
| { kind: 'write-complete'; ids: (string | number)[] }
|
2026-07-30 18:27:36 +08:00
|
|
|
| { kind: 'root-disposed' }
|
2026-07-11 14:08:12 +08:00
|
|
|
| { kind: 'exit'; code: number }
|
|
|
|
|
|
|
|
|
|
interface ApplyHarness {
|
|
|
|
|
ctx: Context
|
2026-07-14 00:40:36 +08:00
|
|
|
/** The plugin fiber used by the bare-dispose case. */
|
2026-07-11 14:08:12 +08:00
|
|
|
fiber: Awaited<ReturnType<Context['plugin']>>
|
2026-07-14 00:40:36 +08:00
|
|
|
/** Frames, write completions, and exits in observation order. */
|
2026-07-11 14:08:12 +08:00
|
|
|
events: WireEvent[]
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
outputErrors: Error[]
|
2026-07-11 14:08:12 +08:00
|
|
|
send(frame: Record<string, unknown>): void
|
|
|
|
|
sendRaw(text: string): void
|
|
|
|
|
frames(): Record<string, unknown>[]
|
|
|
|
|
exits(): number[]
|
|
|
|
|
waitForFrame(predicate: (frame: Record<string, unknown>) => boolean, description: string): Promise<Record<string, unknown>>
|
|
|
|
|
dispose(): Promise<void>
|
|
|
|
|
}
|
|
|
|
|
|
feat(sdk): launch TypeScript clients through dsh profiles
Replace the TypeScript SDK's public arbitrary command/argv launch surface with the same-version dsh CLI, a named profile, ordered per-launch patches, optional process cwd, and explicit Harness home selection. Installed consumers use the built CLI; clean source checkouts use the package's src/bin.ts through an absolute tsx/esm loader and a source-only patch that omits build-generated Typert loading.
Materialize explicit or inherited environments at spawn time, keep profile-internal patches below caller patches, and resolve every caller-relative path before the child starts. The SDK subagent provider validates its optional CLI and patch files at plugin load and requires an isolated absolute child home.
Treat JSON-RPC initialize as the Loader-owned readiness point: Loader settlement joins entry imports, fiber lifecycle work, and synchronous effect registration, so the server needs no scheduler tick. A delayed profile entry registers a private adapter before initialize resolves, proving caller-supplied plugin routes are visible without fallback.
Update sdk-app ownership, server diagnostics, TypeScript SDK examples, nested-loader coverage, and session-upload replay layering together. The following commit contains only the regenerated SDK transcripts, keeping this API and lifecycle change directly reviewable.
2026-08-23 01:47:42 +08:00
|
|
|
/** Adapter whose route registration is the delayed Loader entry's readiness fact. */
|
|
|
|
|
class DelayedAdapter extends LlmAdapter {
|
|
|
|
|
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
|
|
|
throw new Error('not exercised')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
/** Poll asynchronous output for up to five seconds. */
|
2026-07-11 14:08:12 +08:00
|
|
|
async function waitFor<T>(get: () => T | undefined, description: string): Promise<T> {
|
|
|
|
|
const deadline = Date.now() + 5000
|
|
|
|
|
for (;;) {
|
|
|
|
|
const value = get()
|
|
|
|
|
if (value !== undefined) return value
|
|
|
|
|
if (Date.now() > deadline) throw new Error(`timed out waiting for ${description}`)
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 5))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
/** Drain asynchronous work before a negative assertion. */
|
2026-07-11 14:08:12 +08:00
|
|
|
async function settle(): Promise<void> {
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 25))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
/** Mount the real plugin on a minimal harness with in-memory stdio and exit. */
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
async function mountPlugin(
|
|
|
|
|
storageDir: string,
|
2026-08-18 17:36:59 +08:00
|
|
|
options: {
|
|
|
|
|
writeDelayMs?: number
|
|
|
|
|
failFlush?: boolean
|
|
|
|
|
beforeServer?: (ctx: Context) => Promise<void> | void
|
2026-08-24 13:10:12 +08:00
|
|
|
toolFilter?: jsonrpc.JsonRpcConfig['toolFilter']
|
2026-08-18 17:36:59 +08:00
|
|
|
} = {},
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
): Promise<ApplyHarness> {
|
2026-07-11 14:08:12 +08:00
|
|
|
const ctx = new Context()
|
2026-07-14 19:50:25 +08:00
|
|
|
await ctx.plugin(agentCore, { workspaceContext: false })
|
2026-08-13 00:36:22 +08:00
|
|
|
await ctx.plugin(JsonlSessionPersistence, { root: storageDir })
|
2026-07-11 14:08:12 +08:00
|
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
2026-08-18 17:36:59 +08:00
|
|
|
await options.beforeServer?.(ctx)
|
2026-07-11 14:08:12 +08:00
|
|
|
|
|
|
|
|
const input = new PassThrough()
|
|
|
|
|
const events: WireEvent[] = []
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
const outputErrors: Error[] = []
|
2026-07-11 14:08:12 +08:00
|
|
|
let pendingOutput = ''
|
2026-07-14 00:40:36 +08:00
|
|
|
// Record frame admission separately from write completion so delayed output
|
|
|
|
|
// tests the flush barrier.
|
2026-07-11 14:08:12 +08:00
|
|
|
const output = new Writable({
|
|
|
|
|
write(chunk: Buffer, _encoding, callback) {
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
const ids: (string | number)[] = []
|
2026-07-11 14:08:12 +08:00
|
|
|
pendingOutput += chunk.toString('utf8')
|
|
|
|
|
for (;;) {
|
|
|
|
|
const newline = pendingOutput.indexOf('\n')
|
|
|
|
|
if (newline < 0) break
|
|
|
|
|
const line = pendingOutput.slice(0, newline).trim()
|
|
|
|
|
pendingOutput = pendingOutput.slice(newline + 1)
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
if (line) {
|
|
|
|
|
const frame = JSON.parse(line) as Record<string, unknown>
|
|
|
|
|
events.push({ kind: 'frame', frame })
|
|
|
|
|
if (typeof frame.id === 'string' || typeof frame.id === 'number') ids.push(frame.id)
|
|
|
|
|
}
|
2026-07-11 14:08:12 +08:00
|
|
|
}
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
const complete = (): void => {
|
|
|
|
|
if (options.failFlush === true && chunk.length === 0) {
|
|
|
|
|
callback(new Error('flush callback failed'))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
events.push({ kind: 'write-complete', ids })
|
|
|
|
|
callback()
|
|
|
|
|
}
|
|
|
|
|
if ((options.writeDelayMs ?? 0) > 0) setTimeout(complete, options.writeDelayMs)
|
|
|
|
|
else complete()
|
2026-07-11 14:08:12 +08:00
|
|
|
},
|
|
|
|
|
})
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
output.on('error', (error: Error) => { outputErrors.push(error) })
|
2026-07-11 14:08:12 +08:00
|
|
|
const exit = (code: number): void => { events.push({ kind: 'exit', code }) }
|
|
|
|
|
|
2026-07-30 18:27:36 +08:00
|
|
|
ctx.effect(() => () => { events.push({ kind: 'root-disposed' }) }, 'jsonrpc test root-disposal witness')
|
2026-08-24 13:10:12 +08:00
|
|
|
const fiber = await ctx.plugin(jsonrpc, {
|
|
|
|
|
input,
|
|
|
|
|
output,
|
|
|
|
|
exit,
|
|
|
|
|
...options.toolFilter === undefined ? {} : { toolFilter: options.toolFilter },
|
|
|
|
|
})
|
2026-07-11 14:08:12 +08:00
|
|
|
|
|
|
|
|
const frames = (): Record<string, unknown>[] =>
|
|
|
|
|
events.flatMap(event => event.kind === 'frame' ? [event.frame] : [])
|
|
|
|
|
return {
|
|
|
|
|
ctx,
|
|
|
|
|
fiber,
|
|
|
|
|
events,
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
outputErrors,
|
2026-07-11 14:08:12 +08:00
|
|
|
send: (frame) => { input.write(`${JSON.stringify(frame)}\n`) },
|
|
|
|
|
sendRaw: (text) => { input.write(text) },
|
|
|
|
|
frames,
|
|
|
|
|
exits: () => events.flatMap(event => event.kind === 'exit' ? [event.code] : []),
|
|
|
|
|
waitForFrame: (predicate, description) => waitFor(() => frames().find(predicate), description),
|
|
|
|
|
dispose: async () => { await ctx.fiber.dispose() },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const servers: Server[] = []
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
|
|
|
|
vi.unstubAllEnvs()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
/** Keyless SSE endpoint for completing a prompt turn. */
|
2026-07-11 14:08:12 +08:00
|
|
|
async function mockCompletionServer(): Promise<{ url: string; requests: unknown[] }> {
|
|
|
|
|
const requests: unknown[] = []
|
|
|
|
|
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
|
|
|
|
let body = ''
|
|
|
|
|
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
|
|
|
|
|
request.on('end', () => {
|
|
|
|
|
requests.push(JSON.parse(body))
|
|
|
|
|
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
|
|
|
response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n')
|
|
|
|
|
response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
|
|
|
|
|
response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
|
|
|
|
|
response.write('data: [DONE]\n\n')
|
|
|
|
|
response.end()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
servers.push(server)
|
|
|
|
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
|
|
|
const address = server.address()
|
|
|
|
|
if (address === null || typeof address === 'string') throw new Error('no port')
|
|
|
|
|
return { url: `http://127.0.0.1:${address.port}`, requests }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 00:36:22 +08:00
|
|
|
describe('dsh-sdk-jsonrpc-server plugin apply', () => {
|
2026-07-11 14:08:12 +08:00
|
|
|
it('serves initialize over the injected stdio pair', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-init-'))
|
|
|
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
|
|
|
const harness = await mountPlugin(storageDir)
|
|
|
|
|
try {
|
2026-07-29 16:36:07 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'apply-model' } })
|
2026-07-11 14:08:12 +08:00
|
|
|
|
|
|
|
|
const response = await harness.waitForFrame(frame => frame.id === 'init-1', 'initialize response')
|
|
|
|
|
expect(response).toEqual({
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
id: 'init-1',
|
|
|
|
|
result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } },
|
|
|
|
|
})
|
|
|
|
|
expect(harness.exits()).toEqual([])
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
feat(sdk): launch TypeScript clients through dsh profiles
Replace the TypeScript SDK's public arbitrary command/argv launch surface with the same-version dsh CLI, a named profile, ordered per-launch patches, optional process cwd, and explicit Harness home selection. Installed consumers use the built CLI; clean source checkouts use the package's src/bin.ts through an absolute tsx/esm loader and a source-only patch that omits build-generated Typert loading.
Materialize explicit or inherited environments at spawn time, keep profile-internal patches below caller patches, and resolve every caller-relative path before the child starts. The SDK subagent provider validates its optional CLI and patch files at plugin load and requires an isolated absolute child home.
Treat JSON-RPC initialize as the Loader-owned readiness point: Loader settlement joins entry imports, fiber lifecycle work, and synchronous effect registration, so the server needs no scheduler tick. A delayed profile entry registers a private adapter before initialize resolves, proving caller-supplied plugin routes are visible without fallback.
Update sdk-app ownership, server diagnostics, TypeScript SDK examples, nested-loader coverage, and session-upload replay layering together. The following commit contains only the regenerated SDK transcripts, keeping this API and lifecycle change directly reviewable.
2026-08-23 01:47:42 +08:00
|
|
|
it('waits for Loader-owned adapter registration before initialize', async () => {
|
2026-08-18 17:36:59 +08:00
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-readiness-'))
|
|
|
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
|
|
|
let markStarted!: () => void
|
|
|
|
|
let release!: () => void
|
|
|
|
|
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
|
|
|
|
const ready = new Promise<void>((resolve) => { release = resolve })
|
|
|
|
|
let delayedEntry: Promise<string> | undefined
|
|
|
|
|
const harness = await mountPlugin(storageDir, {
|
|
|
|
|
beforeServer: async (ctx) => {
|
|
|
|
|
await ctx.plugin(Loader)
|
|
|
|
|
ctx.loader.builtins['delayed-readiness'] = {
|
feat(sdk): launch TypeScript clients through dsh profiles
Replace the TypeScript SDK's public arbitrary command/argv launch surface with the same-version dsh CLI, a named profile, ordered per-launch patches, optional process cwd, and explicit Harness home selection. Installed consumers use the built CLI; clean source checkouts use the package's src/bin.ts through an absolute tsx/esm loader and a source-only patch that omits build-generated Typert loading.
Materialize explicit or inherited environments at spawn time, keep profile-internal patches below caller patches, and resolve every caller-relative path before the child starts. The SDK subagent provider validates its optional CLI and patch files at plugin load and requires an isolated absolute child home.
Treat JSON-RPC initialize as the Loader-owned readiness point: Loader settlement joins entry imports, fiber lifecycle work, and synchronous effect registration, so the server needs no scheduler tick. A delayed profile entry registers a private adapter before initialize resolves, proving caller-supplied plugin routes are visible without fallback.
Update sdk-app ownership, server diagnostics, TypeScript SDK examples, nested-loader coverage, and session-upload replay layering together. The following commit contains only the regenerated SDK transcripts, keeping this API and lifecycle change directly reviewable.
2026-08-23 01:47:42 +08:00
|
|
|
inject: ['llm'],
|
|
|
|
|
async apply(entryCtx: Context) {
|
2026-08-18 17:36:59 +08:00
|
|
|
markStarted()
|
|
|
|
|
await ready
|
feat(sdk): launch TypeScript clients through dsh profiles
Replace the TypeScript SDK's public arbitrary command/argv launch surface with the same-version dsh CLI, a named profile, ordered per-launch patches, optional process cwd, and explicit Harness home selection. Installed consumers use the built CLI; clean source checkouts use the package's src/bin.ts through an absolute tsx/esm loader and a source-only patch that omits build-generated Typert loading.
Materialize explicit or inherited environments at spawn time, keep profile-internal patches below caller patches, and resolve every caller-relative path before the child starts. The SDK subagent provider validates its optional CLI and patch files at plugin load and requires an isolated absolute child home.
Treat JSON-RPC initialize as the Loader-owned readiness point: Loader settlement joins entry imports, fiber lifecycle work, and synchronous effect registration, so the server needs no scheduler tick. A delayed profile entry registers a private adapter before initialize resolves, proving caller-supplied plugin routes are visible without fallback.
Update sdk-app ownership, server diagnostics, TypeScript SDK examples, nested-loader coverage, and session-upload replay layering together. The following commit contains only the regenerated SDK transcripts, keeping this API and lifecycle change directly reviewable.
2026-08-23 01:47:42 +08:00
|
|
|
entryCtx.llm.registerAdapter(['delayed-private'], new DelayedAdapter())
|
2026-08-18 17:36:59 +08:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
delayedEntry = ctx.loader.create({ name: 'cordis:delayed-readiness' })
|
|
|
|
|
await started
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
const initialize = {
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
id: 'init-delayed',
|
|
|
|
|
method: 'initialize',
|
feat(sdk): launch TypeScript clients through dsh profiles
Replace the TypeScript SDK's public arbitrary command/argv launch surface with the same-version dsh CLI, a named profile, ordered per-launch patches, optional process cwd, and explicit Harness home selection. Installed consumers use the built CLI; clean source checkouts use the package's src/bin.ts through an absolute tsx/esm loader and a source-only patch that omits build-generated Typert loading.
Materialize explicit or inherited environments at spawn time, keep profile-internal patches below caller patches, and resolve every caller-relative path before the child starts. The SDK subagent provider validates its optional CLI and patch files at plugin load and requires an isolated absolute child home.
Treat JSON-RPC initialize as the Loader-owned readiness point: Loader settlement joins entry imports, fiber lifecycle work, and synchronous effect registration, so the server needs no scheduler tick. A delayed profile entry registers a private adapter before initialize resolves, proving caller-supplied plugin routes are visible without fallback.
Update sdk-app ownership, server diagnostics, TypeScript SDK examples, nested-loader coverage, and session-upload replay layering together. The following commit contains only the regenerated SDK transcripts, keeping this API and lifecycle change directly reviewable.
2026-08-23 01:47:42 +08:00
|
|
|
params: { cwd: storageDir, provider: 'delayed-private', model: 'apply-model' },
|
2026-08-18 17:36:59 +08:00
|
|
|
}
|
|
|
|
|
const probe = { jsonrpc: '2.0', id: 'probe-during-delay', method: 'nope/unknown' }
|
|
|
|
|
harness.sendRaw(`${JSON.stringify(initialize)}\n${JSON.stringify(probe)}\n`)
|
|
|
|
|
|
|
|
|
|
// The transport processes independent requests concurrently. Receiving
|
|
|
|
|
// this later probe proves the preceding initialize handler has reached
|
|
|
|
|
// its Loader wait, without relying on a scheduler delay.
|
|
|
|
|
await harness.waitForFrame(frame => frame.id === 'probe-during-delay', 'probe while initialize waits')
|
|
|
|
|
expect(harness.frames().some(frame => frame.id === 'init-delayed')).toBe(false)
|
|
|
|
|
|
|
|
|
|
release()
|
|
|
|
|
await delayedEntry
|
|
|
|
|
const response = await harness.waitForFrame(frame => frame.id === 'init-delayed', 'initialize response after Loader settlement')
|
|
|
|
|
expect(response).toMatchObject({
|
|
|
|
|
id: 'init-delayed',
|
|
|
|
|
result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
|
|
|
|
|
})
|
feat(sdk): launch TypeScript clients through dsh profiles
Replace the TypeScript SDK's public arbitrary command/argv launch surface with the same-version dsh CLI, a named profile, ordered per-launch patches, optional process cwd, and explicit Harness home selection. Installed consumers use the built CLI; clean source checkouts use the package's src/bin.ts through an absolute tsx/esm loader and a source-only patch that omits build-generated Typert loading.
Materialize explicit or inherited environments at spawn time, keep profile-internal patches below caller patches, and resolve every caller-relative path before the child starts. The SDK subagent provider validates its optional CLI and patch files at plugin load and requires an isolated absolute child home.
Treat JSON-RPC initialize as the Loader-owned readiness point: Loader settlement joins entry imports, fiber lifecycle work, and synchronous effect registration, so the server needs no scheduler tick. A delayed profile entry registers a private adapter before initialize resolves, proving caller-supplied plugin routes are visible without fallback.
Update sdk-app ownership, server diagnostics, TypeScript SDK examples, nested-loader coverage, and session-upload replay layering together. The following commit contains only the regenerated SDK transcripts, keeping this API and lifecycle change directly reviewable.
2026-08-23 01:47:42 +08:00
|
|
|
expect(harness.ctx.llm.listProviders()).toContainEqual({ id: 'delayed-private', name: 'delayed-private' })
|
2026-08-18 17:36:59 +08:00
|
|
|
} finally {
|
|
|
|
|
release()
|
|
|
|
|
await Promise.allSettled(delayedEntry === undefined ? [] : [delayedEntry])
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 14:08:12 +08:00
|
|
|
it('drives a session/prompt turn end-to-end and forwards session notifications as output frames', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-prompt-'))
|
|
|
|
|
const llmServer = await mockCompletionServer()
|
|
|
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
|
|
|
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
|
|
|
|
|
const harness = await mountPlugin(storageDir)
|
|
|
|
|
try {
|
2026-07-29 16:36:07 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'dsagent-model' } })
|
2026-07-11 14:08:12 +08:00
|
|
|
await harness.waitForFrame(frame => frame.id === 1, 'initialize response')
|
|
|
|
|
|
|
|
|
|
harness.send({
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
id: 2,
|
|
|
|
|
method: 'session/prompt',
|
|
|
|
|
params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'fix it' }] },
|
|
|
|
|
})
|
|
|
|
|
const response = await harness.waitForFrame(frame => frame.id === 2, 'prompt response')
|
2026-07-30 18:18:04 +08:00
|
|
|
expect((response.result as { messageId?: unknown }).messageId).toBeTypeOf('string')
|
|
|
|
|
await harness.waitForFrame(
|
|
|
|
|
frame => frame.method === 'session.status'
|
|
|
|
|
&& (frame.params as { status?: string } | undefined)?.status === 'idle',
|
|
|
|
|
'idle session status',
|
|
|
|
|
)
|
2026-07-11 14:08:12 +08:00
|
|
|
|
|
|
|
|
expect(llmServer.requests).toHaveLength(1)
|
|
|
|
|
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
|
|
|
|
|
expect(body.model).toBe('dsagent-model')
|
|
|
|
|
expect(body.messages.at(-1)?.role).toBe('user')
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
// Notifications use the same transport and arrive as id-less frames.
|
2026-07-11 14:08:12 +08:00
|
|
|
const notifications = harness.frames().filter(frame => frame.id === undefined)
|
|
|
|
|
expect(notifications.some(frame => frame.method === 'session.event')).toBe(true)
|
2026-07-30 18:18:04 +08:00
|
|
|
expect(notifications.findLast(frame => frame.method === 'session.status')).toMatchObject({
|
2026-07-11 14:08:12 +08:00
|
|
|
jsonrpc: '2.0',
|
2026-07-30 18:18:04 +08:00
|
|
|
params: { sessionId: 'main', status: 'idle' },
|
2026-07-11 14:08:12 +08:00
|
|
|
})
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-24 13:10:12 +08:00
|
|
|
it('applies the configured root-agent tool filter through the Loader plugin', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-tool-filter-'))
|
|
|
|
|
const llmServer = await mockCompletionServer()
|
|
|
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
|
|
|
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
|
|
|
|
|
const harness = await mountPlugin(storageDir, {
|
|
|
|
|
toolFilter: { allow: ['kept'] },
|
|
|
|
|
beforeServer: (ctx) => {
|
|
|
|
|
for (const name of ['kept', 'excluded']) {
|
|
|
|
|
ctx.tools.register(defineTool({
|
|
|
|
|
name,
|
|
|
|
|
description: name,
|
|
|
|
|
parameters: {},
|
|
|
|
|
output: {
|
|
|
|
|
schema: { type: 'string' },
|
|
|
|
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
|
|
|
},
|
|
|
|
|
execute: async () => name,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' } })
|
|
|
|
|
await harness.waitForFrame(frame => frame.id === 1, 'initialize response')
|
|
|
|
|
harness.send({
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
id: 2,
|
|
|
|
|
method: 'session/prompt',
|
|
|
|
|
params: { sessionId: 'filtered', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
|
|
|
|
|
})
|
|
|
|
|
await harness.waitForFrame(
|
|
|
|
|
frame => frame.method === 'session.status'
|
|
|
|
|
&& (frame.params as { status?: string } | undefined)?.status === 'idle',
|
|
|
|
|
'filtered session idle status',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const request = llmServer.requests[0] as { tools?: Array<{ function?: { name?: string } }> }
|
|
|
|
|
expect(request.tools?.map(entry => entry.function?.name)).toEqual(['kept'])
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 14:08:12 +08:00
|
|
|
it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-'))
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
const harness = await mountPlugin(storageDir, { writeDelayMs: 10 })
|
2026-07-11 14:08:12 +08:00
|
|
|
try {
|
2026-07-14 00:40:36 +08:00
|
|
|
// One chunk makes the two deferred exit callbacks race.
|
2026-07-11 14:08:12 +08:00
|
|
|
const first = { jsonrpc: '2.0', id: 'sd-1', method: 'shutdown' }
|
|
|
|
|
const second = { jsonrpc: '2.0', id: 'sd-2', method: 'shutdown' }
|
|
|
|
|
harness.sendRaw(`${JSON.stringify(first)}\n${JSON.stringify(second)}\n`)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit recorder call')
|
|
|
|
|
expect(harness.exits()).toEqual([0])
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
// Both response writes and the flush barrier complete before exit.
|
2026-07-11 14:08:12 +08:00
|
|
|
const exitIndex = harness.events.findIndex(event => event.kind === 'exit')
|
|
|
|
|
const firstResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-1')
|
|
|
|
|
const secondResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-2')
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
const firstComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-1'))
|
|
|
|
|
const secondComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-2'))
|
|
|
|
|
const flushComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.length === 0)
|
2026-07-30 18:27:36 +08:00
|
|
|
const rootDisposed = harness.events.findIndex(event => event.kind === 'root-disposed')
|
2026-07-11 14:08:12 +08:00
|
|
|
expect(firstResponse).toBeGreaterThanOrEqual(0)
|
|
|
|
|
expect(secondResponse).toBeGreaterThanOrEqual(0)
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
expect(firstComplete).toBeGreaterThan(firstResponse)
|
|
|
|
|
expect(secondComplete).toBeGreaterThan(secondResponse)
|
|
|
|
|
expect(flushComplete).toBeGreaterThan(firstComplete)
|
|
|
|
|
expect(flushComplete).toBeGreaterThan(secondComplete)
|
2026-07-30 18:27:36 +08:00
|
|
|
expect(rootDisposed).toBeGreaterThan(flushComplete)
|
|
|
|
|
expect(exitIndex).toBeGreaterThan(rootDisposed)
|
2026-07-11 14:08:12 +08:00
|
|
|
|
|
|
|
|
await settle()
|
|
|
|
|
expect(harness.exits()).toEqual([0])
|
2026-07-30 18:27:36 +08:00
|
|
|
expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1)
|
2026-07-11 14:08:12 +08:00
|
|
|
|
|
|
|
|
const before = harness.frames().length
|
2026-07-29 16:36:07 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
|
2026-07-11 14:08:12 +08:00
|
|
|
await settle()
|
|
|
|
|
expect(harness.frames().length).toBe(before)
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
it('still disposes and exits once when the flush callback fails', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-flush-failure-'))
|
|
|
|
|
const harness = await mountPlugin(storageDir, { failFlush: true })
|
|
|
|
|
try {
|
|
|
|
|
harness.send({ jsonrpc: '2.0', id: 'sd-fail', method: 'shutdown' })
|
|
|
|
|
|
|
|
|
|
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit after flush failure')
|
|
|
|
|
await settle()
|
|
|
|
|
expect(harness.exits()).toEqual([0])
|
2026-07-30 18:27:36 +08:00
|
|
|
expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1)
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
|
|
|
|
|
|
|
|
|
|
const before = harness.frames().length
|
2026-07-29 16:36:07 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
await settle()
|
|
|
|
|
expect(harness.frames().length).toBe(before)
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 14:08:12 +08:00
|
|
|
it('stops serving on a bare fiber dispose (HMR-style unload) without calling exit', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-dispose-'))
|
|
|
|
|
const harness = await mountPlugin(storageDir)
|
|
|
|
|
try {
|
2026-07-14 00:40:36 +08:00
|
|
|
// Prove the handler-rejection path is live before disposal.
|
2026-07-11 14:08:12 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 'probe-1', method: 'nope/unknown' })
|
|
|
|
|
const error = await harness.waitForFrame(frame => frame.id === 'probe-1', 'error response for unknown method')
|
|
|
|
|
expect(error.error).toMatchObject({
|
|
|
|
|
code: -32603,
|
|
|
|
|
message: 'unknown DeepSeek Harness SDK runtime method: nope/unknown',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await harness.fiber.dispose()
|
2026-07-30 18:27:36 +08:00
|
|
|
expect(harness.events.some(event => event.kind === 'root-disposed')).toBe(false)
|
2026-07-11 14:08:12 +08:00
|
|
|
|
|
|
|
|
const before = harness.frames().length
|
2026-07-29 16:36:07 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
|
2026-07-11 14:08:12 +08:00
|
|
|
await settle()
|
|
|
|
|
expect(harness.frames().length).toBe(before)
|
|
|
|
|
expect(harness.exits()).toEqual([])
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|