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