From 822651a2c72677d82e02554201040e26c9a9d4ea Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 21 Jul 2026 16:51:51 +0800 Subject: [PATCH] test(subagent-acp): cover inherited cwd through a Loader composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inheritance branch ran only under hand-mounted plugin tests, and the with-key e2e always configures an explicit cwd. Add a test-only cordis.yml (scripted delegating model + the scripted mock ACP child) booting the stdio app through the real Loader with cwd omitted: the child proves it ran in — and was announced — the parent session's workspace, keylessly. --- .../fixtures/subagent/subagent-acp/cordis.yml | 37 +++++++ .../subagent-acp/mock-delegating-llm.ts | 48 +++++++++ examples/package.json | 1 + knip.json | 1 + .../tests/loader-composition.e2e.ts | 101 ++++++++++++++++++ pnpm-lock.yaml | 3 + 6 files changed, 191 insertions(+) create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts create mode 100644 packages/subagent/subagent-acp/tests/loader-composition.e2e.ts diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml new file mode 100644 index 0000000000..76ac73078f --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml @@ -0,0 +1,37 @@ +# Test-only composition: the ACP subagent backend on the real Loader/app path. +# The scripted model delegates once; the scripted mock ACP child (MOCK_ECHO_CWD) +# echoes its process cwd and announced session cwd, so parent-session cwd +# inheritance is asserted keylessly end to end. `cwd` is deliberately omitted — +# the inheritance branch under test. The child command path is machine-absolute, +# so the driving e2e supplies it via DSH_TEST_MOCK_ACP_SERVER. +- id: mock-llm + name: './mock-delegating-llm.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-acp + name: '@deepseek-ai/dsh-subagent-acp' + config: + providerName: acp + command: !!js process.execPath + args: + - !!js process.env.DSH_TEST_MOCK_ACP_SERVER + permission: reject + env: + MOCK_ECHO_CWD: '1' + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: acp + toolName: subagent + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: mock + model: mock-delegate + welcome: 'acp subagent cwd e2e ready.' + persistenceRoot: './.sessions' + workspaceContext: false diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts new file mode 100644 index 0000000000..9d3857ffc8 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/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 ACP child's answer (the scripted mock server's cwd echo) + * reaches the REPL stdout 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 3a5f90d30e..026968888b 100644 --- a/examples/package.json +++ b/examples/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-stdio-demo": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-subagent-acp": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", diff --git a/knip.json b/knip.json index 2c3465ab38..f37ceeb3a6 100644 --- a/knip.json +++ b/knip.json @@ -12,6 +12,7 @@ "echo-agent/src/*.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", + "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" ], diff --git a/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts b/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..bdebf64fbb --- /dev/null +++ b/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts @@ -0,0 +1,101 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { realpathSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +/** + * Keyless REAL-composition coverage for parent-session cwd inheritance: a + * test-only cordis.yml boots the stdio app through the Loader with the ACP + * backend's `cwd` omitted, a scripted model delegates once, and the scripted + * mock ACP child echoes where it actually ran plus the workspace it was + * announced — both must be the parent session's cwd. Mock-only composition, so + * only this keyless tier applies (the with-key tier lives in subagent-acp.e2e.ts). + */ + +const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml', + import.meta.url, +)) +const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const PROCESS_TIMEOUT_MS = 30_000 +const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function runDelegation(): Promise<{ stdout: string; stderr: string; cwd: string }> { + workdir = await mkdtemp(join(tmpdir(), 'acp-subagent-composition-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + DSH_TEST_MOCK_ACP_SERVER: mockServer, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child = proc + let stdout = '' + let stderr = '' + let closedStdin = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + // One full turn: delegation + the follow-up reply quoting the child. + if (!closedStdin && stdout.includes('child reported:')) { + closedStdin = true + proc.stdin.end() + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error(`acp-subagent composition e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, PROCESS_TIMEOUT_MS) + + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr, cwd }) + else reject(new Error(`acp-subagent composition e2e exited ${code}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('delegate\n') + }) +} + +describe('ACP subagent cwd inheritance through a real cordis.yml and stdio process', () => { + it('runs the child in the parent session workspace and announces it as the ACP session cwd', async () => { + const { stdout, stderr, cwd } = await runDelegation() + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('acp subagent cwd e2e ready.') + // The child streams two lines: its real process.cwd() and the cwd the + // backend announced in `session/new`. The parent session's workspace is the + // app's launch directory (canonical form — the child reports realpaths). + const workspace = realpathSync(cwd) + expect(stdout).toContain(`child reported:\n${workspace}\n${workspace}`) + }, TEST_TIMEOUT_MS) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dba3435f60..74cda32326 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-acp': + specifier: workspace:* + version: link:../packages/subagent/subagent-acp '@deepseek-ai/dsh-subagent-fork': specifier: workspace:* version: link:../packages/subagent/subagent-fork