Merge master into worktree-windows-runtime

This commit is contained in:
Tianyi Cui
2026-07-21 23:39:14 +08:00
936 changed files with 36271 additions and 10361 deletions

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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' })