Merge remote-tracking branch 'origin/worktree/ci-native-windows-20260808' into worktree/ci-native-windows-coverage-20260808

This commit is contained in:
Tianyi Cui
2026-08-09 02:15:46 +08:00
1062 changed files with 17432 additions and 9494 deletions

View File

@@ -0,0 +1,231 @@
/**
* Deterministic ladder coverage against a scriptable fake child: each
* escalation tier's timing is driven exactly (the client suite exercises the
* same ladder against real subprocesses end to end).
*/
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { describe, expect, it, vi } from 'vitest'
import { disposeRuntimeProcess } from '../src/dispose.ts'
/** What fells a scripted {@link FakeChild}. */
type LethalTrigger = 'eof' | NodeJS.Signals
/** Per-scenario script for a {@link FakeChild}. */
interface FakeChildScript {
/**
* The one trigger that makes the child exit (SIGKILL always does,
* uncatchable, like a real process). Omitted: only SIGKILL fells it.
*/
diesOn?: LethalTrigger
/** Delay (ms) between the lethal trigger and the exit event. */
delayMs?: number
/** Complete the scripted exit inside the triggering call. */
synchronousExit?: boolean
/** `false` models a child spawned without a stdin pipe. */
stdin?: boolean
}
/**
* A scriptable stand-in for a ChildProcess carrying exactly the surface the
* ladder reads: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
* `exit` event.
*/
class FakeChild extends EventEmitter {
exitCode: number | null = null
signalCode: NodeJS.Signals | null = null
readonly kills: NodeJS.Signals[] = []
stdinEnded = false
readonly stdin: { end: () => void } | null
constructor(private readonly script: FakeChildScript = {}) {
super()
this.stdin = script.stdin === false
? null
: { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
}
kill(signal: NodeJS.Signals): boolean {
this.kills.push(signal)
this.maybeDie(signal)
return true
}
private maybeDie(trigger: LethalTrigger): void {
// SIGKILL is uncatchable — it always fells the child; any other trigger
// only when the scenario scripts it as the lethal one.
if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
const exit = (): void => {
if (trigger === 'eof') this.exitCode = 0
else this.signalCode = trigger
this.emit('exit', this.exitCode, this.signalCode)
}
if (this.script.synchronousExit === true) exit()
else setTimeout(exit, this.script.delayMs ?? 0)
}
}
/** The ladder takes a real ChildProcess; the fake carries the read surface. */
function asChild(fake: FakeChild): ChildProcess {
return fake as unknown as ChildProcess
}
describe('disposeRuntimeProcess', () => {
it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
const fake = new FakeChild()
fake.exitCode = 0
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(false)
expect(fake.kills).toEqual([])
})
it('returns immediately for a child already dead by signal', async () => {
const fake = new FakeChild()
fake.signalCode = 'SIGKILL'
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(false)
expect(fake.kills).toEqual([])
})
it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.stdinEnded).toBe(true)
expect(fake.kills).toEqual([])
expect(fake.exitCode).toBe(0)
})
it('recognizes a child that exits synchronously on stdin EOF', async () => {
const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
expect(fake.exitCode).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.stdinEnded).toBe(true)
expect(fake.kills).toEqual(['SIGTERM'])
expect(fake.signalCode).toBe('SIGTERM')
expect(fake.listenerCount('exit')).toBe(0)
})
it('recognizes a child that exits synchronously on SIGTERM', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
expect(fake.signalCode).toBe('SIGTERM')
expect(fake.listenerCount('exit')).toBe(0)
})
it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
// Quiescence, not a request: at resolution the child has ACTUALLY exited
// (the exit event landed, despite the scripted post-SIGKILL delay).
expect(fake.signalCode).toBe('SIGKILL')
})
it('recognizes a child already gone when the final exit wait begins', async () => {
const fake = new FakeChild({ synchronousExit: true })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
expect(fake.signalCode).toBe('SIGKILL')
})
it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
queueMicrotask(() => {
if (marker === 'exitCode') fake.exitCode = 0
else fake.signalCode = 'SIGTERM'
})
return true
})
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
})
it('walks the ladder for a child spawned without a stdin pipe', async () => {
const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
expect(fake.kills).toEqual(['SIGTERM'])
})
it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
expect(fake.kills).toEqual(['SIGKILL'])
expect(fake.signalCode).toBe('SIGKILL')
})
it('propagates a forced-termination error without waiting for the grace', async () => {
const fake = new FakeChild()
const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
fake.emit('error', failure)
return false
})
await expect(disposeRuntimeProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
'win32',
)).rejects.toBe(failure)
expect(fake.kills).toEqual(['SIGKILL'])
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
const fake = new FakeChild()
const failure = new Error('invalid signal state')
vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
await expect(disposeRuntimeProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
'win32',
)).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('bounds a refused forced termination that produces no error or exit', async () => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
return false
})
await expect(disposeRuntimeProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
'win32',
)).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was refused')
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
it('bounds an accepted forced termination that never reports exit', async () => {
const fake = new FakeChild()
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
fake.kills.push(signal)
return true
})
await expect(disposeRuntimeProcess(
asChild(fake),
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
'win32',
)).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was accepted')
expect(fake.listenerCount('error')).toBe(0)
expect(fake.listenerCount('exit')).toBe(0)
})
})

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env node
/**
* Scripted stand-in for the DeepSeek Harness SDK runtime, driven entirely by
* env vars — no model, no network, no harness imports. Speaks the runtime's
* newline-delimited JSON-RPC protocol on stdio: answers `initialize`,
* `session/prompt` (streaming scripted `session.event` notifications, then
* `session.finished`, then the response), and `shutdown`.
*
* Script vocabulary (all optional):
* - `FAKE_TEXT`: assistant text for each turn (default `hello from fake runtime`).
* - `FAKE_STATUS`: the `session.finished` status (default `ok`).
* - `FAKE_REASON_KIND`: the `session.finished` reason kind (default `completed`; `none` omits the reason).
* - `FAKE_SUBAGENT`: also emit a child session (subagent.started + child event + subagent.finished).
* - `FAKE_ECHO_CWD`: prefix the assistant text with the process cwd.
* - `FAKE_ECHO_ENV`: comma-separated env names to echo as `name=value` lines in the assistant text.
* - `FAKE_MALFORMED`: `initialize` returns `{}` (no serverInfo); `prompt` returns `{}` (no accepted).
* - `FAKE_MALFORMED_PROMPT`: `initialize` is normal; only `prompt` returns `{}` (no accepted).
* - `FAKE_INIT_ERROR`: `initialize` answers a JSON-RPC error response with code 7.
* - `FAKE_INIT_ERROR_ONCE_FILE`: fail `initialize` (code 7) only when this
* marker file does NOT exist yet, creating it — so the first runtime
* process fails the handshake and a respawned one succeeds (retry probe).
* - `FAKE_ECHO_CWD_IN_INIT`: reply `serverInfo.version` = this process's cwd
* (wire-visible spawn-cwd probe).
* - `FAKE_MALFORMED_EVENT`: the turn's `session.event` carries a number as
* the event; `FAKE_MALFORMED_MESSAGE`: assistant/message content is not an
* array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
* member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
* string (wire-validation probes).
* - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
* arrives, then poll for the GO file before answering (deterministic
* cancel-during-handshake window).
* - `FAKE_HANG_PROMPT`: never answer `session/prompt` (for timeout/dispose tests).
* - `FAKE_STREAM_THEN_MALFORMED`: stream a text chunk for the prompt, then
* answer `{}` (no accepted) — same-pipe ordering makes the chunk arrive
* before the protocol failure (partial-output retention probe).
* - `FAKE_IGNORE_EOF` + `FAKE_SIGTERM_FILE`: keep running after stdin EOF; touch the file on SIGTERM (ladder probe).
* - `FAKE_TRAP_SIGTERM`: with `FAKE_IGNORE_EOF`, survive SIGTERM too (SIGKILL-rung probe).
* - `FAKE_EXIT_BEFORE_INIT`: exit 3 immediately (spawn-then-die probe).
* - `FAKE_STDERR`: write this line to stderr at boot (diagnostics-tail probe).
* - `FAKE_STDERR_NO_NEWLINE`: write this to stderr WITHOUT a newline (buffer-flush probe).
* - `FAKE_RECORD_INIT`: append each `initialize` params JSON to this file (handshake probe).
*/
import { appendFileSync, existsSync, writeFileSync } from 'node:fs'
import process from 'node:process'
import { createInterface } from 'node:readline'
const env = process.env
if (env.FAKE_STDERR !== undefined) process.stderr.write(`${env.FAKE_STDERR}\n`)
if (env.FAKE_STDERR_NO_NEWLINE !== undefined) process.stderr.write(env.FAKE_STDERR_NO_NEWLINE)
if (env.FAKE_EXIT_BEFORE_INIT !== undefined) process.exit(3)
if (env.FAKE_IGNORE_EOF !== undefined) {
// Simulate a runtime that never quiesces from EOF so the dispose ladder
// must escalate; record which rung fired.
process.stdin.resume()
process.stdin.on('end', () => { setInterval(() => {}, 1_000) })
process.on('SIGTERM', () => {
if (env.FAKE_SIGTERM_FILE !== undefined) writeFileSync(env.FAKE_SIGTERM_FILE, 'sigterm\n')
if (env.FAKE_TRAP_SIGTERM === undefined) process.exit(0)
})
}
function write(message: object): void {
process.stdout.write(`${JSON.stringify(message)}\n`)
}
function notify(method: string, params: object): void {
write({ jsonrpc: '2.0', method, params })
}
let seq = 0
function event(sessionId: string, type: string, data: object): void {
notify('session.event', { sessionId, event: { type, seq: seq++, time: 0, data } })
}
function assistantText(): string {
const parts: string[] = []
if (env.FAKE_ECHO_CWD !== undefined) parts.push(`cwd=${process.cwd()}`)
for (const name of (env.FAKE_ECHO_ENV ?? '').split(',').filter(entry => entry.length > 0)) {
parts.push(`${name}=${env[name] ?? ''}`)
}
parts.push(env.FAKE_TEXT ?? 'hello from fake runtime')
return parts.join('\n')
}
function runTurn(sessionId: string): void {
const text = assistantText()
if (env.FAKE_MALFORMED_EVENT !== undefined) {
notify('session.event', { sessionId, event: 42 })
return
}
event(sessionId, 'turn/start', { turn: 0 })
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text } })
if (env.FAKE_MALFORMED_MESSAGE !== undefined) {
event(sessionId, 'assistant/message', {
turn: 0,
step: 0,
message: {
id: 'fake-malformed-message',
role: 'assistant',
content: 'not-an-array',
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
})
return
}
if (env.FAKE_MESSAGE_WITHOUT_DATA !== undefined) {
notify('session.event', { sessionId, event: { type: 'assistant/message', seq: seq++, time: 0 } })
return
}
event(sessionId, 'assistant/message', {
turn: 0,
step: 0,
message: {
id: `fake-assistant-${seq}`,
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
})
const reasonKind = env.FAKE_REASON_KIND ?? 'completed'
event(sessionId, 'turn/end', { turn: 0, reason: { kind: reasonKind } })
if (env.FAKE_SUBAGENT !== undefined) {
const childId = `${sessionId}-child`
notify('subagent.started', { parentSessionId: sessionId, childSessionId: childId })
event(childId, 'assistant/message', {
turn: 0,
step: 0,
content: [{ type: 'text', text: 'child says hi' }],
provenance: { provider: 'fake', model: 'fake' },
})
notify('subagent.finished', {
provider: 'spawn',
agentId: childId,
parentSessionId: sessionId,
childSessionId: childId,
status: 'ok',
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'child says hi' }],
})
}
}
function sessionIdOf(params: Record<string, unknown> | undefined): string {
const value = params?.sessionId
return typeof value === 'string' ? value : ''
}
const reader = createInterface({ input: process.stdin })
reader.on('line', (line) => {
if (line.trim().length === 0) return
const frame = JSON.parse(line) as { id?: string | number; method?: string; params?: Record<string, unknown> }
if (frame.method === undefined || frame.id === undefined) return
const respond = (result: object): void => { write({ jsonrpc: '2.0', id: frame.id, result }) }
switch (frame.method) {
case 'initialize':
if (env.FAKE_RECORD_INIT !== undefined) appendFileSync(env.FAKE_RECORD_INIT, `${JSON.stringify(frame.params)}\n`)
if (env.FAKE_HANG_INIT !== undefined) return
if (env.FAKE_INIT_READY !== undefined && env.FAKE_INIT_GO !== undefined) {
writeFileSync(env.FAKE_INIT_READY, 'ready\n')
const go = env.FAKE_INIT_GO
const id = frame.id
const poll = setInterval(() => {
if (!existsSync(go)) return
clearInterval(poll)
write({ jsonrpc: '2.0', id, result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } })
}, 5)
return
}
if (env.FAKE_INIT_ERROR !== undefined) {
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted init failure', data: { hint: 'fake' } } })
return
}
if (env.FAKE_INIT_ERROR_ONCE_FILE !== undefined && !existsSync(env.FAKE_INIT_ERROR_ONCE_FILE)) {
writeFileSync(env.FAKE_INIT_ERROR_ONCE_FILE, 'failed-once\n')
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted first-boot failure' } })
return
}
if (env.FAKE_MALFORMED !== undefined) {
respond({})
return
}
if (env.FAKE_ECHO_CWD_IN_INIT !== undefined) {
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: process.cwd() } })
return
}
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
return
case 'session/prompt': {
const sessionId = sessionIdOf(frame.params)
const messageId = `fake-user-${seq}`
event(sessionId, 'agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [{
id: messageId,
role: 'user',
content: [],
source: { kind: 'user' },
}],
})
notify('session.status', { sessionId, status: 'running' })
if (env.FAKE_STREAM_THEN_MALFORMED !== undefined) {
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } })
respond({})
return
}
if (env.FAKE_HANG_PROMPT !== undefined) return
if (env.FAKE_MALFORMED !== undefined || env.FAKE_MALFORMED_PROMPT !== undefined) {
respond({})
return
}
runTurn(sessionId)
notify('session.status', { sessionId, status: 'idle' })
respond({ messageId })
return
}
case 'shutdown':
respond({})
// An EOF-ignoring fake also refuses the protocol exit, so the client's
// dispose ladder (not this cooperative path) must reap it.
if (env.FAKE_IGNORE_EOF === undefined) setImmediate(() => process.exit(0))
return
default:
write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: `unknown method: ${frame.method}` } })
}
})

View File

@@ -0,0 +1,527 @@
/**
* SDK client against a real scripted runtime subprocess
* (`tests/fake-runtime.ts`, protocol-only — the only faked boundary is the
* model-owning runtime itself). Covers the turn loop, notification routing
* and session-tree scoping, error surfaces, timeouts, and the dispose ladder.
*/
import { mkdir, mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import {
DeepSeekHarness,
HarnessClient,
HarnessSession,
JsonRpcResponseError,
RequestTimeoutError,
SdkProtocolError,
TransportClosedError,
type HarnessNotification,
} from '../src/index.ts'
import { finalResponse, normalizeInput } from '../src/api.ts'
const fakeRuntime = fileURLToPath(new URL('./fake-runtime.ts', import.meta.url))
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) await cleanup()
})
type LaunchOverrides = Partial<ConstructorParameters<typeof HarnessClient>[0]>
/** Launch options running the fake runtime on the current node (type stripping). */
function fakeLaunch(env: Record<string, string> = {}, extra: LaunchOverrides = {}) {
return {
command: process.execPath,
args: [fakeRuntime],
env: { ...process.env as Record<string, string>, ...env },
...extra,
}
}
function harnessWith(env: Record<string, string> = {}, extra: LaunchOverrides = {}): DeepSeekHarness {
const harness = new DeepSeekHarness({ launch: fakeLaunch(env, extra) })
cleanups.push(() => harness.close())
return harness
}
async function tempDir(prefix: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), prefix))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
describe('DeepSeekHarness', () => {
it('ignores notifications that precede the submitted message receipt', async () => {
const notifications = [
{ method: 'session.status', params: { sessionId: 'owned', status: 'running' } },
{
method: 'session.event',
params: { sessionId: 'owned', event: { type: 'turn/start', data: { turn: 1 } } },
},
{
method: 'session.event',
params: {
sessionId: 'owned',
event: { type: 'agent/inbox/spliced', data: { inserted: null } },
},
},
{
method: 'session.event',
params: {
sessionId: 'owned',
event: {
type: 'agent/inbox/spliced',
seq: 0,
time: 0,
data: {
target: 'next-turn',
start: 0,
inserted: [{ id: 'accepted-message', role: 'user', content: [], source: { kind: 'user' } }],
},
},
},
},
{ method: 'session.status', params: { sessionId: 'owned', status: 'idle' } },
] as HarnessNotification[]
let closed = false
const harness = {
start: () => Promise.resolve(),
client: {
prompt: () => Promise.resolve('accepted-message'),
subscribeSessionTree: () => ({
next: async () => {
const notification = notifications.shift()
if (notification === undefined) throw new Error('scripted notification queue exhausted')
return notification
},
tryNext: () => notifications.shift(),
close: () => { closed = true },
async * [Symbol.asyncIterator]() {},
}),
},
} as unknown as DeepSeekHarness
const result = await new HarnessSession(harness, 'owned').run('go')
expect(result.notifications.map(notification => notification.method))
.toEqual(['session.event', 'session.status'])
expect(result.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
expect(closed).toBe(true)
})
it('runs a turn end to end and reuses the runtime across sessions', async () => {
const harness = harnessWith({ FAKE_TEXT: 'turn answer' })
const first = await harness.run('say hi')
expect(first.finalResponse).toBe('turn answer')
expect(first.events.map(event => event.type)).toEqual([
'agent/inbox/spliced', 'turn/start', 'assistant/chunk', 'assistant/message', 'turn/end',
])
// Same subprocess, second session: ids differ, protocol state is reusable.
const second = await harness.run([{ type: 'text', text: 'again' }])
expect(second.sessionId).not.toBe(first.sessionId)
await harness.close()
})
it('keeps events root-scoped while streaming notifications for the session tree', async () => {
const harness = harnessWith({ FAKE_SUBAGENT: '1' })
const seen: HarnessNotification[] = []
const result = await harness.run('delegate', {
sessionId: 'parent-1',
onNotification: (n) => { seen.push(n) },
})
// The child session's events arrive through subagent.started lineage.
expect(seen.map(n => n.method)).toContain('subagent.started')
expect(seen.map(n => n.method)).toContain('subagent.finished')
const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child')
expect(childEvents.length).toBeGreaterThan(0)
// RunResult.events is the root session's typed stream; descendants retain
// their session ids in the raw notification stream above.
expect(result.events.every(event => event.type !== 'assistant/message'
|| event.data.message.content[0]?.type !== 'text'
|| event.data.message.content[0].text !== 'child says hi')).toBe(true)
await harness.close()
})
it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => {
const dir = await tempDir('sdk-client-init-')
const recordFile = join(dir, 'init.jsonl')
const harness = new DeepSeekHarness({
launch: fakeLaunch({ FAKE_RECORD_INIT: recordFile }),
cwd: dir,
provider: 'custom-provider',
model: 'custom-model',
maxTokens: 4096,
})
cleanups.push(() => harness.close())
await harness.run('one')
await harness.run('two')
await harness.close()
const records = (await readFile(recordFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as object)
expect(records).toEqual([{
cwd: dir,
provider: 'custom-provider',
model: 'custom-model',
maxTokens: 4096,
}])
})
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 mkdtemp(join(process.cwd(), '.dsh-sdk-client-relcwd-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
const recordFile = join(dir, 'init.jsonl')
const inner = join(dir, 'worker')
await mkdir(inner)
const relativeCwd = relative(process.cwd(), inner)
expect(isAbsolute(relativeCwd)).toBe(false)
const harness = new DeepSeekHarness({
launch: fakeLaunch({ FAKE_RECORD_INIT: recordFile, FAKE_ECHO_CWD_IN_INIT: '1' }, { cwd: relativeCwd }),
})
cleanups.push(() => harness.close())
await harness.start()
const identity = await harness.client.initialize({ cwd: inner, provider: 'p', model: 'm' })
await harness.close()
// The child spawned under the temp worker dir (its physical cwd)...
expect(identity.serverInfo.version).toBe(await realpath(inner))
// ...and the handshake wire cwd went out ABSOLUTE, so the child cannot
// re-resolve a relative string into dir/worker/worker.
const records = (await readFile(recordFile, 'utf8')).trim().split('\n')
.map(line => (JSON.parse(line) as { cwd: string }).cwd)
expect(records).toEqual([resolvePath(relativeCwd), inner])
})
it('propagates a JSON-RPC error response from initialize and closes the runtime', async () => {
const harness = harnessWith({ FAKE_INIT_ERROR: '1' })
const failure = await harness.run('boom').then(
() => { throw new Error('run unexpectedly succeeded') },
(error: unknown) => error,
)
expect(failure).toBeInstanceOf(JsonRpcResponseError)
expect(failure).toMatchObject({ code: 7, message: 'scripted init failure', data: { hint: 'fake' } })
// The failed handshake reset lets a later start retry instead of wedging.
await expect(harness.run('later')).rejects.toThrow()
})
it('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.finalResponse).toBe('second boot answer')
await harness.close()
// close() is terminal: a handshake failure after it must not respawn.
await expect(harness.run('after-close')).rejects.toThrow(TransportClosedError)
})
it('rejects a malformed initialize result as a protocol error', async () => {
const harness = harnessWith({ FAKE_MALFORMED: '1' })
await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError)
})
it('supports await using disposal', async () => {
let captured: DeepSeekHarness
{
await using harness = new DeepSeekHarness({ launch: fakeLaunch() })
captured = harness
const result = await harness.run('scoped')
expect(result.finalResponse).toBe('hello from fake runtime')
}
// After scope exit the runtime is closed: reuse fails loudly.
await expect(captured.run('after')).rejects.toThrow(TransportClosedError)
})
})
describe('HarnessClient', () => {
it('times out a hung request at the per-call bound', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }))
cleanups.push(() => client.close())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('hi') }, 200))
.rejects.toThrow(RequestTimeoutError)
await client.close()
})
it('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 () => {
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }, { requestTimeoutMs: 400 }))
cleanups.push(() => client.close())
// The bound applies from send, so it holds regardless of runtime boot time.
await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(RequestTimeoutError)
await client.close()
})
it('rejects a malformed prompt acceptance as a protocol error', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_MALFORMED: '1' }))
cleanups.push(() => client.close())
await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(SdkProtocolError)
await client.close()
})
it('fails pending requests with exit code and stderr tail when the runtime dies', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'fatal: scripted death' }))
cleanups.push(() => client.close())
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
() => { throw new Error('initialize unexpectedly succeeded') },
(error: unknown) => error,
)
expect(failure).toBeInstanceOf(TransportClosedError)
expect(String(failure)).toContain('exit code: 3')
expect(String(failure)).toContain('fatal: scripted death')
// Requests after death fail immediately with the same context.
await expect(client.request('initialize', {})).rejects.toThrow('exit code: 3')
})
it('flushes an unterminated stderr line into the tail at close', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_STDERR_NO_NEWLINE: 'no trailing newline', FAKE_EXIT_BEFORE_INIT: '1' }))
cleanups.push(() => client.close())
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
() => { throw new Error('initialize unexpectedly succeeded') },
(error: unknown) => error,
)
expect(String(failure)).toContain('no trailing newline')
})
it('fails fast when the command does not exist', async () => {
const client = new HarnessClient({ command: join(tmpdir(), 'dsh-no-such-runtime-bin') })
cleanups.push(() => client.close())
await expect(client.request('initialize', {}, 1_000)).rejects.toThrow(TransportClosedError)
})
it('close() is idempotent, reaps the child, and fails later use', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
await Promise.all([client.close(), client.close()])
expect(() => { client.start() }).toThrow(TransportClosedError)
await expect(client.request('anything')).rejects.toThrow(TransportClosedError)
// Close with no child ever spawned is a no-op.
const untouched = new HarnessClient(fakeLaunch())
await untouched.close()
})
it('escalates through SIGTERM when the runtime ignores EOF', async () => {
const dir = await tempDir('sdk-client-ladder-')
const sigtermFile = join(dir, 'sigterm.txt')
const client = new HarnessClient(fakeLaunch(
{ FAKE_IGNORE_EOF: '1', FAKE_SIGTERM_FILE: sigtermFile },
{ shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 1_000 },
))
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
await client.close()
if (process.platform === 'win32') {
await expect(stat(sigtermFile)).rejects.toMatchObject({ code: 'ENOENT' })
} else {
expect((await stat(sigtermFile)).isFile()).toBe(true)
}
})
it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => {
const client = new HarnessClient(fakeLaunch(
{ FAKE_IGNORE_EOF: '1', FAKE_TRAP_SIGTERM: '1' },
{ shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 300 },
))
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
// Resolves (does not hang or reject): the SIGKILL rung reaped the child.
await client.close()
})
it('delivers notifications to unfiltered and filtered subscriptions in wire order', async () => {
const client = new HarnessClient(fakeLaunch())
cleanups.push(() => client.close())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const all = client.subscribe()
const idleOnly = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
const firstPending = all.next()
await client.prompt('sub-test', normalizeInput('go'))
const first = await firstPending
expect(first.method).toBe('session.event')
const idle = await idleOnly.next()
expect(idle.method).toBe('session.status')
expect(idleOnly.tryNext()).toBeUndefined()
// A bare unbounded request with omitted params sends `{}` on the wire.
const identity = await client.request('initialize') as { serverInfo: { name: string } }
expect(identity.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
// Async iteration consumes queued items and then parks.
const collected: string[] = []
for await (const notification of all) {
collected.push(notification.method)
if (notification.method === 'session.status' && notification.params.status === 'idle') break
}
expect(collected.at(-1)).toBe('session.status')
all.close()
idleOnly.close()
await expect(all.next()).rejects.toThrow('notification subscription closed')
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.status' && n.params.status === 'idle')
await client.prompt('filter-contain', normalizeInput('go'))
// The sibling subscription and the read loop are undisturbed.
expect((await healthy.next()).method).toBe('session.status')
// Each broken subscription failed with ITS OWN error and detached.
await expect(broken.next()).rejects.toThrow('filter exploded')
await expect(brokenNonError.next()).rejects.toThrow('string boom')
healthy.close()
await client.close()
})
it('close() drops queued notifications; runtime death keeps them drainable', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const closed = client.subscribe()
const drainable = client.subscribe()
await client.prompt('queue-drop', normalizeInput('go'))
expect(closed.tryNext()).toBeDefined()
closed.close()
// Manual close drops the rest of the queue outright.
expect(closed.tryNext()).toBeUndefined()
await expect(closed.next()).rejects.toThrow('notification subscription closed')
// Runtime teardown, by contrast, only stops FUTURE delivery: what was
// already delivered before close() stays drainable.
await client.close()
expect(drainable.tryNext()).toBeDefined()
})
it('subscriptions created after termination are born failed', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
await client.close()
// No producer can ever feed this subscription; next() must not park forever.
await expect(client.subscribe().next()).rejects.toThrow(TransportClosedError)
const dead = new HarnessClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1' }))
cleanups.push(() => dead.close())
await dead.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).catch(() => {})
await expect(dead.subscribe().next()).rejects.toThrow(TransportClosedError)
})
it('closes subscriptions with the runtime and rejects parked waiters', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const subscription = client.subscribe()
const parked = subscription.next()
await client.close()
await expect(parked).rejects.toThrow(TransportClosedError)
})
it('scopes the session tree across multi-hop lineage and ignores foreign sessions', async () => {
const client = new HarnessClient(fakeLaunch())
cleanups.push(() => client.close())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const tree = client.subscribeSessionTree('root')
// Lineage edges arrive as subagent.started notifications.
const inject = (method: string, params: Record<string, unknown>): void => {
(client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params })
}
inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' })
inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' })
inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } })
inject('session.event', { sessionId: 'stranger', event: { type: 'noop' } })
inject('subagent.started', { parentSessionId: 'other-root', childSessionId: 'other-child' })
inject('subagent.finished', { parentSessionId: 'child', childSessionId: 'grandchild' })
// Self-loop and empty edges must not corrupt the lineage map.
inject('subagent.started', { parentSessionId: 'loop', childSessionId: 'loop' })
inject('subagent.started', { parentSessionId: '', childSessionId: 'x' })
inject('subagent.finished', { childSessionId: 'root' })
expect((await tree.next()).method).toBe('subagent.started')
expect((await tree.next()).method).toBe('subagent.started')
expect((await tree.next()).params.sessionId).toBe('grandchild')
expect((await tree.next()).method).toBe('subagent.finished')
// The foreign-root edge and stranger event were filtered; next is the root-child edge.
expect((await tree.next()).params.childSessionId).toBe('root')
tree.close()
await client.close()
})
})
describe('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)
})
})
describe('stderr tail bound', () => {
it('keeps only the newest lines up to the limit', async () => {
const manyLines = Array.from({ length: 450 }, (_, i) => `line-${i}`).join('\n')
const client = new HarnessClient(fakeLaunch({ FAKE_STDERR: manyLines, FAKE_EXIT_BEFORE_INIT: '1' }))
cleanups.push(() => client.close())
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
() => { throw new Error('initialize unexpectedly succeeded') },
(error: unknown) => error,
)
const text = String(failure)
// The tail is bounded to the newest 400 lines: the oldest are dropped.
expect(text).toContain('line-449')
expect(text).not.toContain('line-0\n')
})
})
describe('pure helpers', () => {
it('normalizeInput wraps strings and passes blocks through', () => {
expect(normalizeInput('x')).toEqual([{ type: 'text', text: 'x' }])
const blocks = [{ type: 'text' as const, text: 'y' }]
expect(normalizeInput(blocks)).toBe(blocks)
})
it('finalResponse reads the last assistant message and tolerates absence', () => {
expect(finalResponse([])).toBe('')
expect(finalResponse([{ type: 'turn/start', seq: 0, time: 0, data: { turn: 0 } } as never])).toBe('')
expect(finalResponse([
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'first' }] } } } as never,
{ type: 'assistant/message', seq: 1, time: 0, data: { message: { content: [{ type: 'text', text: 'a' }, { type: 'tool-call' }, { type: 'text', text: 'b' }] } } } as never,
])).toBe('ab')
})
})