From 3e89a73c71b5962cacbdbb9227cb9254821c83b2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:59:46 +0800 Subject: [PATCH] test(subagent-sdk): keyless Loader-composition e2e across the SDK wire A test-only cordis.yml under examples/jsonrpc-agent boots the headless app through the Loader; a scripted model delegates once to the SDK backend, whose child is a COMPLETE second harness runtime (own cordis.yml, jsonrpc serving surface, scripted cwd-echo model, own JSONL persistence). Asserts the parent tool result AND the child's own persisted transcript both carry the parent session's workspace cwd; child launch resolves through the shared example-launch resolver so src/lib modes both hold. --- .../subagent/subagent-sdk/child-mock-llm.ts | 32 ++++++ .../subagent/subagent-sdk/child.cordis.yml | 36 +++++++ .../fixtures/subagent/subagent-sdk/cordis.yml | 42 ++++++++ .../fixtures/subagent/subagent-sdk/driver.ts | 15 +++ .../subagent-sdk/mock-delegating-llm.ts | 48 +++++++++ examples/package.json | 13 +-- knip.json | 23 +++- .../tests/loader-composition.e2e.ts | 101 ++++++++++++++++++ 8 files changed, 300 insertions(+), 10 deletions(-) create mode 100644 examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts create mode 100644 examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml create mode 100644 examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml create mode 100644 examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts create mode 100644 examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts create mode 100644 packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts new file mode 100644 index 0000000000..30f480064e --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts @@ -0,0 +1,32 @@ +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' + +/** + * Scripted model for the CHILD runtime: answers every request with its own + * process cwd, so the driving e2e can prove the parent session's workspace + * reached the child process across the SDK wire. `options` carries the + * request; the reply depends only on process state. + */ +class CwdEchoAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + void options + const reply = `child cwd: ${process.cwd()}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 3, outputTokens: reply.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'child-mock-llm' +export const inject = ['llm'] + +/** + * Register the cwd-echo adapter under the `mock` provider. + * @param ctx - the plugin context supplying `ctx.llm`. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new CwdEchoAdapter()) +} diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml new file mode 100644 index 0000000000..ad77c03c19 --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml @@ -0,0 +1,36 @@ +# The CHILD runtime for the SDK subagent composition test: a complete +# stdio JSON-RPC harness whose scripted model echoes its process cwd. The +# parent's subagent-sdk backend spawns this composition per run; stdout is +# reserved for JSON-RPC frames. +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +- id: child-mock-llm + name: './child-mock-llm.ts' + +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + persona: 'Echo where you run.' + workspaceContext: false + skills: + enabled: false + toolBash: + enableRunInBackground: false + toolTasks: false + +# The child persists its own session log beside the parent's (distinct root), +# so the driving e2e can inspect both transcripts after the run. +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.child-sessions' + compression: none + +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml new file mode 100644 index 0000000000..a1e95fcddd --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml @@ -0,0 +1,42 @@ +# Test-only composition: the SDK subagent backend on the real Loader/app path. +# The scripted model delegates once; the child — a COMPLETE second harness +# runtime speaking stdio JSON-RPC — echoes its process cwd, so parent-session +# cwd inheritance is asserted keylessly end to end across the SDK wire. +# `cwd` is deliberately omitted — the inheritance branch under test. The child +# launch is machine-absolute, so the driving e2e supplies it via +# DSH_TEST_CHILD_COMMAND / DSH_TEST_CHILD_ARGS / DSH_TEST_CHILD_ENV (resolved +# through the shared example-launch resolver, per testing policy). +- id: mock-llm + name: './mock-delegating-llm.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-sdk + name: '@deepseek-ai/dsh-subagent-sdk' + config: + providerName: sdk + command: !!js process.env.DSH_TEST_CHILD_COMMAND + args: !!js JSON.parse(process.env.DSH_TEST_CHILD_ARGS ?? '[]') + provider: mock + model: mock-echo + env: !!js JSON.parse(process.env.DSH_TEST_CHILD_ENV ?? '{}') + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: sdk + toolName: subagent + # The SDK backend advertises no depthLimit: the child harness owns its own + # recursion budget, so the local numeric default cannot apply here. + maxDepth: 'provider-managed' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: mock + model: mock-delegate + persona: 'Test SDK subagent cwd inheritance.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts new file mode 100644 index 0000000000..34412c1839 --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +/** Test driver: one delegation turn through a headless Loader composition. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('sdk-subagent cwd driver requires a config path') + +const ctx = await boot('sdk-subagent-cwd-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'delegate' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts new file mode 100644 index 0000000000..e5ce753fd8 --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts @@ -0,0 +1,48 @@ +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' + +/** + * Test adapter for the `mock-delegate` model: the first request calls the + * `subagent` tool once, and the follow-up streams the tool result text back + * verbatim — so the SDK child runtime's answer (the scripted child model's + * cwd echo) reaches the parent session log for the driving e2e to assert. + */ +class MockDelegatingAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const toolResultText = options.messages.at(-1)?.content + .filter(block => block.type === 'tool-result') + .flatMap(block => block.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') ?? '' + + if (toolResultText.length === 0) { + const args = JSON.stringify({ description: 'cwd probe', prompt: 'report your workspace' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: CallId('call-delegate'), name: 'subagent', argumentsDelta: args } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-delegate'), name: 'subagent', arguments: args } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + + const reply = `child reported:\n${toolResultText}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'mock-llm' +export const inject = ['llm'] + +/** + * Register the delegating mock adapter under the `mock` provider. + * @param ctx - the plugin context supplying `ctx.llm`. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new MockDelegatingAdapter()) +} diff --git a/examples/package.json b/examples/package.json index 8a81d399b8..279e211fe2 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", @@ -30,24 +30,24 @@ "@deepseek-ai/dsh-llm-replay": "workspace:*", "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", - "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", + "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", - "@deepseek-ai/dsh-tui-demo": "workspace:*", - "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", + "@deepseek-ai/dsh-subagent-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", @@ -57,15 +57,16 @@ "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", - "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-lsp": "workspace:*", + "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-tui-demo": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-local": "workspace:*", diff --git a/knip.json b/knip.json index 110abb3a2b..f49fc986d5 100644 --- a/knip.json +++ b/knip.json @@ -36,6 +36,9 @@ "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" ], @@ -262,8 +265,14 @@ ] }, "packages/session-query/session-query-sqlite": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/code-runtime/code-runtime-worker": { "entry": [ @@ -316,8 +325,14 @@ ] }, "packages/session-persistence/session-checkpoint-policy": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/util/paths": { "entry": [ diff --git a/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts b/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..4fe435b311 --- /dev/null +++ b/packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts @@ -0,0 +1,101 @@ +/** + * Keyless REAL-composition coverage for parent-session cwd inheritance across + * the SDK wire: a test-only cordis.yml boots the headless app through the + * Loader with the SDK backend's `cwd` omitted, a scripted model delegates + * once, and the child — a COMPLETE second harness runtime booted from its own + * cordis.yml and driven over stdio JSON-RPC — echoes where it actually ran. + * Both the parent's tool result and the child's own persisted session log + * must carry the parent session's cwd. Mock-only composition, so only this + * keyless tier applies (the with-key tier lives in subagent-sdk.e2e.ts). + */ + +import { realpathSync } from 'node:fs' +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +const fixtureDir = new URL('../../../../examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/', import.meta.url) +const driver = fileURLToPath(new URL('driver.ts', fixtureDir)) +const configPath = fileURLToPath(new URL('cordis.yml', fixtureDir)) +const childConfigPath = fileURLToPath(new URL('child.cordis.yml', fixtureDir)) +const runtimeBin = fileURLToPath(new URL('../../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +async function sessionEvents(log: string): Promise { + const lines = (await readFile(log, 'utf8')).trimEnd().split('\n') + return lines.slice(1).map(line => JSON.parse(line) as SessionEvent) +} + +describe('SDK subagent cwd inheritance through a real cordis.yml', () => { + it('runs the child runtime in the parent session workspace', async () => { + // The child launch honors the same src/lib mode as the driving harness, + // per the shared example-launch resolver (testing policy forbids + // hand-written `--import tsx` argv for example subprocesses). + const childLaunch = resolveExampleLaunch({ + srcBin: runtimeBin, + configArgs: [childConfigPath], + tsconfigPath: repoTsconfig, + }) + + let events: SessionEvent[] = [] + let childEvents: SessionEvent[] = [] + let workspace = '' + const { stderr } = await runLoaderSmoke({ + label: 'sdk-subagent cwd composition smoke', + tempDirPrefix: 'sdk-subagent-cwd-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { + DSH_TEST_CHILD_COMMAND: childLaunch.command, + DSH_TEST_CHILD_ARGS: JSON.stringify(childLaunch.args), + DSH_TEST_CHILD_ENV: JSON.stringify({ + ...Object.fromEntries(Object.entries(childLaunch.env).filter(([, value]) => value !== undefined)), + }), + }, + inspect: async (cwd) => { + // The child reports realpaths; canonicalize the temp workspace to match. + workspace = realpathSync(cwd) + const parentLogs = await jsonlFiles(join(cwd, '.sessions')) + expect(parentLogs).toHaveLength(1) + events = await sessionEvents(parentLogs[0] as string) + // The child runtime persisted its own transcript in ITS cwd — which + // must be the parent session's workspace for the inheritance to hold. + const childLogs = await jsonlFiles(join(cwd, '.child-sessions')) + expect(childLogs).toHaveLength(1) + childEvents = await sessionEvents(childLogs[0] as string) + }, + }) + expect(stderr).not.toContain('UNHANDLED') + + // The parent's tool result carries the child model's echo of its real + // process.cwd() — the parent session's workspace, never the harness + // process's launch directory. + const results = events.filter(event => event.type === 'tool/result') + expect(results).toHaveLength(1) + const resultText = results[0]!.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + expect(resultText).toBe(`child cwd: ${workspace}`) + + // The child ran a real turn of its own: user message in, assistant out. + expect(childEvents.some(event => event.type === 'user/message')).toBe(true) + const childAnswers = childEvents.filter(event => event.type === 'assistant/message') + expect(childAnswers.length).toBeGreaterThan(0) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +})