|
|
@@ -1,101 +1,73 @@
|
|
|
-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 { readFile, readdir } from 'node:fs/promises'
|
|
|
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'
|
|
|
+import { describe, expect, it } from 'vitest'
|
|
|
+import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } 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
|
|
|
+ * test-only cordis.yml boots the headless 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 driver = fileURLToPath(new URL(
|
|
|
+ '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.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 jsonlFiles(dir: string): Promise<string[]> {
|
|
|
+ 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 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],
|
|
|
+describe('ACP subagent cwd inheritance through a real cordis.yml', () => {
|
|
|
+ it('runs the child in the parent session workspace and announces it as the ACP session cwd', async () => {
|
|
|
+ let events: SessionEvent[] = []
|
|
|
+ let workspace = ''
|
|
|
+ const { stderr } = await runLoaderSmoke({
|
|
|
+ label: 'acp-subagent cwd composition smoke',
|
|
|
+ tempDirPrefix: 'acp-subagent-cwd-e2e-',
|
|
|
+ binScript: driver,
|
|
|
+ libBinScript: driver,
|
|
|
+ configPath,
|
|
|
tsconfigPath: repoTsconfig,
|
|
|
- exposeInternals: true,
|
|
|
- env: {
|
|
|
- DSH_TEST_MOCK_ACP_SERVER: mockServer,
|
|
|
- DSH_HOME: join(cwd, '.dsh'),
|
|
|
- DSH_AGENTS_HOME: join(cwd, '.agents'),
|
|
|
+ env: { DSH_TEST_MOCK_ACP_SERVER: mockServer },
|
|
|
+ inspect: async (cwd) => {
|
|
|
+ // The child reports realpaths; canonicalize the temp workspace to match.
|
|
|
+ workspace = realpathSync(cwd)
|
|
|
+ const logs = await jsonlFiles(join(cwd, '.sessions'))
|
|
|
+ expect(logs).toHaveLength(1)
|
|
|
+ const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
|
|
+ events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
|
|
},
|
|
|
})
|
|
|
- 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)
|
|
|
+
|
|
|
+ // The tool result carries the child's two-line echo: its real process.cwd()
|
|
|
+ // and the cwd the backend announced in `session/new` — both 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(`${workspace}\n${workspace}`)
|
|
|
+ }, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
|
|
})
|