6801130f8c
Two lifecycle findings from the review: - A (blocker): dispose() could hang forever. It only sent SIGTERM and awaited exit, with no escalation — a child that traps SIGTERM (or our acp-agent if it doesn't quiesce on stdin EOF) would wedge dispose, stranding tool-subagent's finally cleanup and orphaning child-owned work (e.g. bash subprocesses). dispose now: ends stdin (graceful ACP close so the child can flush + exit), SIGTERM, then escalates to SIGKILL if it doesn't exit within a grace period (DEFAULT_DISPOSE_GRACE_MS, injectable via spec.disposeGraceMs), awaiting the certain exit. Mirrors the bash executor's bounded teardown. Regression test drives a SIGTERM-trapping mock subprocess and asserts dispose returns promptly — proven to hang (red) without the escalation. - B: an already-aborted request still spawned the configured binary. startAcpRun now returns an inert already-aborted run BEFORE spawning, so a pre-cancelled request launches nothing. Test points the command at `touch <sentinel>` and asserts the sentinel never appears. The dispose regression test exposed (via systematic-debugging) that the child must signal trap-armed readiness before the test cancels — a bare timeout raced the trap install and the default SIGTERM handler killed the child, making the guard a no-op. The mock now touches its ready file once the trap is in place and the test waits on that condition. The `cancelled` flag moved onto a holder object so TS control-flow doesn't narrow the catch-time read to always-false.
165 lines
7.4 KiB
TypeScript
165 lines
7.4 KiB
TypeScript
/**
|
|
* 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_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).
|
|
*
|
|
* It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the
|
|
* child process the ACP backend drives. Kept as a `.ts` run under tsx by the
|
|
* spec (which passes its own tsconfig), mirroring how the snapshot harness boots
|
|
* the real example.
|
|
*
|
|
* @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
|
|
*/
|
|
|
|
import { randomUUID } from 'node:crypto'
|
|
import { existsSync, writeFileSync } from 'node:fs'
|
|
import { Readable, Writable } from 'node:stream'
|
|
import {
|
|
AgentSideConnection,
|
|
ndJsonStream,
|
|
PROTOCOL_VERSION,
|
|
type Agent,
|
|
type CancelNotification,
|
|
type AuthenticateRequest,
|
|
type InitializeRequest,
|
|
type InitializeResponse,
|
|
type NewSessionRequest,
|
|
type NewSessionResponse,
|
|
type PromptRequest,
|
|
type PromptResponse,
|
|
type StopReason,
|
|
} from '@agentclientprotocol/sdk'
|
|
|
|
const TEXT = process.env.MOCK_TEXT ?? 'mock child answer'
|
|
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'
|
|
const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1'
|
|
const THOUGHT = process.env.MOCK_THOUGHT === '1'
|
|
const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1'
|
|
const READY_FILE = process.env.MOCK_READY_FILE
|
|
// When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks
|
|
// until GO appears — letting a test cancel mid-newSession deterministically.
|
|
const NEWSESSION_GATE = process.env.MOCK_NEWSESSION_READY !== undefined && process.env.MOCK_NEWSESSION_GO !== undefined
|
|
? { ready: process.env.MOCK_NEWSESSION_READY, go: process.env.MOCK_NEWSESSION_GO }
|
|
: undefined
|
|
|
|
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
|
|
|
|
return {
|
|
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
|
return Promise.resolve({
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } },
|
|
authMethods: [],
|
|
})
|
|
},
|
|
async newSession(_params: NewSessionRequest): Promise<NewSessionResponse> {
|
|
// 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.
|
|
if (NEWSESSION_GATE !== undefined) {
|
|
writeFileSync(NEWSESSION_GATE.ready, 'at-newSession')
|
|
while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10))
|
|
}
|
|
return { sessionId: randomUUID() }
|
|
},
|
|
authenticate(_params: AuthenticateRequest): Promise<void> {
|
|
// No auth methods advertised; nothing to do.
|
|
return Promise.resolve()
|
|
},
|
|
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
|
if (WANT_PERMISSION) {
|
|
// Ask the client to approve before answering; honor its decision. Under
|
|
// MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy
|
|
// client finds no allow option and must fall back to cancelled.
|
|
const options = NO_ALLOW
|
|
? [{ optionId: 'no', name: 'Reject', kind: 'reject_once' as const }]
|
|
: [
|
|
{ optionId: 'yes', name: 'Allow', kind: 'allow_once' as const },
|
|
{ optionId: 'no', name: 'Reject', kind: 'reject_once' as const },
|
|
]
|
|
const decision = await conn.requestPermission({
|
|
sessionId: params.sessionId,
|
|
toolCall: { toolCallId: 'mock-call', title: 'mock side effect' },
|
|
options,
|
|
})
|
|
if (decision.outcome.outcome === 'cancelled') {
|
|
return { stopReason: 'cancelled' }
|
|
}
|
|
}
|
|
// Optionally emit a NON-message update first (a thought), so the client's
|
|
// sessionUpdate sees an update it must consume-but-not-accumulate.
|
|
if (THOUGHT) {
|
|
await conn.sessionUpdate({
|
|
sessionId: params.sessionId,
|
|
update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking…' } },
|
|
})
|
|
}
|
|
// Stream the canned assistant text as one chunk.
|
|
await conn.sessionUpdate({
|
|
sessionId: params.sessionId,
|
|
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 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
|
|
// before cancelling — deterministic regardless of subprocess cold-start.
|
|
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ready')
|
|
if (HANG) {
|
|
// Never resolve on our own: wait for session/cancel to settle us.
|
|
return new Promise<PromptResponse>((resolve) => {
|
|
resolveCancel = (reason) => { resolve({ stopReason: reason }) }
|
|
})
|
|
}
|
|
return { stopReason: STOP }
|
|
},
|
|
cancel(_params: CancelNotification): Promise<void> {
|
|
if (CRASH_ON_CANCEL) {
|
|
// Exit hard instead of answering — tears the ACP pipe, so the client's
|
|
// pending prompt REJECTS (exercises the backend's catch-while-cancelled
|
|
// path: a transport failure after a cancel settles `aborted`).
|
|
process.exit(1)
|
|
}
|
|
resolveCancel?.('cancelled')
|
|
return Promise.resolve()
|
|
},
|
|
}
|
|
}
|
|
|
|
new AgentSideConnection(
|
|
makeAgent,
|
|
ndJsonStream(
|
|
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
|
|
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
|
|
),
|
|
)
|
|
|
|
// 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.
|
|
setInterval(() => { /* stay alive until SIGKILL */ }, 1000)
|
|
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed')
|
|
}
|
|
|