Merge remote-tracking branch 'origin/master' into codex/pr335-merge-master-20260719

# Conflicts:
#	packages/support/acp-snapshot/README.md
#	packages/support/acp-snapshot/src/harness.ts
#	packages/support/acp-snapshot/src/suite.ts
#	packages/support/acp-snapshot/tests/harness.spec.ts
#	packages/support/acp-snapshot/tests/suite.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-19 11:41:10 +08:00
330 changed files with 7947 additions and 4450 deletions

View File

@@ -6,6 +6,8 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag
`start(request)` performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped.
The returned run id is minted in the parent namespace. The child server's session id remains private to ACP wire calls because ACP guarantees it only within that fresh child process; using it as the parent lifecycle id could collide with another remote run or a local agent.
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. Disposal resolves only after child exit. Every run uses a fresh process; process pooling is not implemented.

View File

@@ -24,6 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subagent-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -36,6 +37,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",

View File

@@ -23,8 +23,8 @@ import {
type SessionNotification,
type StopReason,
} from '@agentclientprotocol/sdk'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess'
@@ -149,9 +149,11 @@ function toError(value: unknown): Error {
* @returns the ready run handle for the child subprocess.
*/
export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise<SubagentRun> {
const id = AgentId(randomUUID())
if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started')
// ACP session ids are unique only within the child server. The lifecycle id
// is minted in the parent namespace so fresh processes cannot collide with
// each other or with a local agent that happens to use the same session id.
const id = SessionId(randomUUID())
// Keep diagnostics on parent stderr; only ACP output contributes to the result.
const child = spawn(spec.command, spec.args, {
@@ -241,7 +243,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
clientCapabilities: {},
})
const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] })
sessionId = session.sessionId
const returnedSessionId: unknown = Reflect.get(session, 'sessionId')
if (typeof returnedSessionId !== 'string') throw new Error('ACP child published without a session id')
sessionId = returnedSessionId
if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
})(),
spawnFailed.then((err): never => { throw err }),
@@ -253,13 +257,18 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started')
throw toError(error)
}
// The startup transaction validates the returned id before it can fulfill.
// This assertion carries that cross-closure invariant into TypeScript.
/* v8 ignore next */
if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id')
const remoteSessionId = sessionId
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
try {
// Race the remote turn against local cancellation.
const prompt = async (): Promise<SubagentResult> => {
// The startup phase cannot fulfill without assigning the session id.
const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) })
const promptResult = await conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) })
return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
}
return await Promise.race([
@@ -285,6 +294,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
let disposal: Promise<void> | undefined
return {
id,
localAgent: undefined,
result,
dispose(): Promise<void> {
if (disposal !== undefined) return disposal

View File

@@ -1,9 +1,45 @@
/**
* Minimal no-network ACP child process for keyless backend tests. Environment variables script its
* text and stop reason, a cancel-cooperative or cancel-ignoring hang, permission requests, and a
* readiness marker. Disposal fixtures can delay an EOF flush, ignore EOF but exit and mark
* SIGTERM, or trap SIGTERM to require SIGKILL. The specs run this protocol-only fixture directly
* with Node's type stripping; it imports no harness code or workspace paths.
* A minimal mock ACP AGENT, run as a subprocess, for the keyless
* `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is
* fully scripted by environment variables — no model, no network:
*
* - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`.
* - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt`
* (`end_turn` default, or `max_tokens`/`refusal`/…).
* - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for
* a `session/cancel`), to exercise the client's cancel path.
* - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives
* `session/cancel` but NEVER resolves the pending prompt
* and never exits — a non-cooperative child. The backend's
* `result` must still settle `aborted` on its own and
* `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_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
* an arbitrary timeout (subprocess cold-start is variable).
* - `MOCK_MISSING_SESSION_ID` — if `1`, return a malformed empty `session/new`
* response to exercise startup rollback.
* - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat
* (MOCK_FLUSH_DELAY_MS, default 150) simulating the real
* acp-agent's EOF-driven quiesce+flush, then touches this
* path and exits ON ITS OWN — no signal. Stands in for a
* child whose durable flush completes only if dispose
* gives EOF a real window before escalating to SIGTERM.
* - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare
* timer) but install a SIGTERM handler that exits (and, if
* MOCK_SIGTERM_FILE is set, touches it as an observable
* proof the SIGTERM rung fired). The child ignores the
* graceful EOF window yet dies cooperatively on SIGTERM —
* exercising dispose's middle tier (exit during the SIGTERM
* grace, before the SIGKILL escalation). Touches
* MOCK_READY_FILE once armed.
*
* It is not a test spec: the specs launch this protocol-only fixture through
* the mode-aware example resolver (tsx in source mode, Node type stripping in
* built mode). It imports no harness code or workspace paths.
*
* @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
*/
@@ -64,7 +100,8 @@ function makeAgent(conn: AgentSideConnection): Agent {
writeFileSync(NEWSESSION_GATE.ready, 'at-newSession')
while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10))
}
return { sessionId: randomUUID() }
if (process.env.MOCK_MISSING_SESSION_ID === '1') return {} as NewSessionResponse
return { sessionId: process.env.MOCK_SESSION_ID ?? randomUUID() }
},
authenticate(_params: AuthenticateRequest): Promise<void> {
// No auth methods advertised; nothing to do.
@@ -124,8 +161,11 @@ function makeAgent(conn: AgentSideConnection): Agent {
process.exit(1)
}
if (IGNORE_CANCEL) {
// A non-cooperative child receives cancellation but neither resolves nor exits. The
// backend must still settle `aborted`, and disposal must kill the process.
// A NON-COOPERATIVE child: receive session/cancel but never resolve the
// pending prompt and never exit. The backend's `result` must still settle
// `aborted` on its own (the cancel-settle race), and `dispose()` must
// still kill the process — proving cancellation does not depend on the
// child cooperating.
return Promise.resolve()
}
resolveCancel?.('cancelled')
@@ -142,9 +182,12 @@ new AgentSideConnection(
),
)
// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process neither quiesces
// on EOF nor dies on the graceful signal — exercising the backend dispose path's SIGKILL
// escalation. READY_FILE proves the trap was armed before the test disposes the run.
// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process
// neither quiesces on EOF nor dies on the graceful signal — exercising the
// backend dispose path's SIGKILL escalation. Without this the process exits
// normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so
// a test waits for that CONDITION before disposing (the trap must be in place,
// not merely the process spawned — otherwise SIGTERM hits the default handler).
if (process.env.MOCK_TRAP_SIGTERM === '1') {
process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ })
// Keep the event loop alive (a bare timer) so nothing else lets it exit.
@@ -152,10 +195,13 @@ if (process.env.MOCK_TRAP_SIGTERM === '1') {
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed')
}
// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on stdin 'end' (the
// dispose path's `child.stdin.end()`), take an ASYNC beat to "flush", then touch the marker and
// exit on its own. A signal sent before MOCK_FLUSH_DELAY_MS would suppress the marker, so it proves
// the EOF grace window was long enough for durable flush.
// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on
// stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to
// "flush", then touch the marker and exit ON OUR OWN — no signal involved. The
// beat is MOCK_FLUSH_DELAY_MS (default 150). A dispose that sends SIGTERM before
// the beat completes (no graceful window, or an EOF grace shorter than the
// flush) default-terminates this process and the marker is missing; a dispose
// that gives the EOF quiesce enough window first lets the flush land.
if (FLUSH_ON_EOF !== undefined) {
const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150')
process.stdin.on('end', () => {
@@ -166,9 +212,14 @@ if (FLUSH_ON_EOF !== undefined) {
})
}
// Ignore EOF but exit on SIGTERM to exercise the middle disposal tier before SIGKILL. The signal
// marker distinguishes that catchable rung from an immediate, uncatchable SIGKILL; READY_FILE
// proves the handler was armed before disposal.
// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF (so the graceful EOF
// window times out) but INSTALL A SIGTERM HANDLER that records it and exits — the
// child ignores the graceful EOF window yet dies cooperatively on SIGTERM,
// exercising dispose's MIDDLE tier (exit during the SIGTERM grace, before the
// SIGKILL escalation). When MOCK_SIGTERM_FILE is set the handler touches it, an
// OBSERVABLE proof that the SIGTERM rung fired: if dispose skipped the middle
// rung and jumped EOF→SIGKILL, SIGKILL is uncatchable so the handler never runs
// and the marker is missing. Touch READY_FILE once armed (a test waits on it).
if (process.env.MOCK_IGNORE_EOF === '1') {
const sigtermFile = process.env.MOCK_SIGTERM_FILE
process.on('SIGTERM', () => {

View File

@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { buildChildEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subagent-subprocess'
import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as acp from '../src/index.ts'
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
@@ -58,7 +58,7 @@ function text(blocks: { type: string; text?: string }[]): string {
/**
* Poll until `file` exists (the mock touches it once its prompt is in flight),
* so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
* subprocess cold-start under tsx is variable, and a fixed sleep both flakes and
* subprocess cold-start is variable, and a fixed sleep both flakes and
* slows the suite. Fails loud if the child never signals readiness.
*/
async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
@@ -108,7 +108,6 @@ describe('buildChildEnv', () => {
// The explicitly-supplied key survives (an opt-in for the child's creds).
expect(env.DEEPSEEK_API_KEY).toBe('explicit')
// A normal ambient var is forwarded.
expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false)
expect(env.PATH).toBe(process.env.PATH)
} finally {
delete process.env.DSH_ACP_TEST_SECRET_TOKEN
@@ -117,15 +116,22 @@ describe('buildChildEnv', () => {
})
describe('dsh-subagent-acp', () => {
it('drives a child process to completion and returns its streamed output', async () => {
const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn' })
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' })
const run = await ctx.subagents.start('acp', request('do X'))
expect(run.id).not.toBe('acp-child-session')
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('hello from acp child')
const disposal = run.dispose()
expect(run.dispose()).toBe(disposal)
await disposal
const nextRun = await ctx.subagents.start('acp', request('do X again'))
expect(nextRun.id).not.toBe(run.id)
expect(nextRun.id).not.toBe('acp-child-session')
await nextRun.result
await nextRun.dispose()
})
it('maps a max_tokens stop reason', async () => {
@@ -184,6 +190,31 @@ describe('dsh-subagent-acp', () => {
}
})
it('reaps a child whose session/new response omits the session id', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-'))
const flushed = join(tmp, 'flushed')
try {
await expect(startAcpRun(request(), {
command: process.execPath,
args: [mockServer],
cwd: process.cwd(),
permission: 'reject',
env: {
MOCK_MISSING_SESSION_ID: '1',
MOCK_FLUSH_ON_EOF: flushed,
MOCK_FLUSH_DELAY_MS: '20',
},
disposeEofGraceMs: 1000,
disposeGraceMs: 100,
})).rejects.toThrow('ACP child published without a session id')
// Startup rejects only after its private child reaches quiescence. The
// marker proves rollback closed stdin and allowed the child's EOF flush.
expect(existsSync(flushed)).toBe(true)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
// The child traps SIGTERM and keeps its event loop alive, so a graceful
// term alone would hang dispose forever. With a short grace, dispose must