Merge master into worktree-windows-runtime
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
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, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
/**
|
||||
* Keyless REAL-composition coverage for parent-session cwd inheritance: a
|
||||
* 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 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))
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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,
|
||||
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)
|
||||
},
|
||||
})
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
|
||||
// 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)
|
||||
})
|
||||
@@ -15,6 +15,11 @@
|
||||
* `dispose()` must still kill the process.
|
||||
* - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission`
|
||||
* before answering, to exercise the client's auto-answer.
|
||||
* - `MOCK_ECHO_CWD` — if `1`, ignore MOCK_TEXT and stream two lines instead:
|
||||
* the agent PROCESS's `process.cwd()` and the `cwd` the
|
||||
* client announced in `session/new` — so a test can assert
|
||||
* where the child actually ran and what workspace it was
|
||||
* told it has.
|
||||
* - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt`
|
||||
* handler is in flight (it has streamed its chunk). A test
|
||||
* polls for this file to cancel on a CONDITION rather than
|
||||
@@ -63,6 +68,7 @@ import {
|
||||
} from '@agentclientprotocol/sdk'
|
||||
|
||||
const TEXT = process.env.MOCK_TEXT ?? 'mock child answer'
|
||||
const ECHO_CWD = process.env.MOCK_ECHO_CWD === '1'
|
||||
const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason
|
||||
const HANG = process.env.MOCK_HANG === '1'
|
||||
const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1'
|
||||
@@ -83,6 +89,8 @@ function makeAgent(conn: AgentSideConnection): Agent {
|
||||
// Pending cancel resolver for the HANG path: a `session/cancel` resolves the
|
||||
// prompt with `cancelled`.
|
||||
let resolveCancel: ((reason: StopReason) => void) | undefined
|
||||
// The cwd the client announced in `session/new`, echoed under MOCK_ECHO_CWD.
|
||||
let sessionCwd: string | undefined
|
||||
|
||||
return {
|
||||
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
@@ -92,7 +100,8 @@ function makeAgent(conn: AgentSideConnection): Agent {
|
||||
authMethods: [],
|
||||
})
|
||||
},
|
||||
async newSession(_params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
sessionCwd = params.cwd
|
||||
// Optionally signal "newSession reached" and block until released, so a
|
||||
// test can cancel DURING newSession (the early-cancel race window) on a
|
||||
// condition rather than a timeout.
|
||||
@@ -136,10 +145,14 @@ function makeAgent(conn: AgentSideConnection): Agent {
|
||||
update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking…' } },
|
||||
})
|
||||
}
|
||||
// Stream the canned assistant text as one chunk.
|
||||
// Stream the canned assistant text as one chunk (or, under MOCK_ECHO_CWD,
|
||||
// the observable process cwd + announced session cwd).
|
||||
await conn.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: TEXT } },
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: ECHO_CWD ? `${process.cwd()}\n${sessionCwd ?? ''}` : TEXT },
|
||||
},
|
||||
})
|
||||
// Signal "prompt is in flight" by touching the readiness file, so a test
|
||||
// can wait on a CONDITION (file exists) rather than an arbitrary timeout
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
|
||||
@@ -22,8 +22,8 @@ import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DI
|
||||
|
||||
const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url))
|
||||
|
||||
/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */
|
||||
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
/** A parent Agent stub. The ACP backend reads exactly one thing off it: the session header's cwd (the workspace its child inherits). */
|
||||
const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
|
||||
|
||||
function request(text = 'p', signal = new AbortController().signal) {
|
||||
return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
|
||||
@@ -115,6 +115,184 @@ describe('buildChildEnv', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cwd resolution', () => {
|
||||
it('falls back to the parent session cwd for the child process AND its ACP session', async () => {
|
||||
// realpath: on macOS `tmpdir()` sits behind a symlink (/var → /private/var),
|
||||
// and the child reports its REAL process.cwd() — compare canonical paths.
|
||||
const workdir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-')))
|
||||
try {
|
||||
const ctx = await setup({ MOCK_ECHO_CWD: '1' })
|
||||
const parent = { id: 'parent', session: { header: { cwd: workdir } } } as unknown as Agent
|
||||
const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
// Line 1: where the child process actually ran; line 2: the workspace the
|
||||
// backend announced in `session/new`. Both must be the parent's workspace.
|
||||
expect(text(result.output)).toBe(`${workdir}\n${workdir}`)
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects before spawning when neither config.cwd nor the parent session provides one', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-no-cwd-'))
|
||||
const sentinel = join(tmp, 'spawned')
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
// A command that would create the sentinel if the child were ever spawned.
|
||||
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
||||
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('no working directory')
|
||||
// Resolution failed BEFORE the process boundary — nothing was launched.
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('prefers the configured cwd override to the parent session cwd', async () => {
|
||||
const configured = realpathSync(mkdtempSync(join(tmpdir(), 'acp-cfg-cwd-')))
|
||||
const parentDir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-')))
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: configured,
|
||||
permission: 'reject',
|
||||
env: { MOCK_ECHO_CWD: '1' },
|
||||
})
|
||||
const parent = { id: 'parent', session: { header: { cwd: parentDir } } } as unknown as Agent
|
||||
const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
expect(text(result.output)).toBe(`${configured}\n${configured}`)
|
||||
} finally {
|
||||
rmSync(configured, { recursive: true, force: true })
|
||||
rmSync(parentDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a relative config cwd against the launch directory at load', async () => {
|
||||
// The child process AND its announced ACP session cwd must both get the
|
||||
// ABSOLUTE form — DSH's own ACP server rejects a relative session cwd, and
|
||||
// deferring resolution to spawn would hide the launch-dir dependency.
|
||||
const relative = 'packages/subagent/subagent-acp'
|
||||
const absolute = resolve(relative)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: relative,
|
||||
permission: 'reject',
|
||||
env: { MOCK_ECHO_CWD: '1' },
|
||||
})
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
expect(text(result.output)).toBe(`${realpathSync(absolute)}\n${absolute}`)
|
||||
})
|
||||
|
||||
it('rejects an empty config cwd at load', async () => {
|
||||
// `path.resolve('')` is the process cwd, so an empty string would silently
|
||||
// reintroduce the launch-directory fallback this resolution removed.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await expect(ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: 'true',
|
||||
args: [],
|
||||
cwd: '',
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
})).rejects.toThrow('config cwd must not be empty')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a config cwd directory without search permission at load', async () => {
|
||||
// statSync().isDirectory() is true for a mode-600 directory, but a
|
||||
// subprocess cwd needs SEARCH permission — spawn would fail EACCES.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-noexec-'))
|
||||
chmodSync(tmp, 0o600)
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await expect(ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: 'true',
|
||||
args: [],
|
||||
cwd: tmp,
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
})).rejects.toThrow('not an accessible directory')
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
chmodSync(tmp, 0o700)
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a config cwd that is not an accessible directory at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await expect(ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: 'true',
|
||||
args: [],
|
||||
cwd: '/nonexistent/acp-child-workspace',
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
})).rejects.toThrow('not an accessible directory')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a parent session cwd that is not absolute', async () => {
|
||||
// SessionHeader documents cwd as absolute; a relative value here is a broken
|
||||
// header, and resolving it against the server process cwd would silently
|
||||
// re-introduce the launch-directory dependency this resolution removes.
|
||||
const ctx = await setup({})
|
||||
const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('must be an absolute path')
|
||||
})
|
||||
|
||||
it('rejects a parent session cwd that names a FILE, not a directory', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-file-cwd-'))
|
||||
const file = join(tmp, 'a-file')
|
||||
writeFileSync(file, 'x')
|
||||
try {
|
||||
const ctx = await setup({})
|
||||
const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('not an accessible directory')
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a parent session cwd that is not an accessible directory, before spawning', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-bad-parent-cwd-'))
|
||||
const sentinel = join(tmp, 'spawned')
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
||||
const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('not an accessible directory')
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-subagent-acp', () => {
|
||||
it('drives child processes with parent-unique run ids and returns streamed output', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn', MOCK_SESSION_ID: 'acp-child-session' })
|
||||
|
||||
Reference in New Issue
Block a user