Forráskód Böngészése

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.
Yichen Jiang 2 hónapja
szülő
commit
822651a2c7

+ 37 - 0
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

+ 48 - 0
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<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())
+}

+ 1 - 0
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:*",

+ 1 - 0
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"
       ],

+ 101 - 0
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)
+})

+ 3 - 0
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