test(subagent-acp): cover inherited cwd through a Loader composition
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.
This commit is contained in:
@@ -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
|
||||||
+48
@@ -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<StreamChunk> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"@deepseek-ai/dsh-spill-policy": "workspace:*",
|
"@deepseek-ai/dsh-spill-policy": "workspace:*",
|
||||||
"@deepseek-ai/dsh-stdio-demo": "workspace:*",
|
"@deepseek-ai/dsh-stdio-demo": "workspace:*",
|
||||||
"@deepseek-ai/dsh-subagent": "workspace:*",
|
"@deepseek-ai/dsh-subagent": "workspace:*",
|
||||||
|
"@deepseek-ai/dsh-subagent-acp": "workspace:*",
|
||||||
"@deepseek-ai/dsh-subagent-fork": "workspace:*",
|
"@deepseek-ai/dsh-subagent-fork": "workspace:*",
|
||||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:*",
|
"@deepseek-ai/dsh-subagent-spawn": "workspace:*",
|
||||||
"@deepseek-ai/dsh-time-context": "workspace:*",
|
"@deepseek-ai/dsh-time-context": "workspace:*",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"echo-agent/src/*.ts",
|
"echo-agent/src/*.ts",
|
||||||
"headless-agent/tests/fixtures/cli-mock-llm.ts",
|
"headless-agent/tests/fixtures/cli-mock-llm.ts",
|
||||||
"tui-agent/tests/fixtures/tui-scripted-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/**/*.e2e.ts",
|
||||||
"*/tests/**/*.snapshot.ts"
|
"*/tests/**/*.snapshot.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)
|
||||||
|
})
|
||||||
Generated
+3
@@ -167,6 +167,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-subagent':
|
'@deepseek-ai/dsh-subagent':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/subagent/subagent
|
version: link:../packages/subagent/subagent
|
||||||
|
'@deepseek-ai/dsh-subagent-acp':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/subagent/subagent-acp
|
||||||
'@deepseek-ai/dsh-subagent-fork':
|
'@deepseek-ai/dsh-subagent-fork':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/subagent/subagent-fork
|
version: link:../packages/subagent/subagent-fork
|
||||||
|
|||||||
Reference in New Issue
Block a user