Merge PR #224 updates into prose cleanup

This commit is contained in:
Tianyi Cui
2026-07-12 23:36:49 +08:00
165 changed files with 11693 additions and 6395 deletions

View File

@@ -36,7 +36,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
## Multi-session
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there.
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so agent-scoped approval events demultiplex in O(1). Every `session/event` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there.
## Session config options
@@ -71,7 +71,7 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal
## Settle-exactly-once
A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
A `session/prompt` resolves or rejects exactly once from the canonical `session/event` stream. The listener captures the prompt's owning turn from `turn/start` and settles in a `finally` block when the matching `turn/end` is appended, so a presentation/streaming failure cannot strand the RPC after the durable terminal event exists. Correlation by turn id prevents a late end from a cancelled prompt from settling its successor. A turn ending in `error` rejects the RPC with an internal error carrying the failure message because ACP has no error stop reason; every other reason resolves through the codec. An empty or whitespace-only prompt is rejected before enqueue because it would start no turn and otherwise leave the RPC pending.
## Permission prompts

View File

@@ -1,7 +1,37 @@
/**
* The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that exposes the harness
* agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive
* it. The structured analogue of the readline `stdio-chat` plugin.
* The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that
* exposes the harness agent as an ACP server over JSON-RPC stdio, so editors
* (Zed and other ACP clients) can drive it. The structured analogue of the
* readline `stdio-chat` plugin.
*
* This is NOT a loop change and NOT an ADR-0009 capability seam: it consumes
* the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory,
* and `dsh-session-persistence` (for `session/load`). It maps:
*
* - `initialize` → protocol-version negotiation, text-only capabilities
* - `session/new` → `ctx.agents.create({ sessionId, meta:{cwd} })`
* - `session/load` → `ctx.agents.resume(...)` then replay the event log
* - `session/prompt` → `agent.send()`, settle on the owning turn's end (a turn
* that ends in `error` rejects the RPC)
* - `session/cancel` → `agent.cancel()` (the queue-aware cancel: aborts a
* running step, clears queued + steering work, and drops a
* turn about to start) + settle the in-flight prompt
*
* Multi-session (RFC 011): N concurrent sessions per connection, each mapped to
* its own `ReactLoopAgent`. Sessions are keyed by id in `sessions` (forward) with an
* `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
* `session/event` and `agent/*` event is routed strictly to its owning session
* record, so two sessions streaming at once never interleave their
* `session/update` notifications. Permission prompts ride the same ownership
* map: the bridge answers `approval/request` for its own agents over
* `session/request_permission` (see the approval answerer below) — whether a
* call ASKS is policy (a hook or plugin returning `ask`), not the bridge's.
*
* stdout is the protocol: this plugin must run in an example that loads NO
* stdout logger (the console logger writes to stdout and would corrupt the
* JSON-RPC frames). The guarantee is config-only — see the package README and
* RFC 010 § Risks.
*
* @module @deepseek-ai/dsh-acp
*/
@@ -41,7 +71,7 @@ import {
} from '@agentclientprotocol/sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
@@ -72,7 +102,11 @@ import {
} from './codec.ts'
export const name = 'acp'
// Persistence enables loadSession; tools own call and result rendering.
// The bridge programs against the interface packages only (architecture rule:
// plugins never depend on dsh-agent-loop). `sessionPersistence` is required
// because `initialize` advertises `loadSession: true`. `tools` lets a tool own
// how its calls render (`presentCall`/`presentResult`); the bridge looks up the
// definition by name and falls back to a generic presentation when absent.
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
/**
@@ -261,28 +295,54 @@ interface SessionRecord {
*/
terminalEnabled: boolean
/**
* The in-flight `session/prompt`, or `undefined` when none is pending. A prompt resolves
* with a {@link StopReason} or rejects with an Error (a turn that ended in failure). Settled
* exactly once via {@link settlePrompt}.
* The in-flight `session/prompt`, or `undefined` when none is pending. A
* prompt resolves with a {@link StopReason} or rejects with an Error (a
* turn that ended in failure). Settled exactly once by its matching
* `turn/end`, direct cancellation, or teardown.
*
* `turn` is the loop turn number this prompt owns, captured from the log's
* `turn/start` after `send()`. Until then it is `undefined` (the turn has not
* begun). Only a `turn/end` whose turn number equals `turn` settles the prompt
* — so a *previous* prompt's late `turn/end` (e.g. an aborted turn whose end
* arrives after the next prompt is already installed) can never settle the
* wrong prompt. A direct cancel/dispose settle clears the whole in-flight slot,
* so a later stale `turn/end` finds no pending prompt.
*
*/
inflight: {
resolve: (reason: StopReason) => void
reject: (error: Error) => void
turn: number | undefined
logWatermark: number
} | undefined
/**
* Config switches accepted while the session was IDLE, not yet anchored in its log.
* Config switches accepted while the session was IDLE, not yet anchored in
* its log. The turn-enclosure contract makes a bare between-turns append
* invalid (the JSONL backend treats a post-`turn/end` tail as crash
* garbage, and dev invariants throw), so an idle switch waits here and is
* anchored at the next turn's prompt-submit — before anything in that
* turn assembles a prompt or runs a call, and last write
* per knob wins (an idle flip-flop anchors as one event). Until anchored,
* the switch lives only in bridge memory: the set/new/load responses
* overlay it truthfully, and a restart before the next turn reverts it —
* which `session/load` then reports honestly from the log's fold.
*/
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
}
/**
* Drive the in-flight prompt's settle from the harness event stream.
* Drive the in-flight prompt's settle from the harness event stream. The bridge
* settles off the durable `turn/end` event for the prompt's own turn. Session
* contains post-commit observers independently, and this listener performs
* correlation in a `finally` so presentation failure cannot starve settlement.
*/
export function apply(ctx: Context, config: AcpConfig): void {
// Capture the injected services NOW, during apply(), while we are inside this plugin's fiber
// (where `inject` grants access).
// Capture the injected services NOW, during apply(), while we are inside this
// plugin's fiber (where `inject` grants access). The ACP method handlers run
// LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is
// NOT this fiber's injection scope — so reading `ctx.agents` / `ctx.logger` /
// `ctx.sessionPersistence` lazily inside a handler throws "cannot get property
// … without inject". Resolving the references here and closing over them keeps
// the handlers working regardless of which fiber later invokes them.
const agents = ctx.agents
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
@@ -292,16 +352,25 @@ export function apply(ctx: Context, config: AcpConfig): void {
// this warn sink so a throwing tool presenter is logged, not propagated.
const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
// Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId reverse map so
// `agent/*` events (which carry only the Agent) demux in O(1).
// Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId
// reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
// The two stay in lockstep: a record is added to `sessions` and the agent to
// `bySession` together, and removed together.
const sessions = new Map<SessionId, SessionRecord>()
const bySession = new WeakMap<Agent, SessionId>()
// Session ids whose `session/load` is mid-`resume()` (the slot is reserved before the async
// resume so a pipelined load/new for the same id can't create two agents).
// Session ids whose `session/load` is mid-`resume()` (the slot is reserved
// before the async resume so a pipelined load/new for the SAME id can't create
// two agents). Distinct ids load concurrently; a given id loads once at a time.
const loadingIds = new Set<SessionId>()
// Set once the bridge has torn down (disposal or client disconnect).
// Set once the bridge has torn down (disposal or client disconnect). An async
// `session/load` mid-`resume()` when teardown ran must observe this after its
// await and NOT install a record (which would resurrect a live agent/listeners
// after the bridge closed). Checked after every load await.
let closed = false
// Whether the client advertised the Zed `_meta.terminal_output` capability in `initialize`.
// Whether the client advertised the Zed `_meta.terminal_output` capability in
// `initialize`. When true, a tool's terminal presentation is rendered as a
// terminal card (content + `_meta.terminal_*`); when false, the bridge uses
// the tool's text fallback. Set once in `initialize`, read on every tool event.
let terminalOutputCap = false
// Assigned at the bottom, before any agent event can fire (a session only
@@ -369,7 +438,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
/** Push a `session/update` notification, swallowing post-close rejections. */
const notify = (notification: SessionNotification): void => {
// sessionUpdate returns a promise; a closed connection rejects it.
// sessionUpdate returns a promise; a closed connection rejects it. The
// update is best-effort UI feed, never load-bearing for correctness, so a
// throwing/rejecting send must not break the turn (the chunk is emitted
// inside the model step — see docs/defensive-patterns.md "contain callback exceptions").
/* v8 ignore next 3 -- the rejection only fires on a stdout/connection write
failure (closed pipe), which the in-memory test transport never induces;
the swallow is a defensive best-effort guard like the loop's emit traps */
@@ -400,74 +472,56 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- Stream the harness event taxonomy to ACP session/update --------------
// All content streaming AND the prompt settle flow through `session/event`, the canonical
// log: every assistant/chunk and tool/call/result is logged, so translating from the log
// makes live streaming and `session/load` replay share the identical path
// (streamSessionEventUpdate).
// All content streaming AND the prompt settle flow through `session/event`,
// the canonical log: every assistant/chunk and tool/call/result is logged, so
// translating from the log makes live streaming and `session/load` replay
// share the identical path (streamSessionEventUpdate). Both the owning-turn
// capture and the settle key off the log's own `turn/start`/`turn/end` — the
// durable boundary events (there is no agent/* turn mirror). `closeTurn`
// appends `turn/end` to the log unconditionally, and `turn/start` is appended
// before any step runs, so within this one listener we always see the
// prompt's turn-start (tag `inflight.turn`) then its turn-end (settle). A
// `turn/end` settles the prompt ONLY when it is the prompt's OWN turn
// (`inflight.turn === event.data.turn`) — a previous, already-cancelled turn
// whose end arrives late is ignored (see
// SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
// has no error stop reason); other reasons resolve via the codec. Demux
// strictly by session id: a `session/event` is routed to its own record, so
// two sessions streaming at once never cross-settle or interleave updates.
ctx.on('session/event', (session, event: SessionEvent) => {
const rec = sessions.get(session.header.id)
if (rec === undefined) return
streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
enabled: rec.terminalEnabled,
cwd: session.header.cwd,
}, { includeUserMessages: false })
const inflight = rec.inflight
if (inflight === undefined) return
if (event.type === 'turn/start') {
// Tag the in-flight prompt with its owning turn — but only a `message`-triggered turn
// (the kind a `send()` prompt produces).
if (inflight.turn === undefined && event.data.trigger.kind === 'message') {
inflight.turn = event.data.turn
try {
streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
enabled: rec.terminalEnabled,
cwd: session.header.cwd,
}, { includeUserMessages: false })
} finally {
const inflight = rec.inflight
if (inflight !== undefined && event.type === 'turn/start') {
// The first message-triggered turn after prompt installation owns the
// prompt; injection-triggered turns must not settle it early.
if (inflight.turn === undefined && event.data.trigger.kind === 'message') {
inflight.turn = event.data.turn
}
} else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
rec.inflight = undefined
settleFromTurnEnd(inflight, event.data.reason)
}
return
}
// Settle only on the OWNING turn's end.
if (event.type !== 'turn/end' || inflight.turn !== event.data.turn) return
rec.inflight = undefined
settleFromTurnEnd(inflight, event.data.reason)
})
// Settle fallback: a `session/event` listener registered before ACP that throws (on
// `turn/start` OR `turn/end`) would, via cordis `emit`'s stop-on-throw, starve ACP's listener
// above — the prompt would hang or, if only the turn number was missed, settle as the wrong
// outcome.
const settleFromLog = (rec: SessionRecord): void => {
const inflight = rec.inflight
if (inflight === undefined) return
const events = rec.agent.session.events
// The owning turn number: the captured one, or — if the live capture was starved — inferred
// from the log as the first MESSAGE-triggered turn opened at/after the watermark.
const owningTurn = inflight.turn ?? events.slice(inflight.logWatermark).find(
(e): e is Extract<SessionEvent, { type: 'turn/start' }> =>
e.type === 'turn/start' && e.data.trigger.kind === 'message',
)?.data.turn
// The owning turn's end in the log.
const end = events.findLast(
(e): e is Extract<SessionEvent, { type: 'turn/end' }> =>
e.type === 'turn/end' && e.data.turn === owningTurn,
)
rec.inflight = undefined
if (end === undefined) {
// No owning turn / no clean turn/end (torn down mid-turn) → cancelled.
inflight.resolve('cancelled')
return
}
settleFromTurnEnd(inflight, end.data.reason)
}
// On a settle to idle/disposed, reconcile any still-pending prompt from the log (covers a
// starved `session/event` listener — see settleFromLog).
ctx.on('agent/status', (agent, status: AgentStatus) => {
const sessionId = bySession.get(agent)
if (sessionId === undefined) return
const rec = sessions.get(sessionId)
if (rec === undefined) return
if (status === 'idle' || status === 'disposed') settleFromLog(rec)
})
// --- Approval answerer The bridge is the approval channel for the agents it owns: an `ask`
// routed through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes an editor
// permission prompt attached to the already-streamed tool call.
// --- Approval answerer -----------------------------------------------------
// The bridge is the approval channel for the agents it owns: an `ask` routed
// through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes
// an editor permission prompt attached to the already-streamed tool call. The
// listener occupies the single decision slot ONLY for its own agents — a
// foreign or call-less request delegates via next() so another answerer (or
// the fail-closed `unavailable` default) takes the question. A rejected
// `requestPermission` (client gone, bridge torn down) propagates and the
// ApprovalService contains it as `unavailable`. Options are one-shot only:
// allow_always is a grant-storage design the approval RFC defers, so the
// prompt never offers a durable grant the harness could not honor.
ctx.on('approval/request', (req, next) => {
const sessionId = bySession.get(req.agent)
// The protocol requires `toolCall` (the prompt renders attached to it), so
@@ -491,11 +545,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- The ACP Agent method surface -----------------------------------------
/**
* The session config options this composition can honor, with current values folded from the
* AGENT'S own session log (`effectiveSandboxMode` / `effectiveApprovalPolicy` — the log is
* the per-session store, so a `session/load` reports a resumed session's overrides with no
* catch-up machinery), overlaid with the record's not-yet-anchored pending switches (see
* {@link SessionRecord.pendingSwitches}).
* The session config options this composition can honor, with current
* values folded from the AGENT'S OWN session log (`effectiveSandboxMode` /
* `effectiveApprovalPolicy` — the log is the per-session store, so a
* `session/load` reports a resumed session's overrides with no catch-up
* machinery), overlaid with the record's not-yet-anchored pending switches
* (see {@link SessionRecord.pendingSwitches}). Capability-gated like every
* advertised lever: the sandbox option exists only when the mounted
* executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval
* option only when the approval seam is composed — both read
* opportunistically so this bridge keeps working in compositions without
* them.
*/
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
const options: SessionConfigOption[] = []
@@ -565,7 +625,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
}
// Anchor idle switches during prompt-submit so persistence observes ordered in-turn events.
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
// is open (the seam fires inside it, per drained message — the first flush
// empties the slot, later ones no-op), the loop has not yet assembled
// anything for it, and — unlike appending from inside a `session/event`
// listener — this seam fires OUTSIDE any log emit, so peer listeners
// (the dev invariants, persistence) observe the anchored events in strict
// log order. A turn with no prompt (an idle inject's one-shot injection
// turn) leaves the switch pending — it runs no step, so nothing executes
// or assembles under a stale value.
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
const sessionId = bySession.get(agent)
const rec = sessionId === undefined ? undefined : sessions.get(sessionId)
@@ -618,7 +686,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
// Creation is now asynchronous because it awaits the unpublished setup transaction.
// Creation is now asynchronous because it awaits the unpublished setup
// transaction. A client disconnect can therefore close this bridge
// after the entry check but before the handle resolves; never install a
// post-close record that quiesce() could not have seen.
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC
immediately on close; real stdio may let the handler resume */
if (closed) {
@@ -649,13 +720,25 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
validateWorkspaceParams(params)
validateMcpServers(params)
// Reserve this id's load slot before the await.
// Reserve THIS id's load slot BEFORE the await. Without it, two pipelined
// loads for the same id could both pass the guard above while the first
// resume() is pending, then both install a record and leak a second
// agent. (Distinct ids load concurrently — the set is keyed by id.) The
// slot is released in `finally` so a rejected load never wedges the id.
loadingIds.add(sessionId)
try {
// Validate the persisted cwd before resuming — `list()` is a metadata-only read (no
// full-log parse), so this rejects a session we can't honor WITHOUT ever
// constructing/registering an agent (a post-resume reject would leak the registered
// agent — cancel() does not unregister it — and wedge the id against re-load).
// Validate the PERSISTED cwd BEFORE resuming — `list()` is a
// metadata-only read (no full-log parse), so this rejects a session we
// can't honor WITHOUT ever constructing/registering an agent (a
// post-resume reject would leak the registered agent — cancel() does not
// unregister it — and wedge the id against re-load). The session's bash
// workdir is derived from its persisted `header.cwd` and the request
// `cwd` does NOT override it (resume takes no cwd), so a session with no
// absolute persisted cwd would silently run bash in the SERVER's launch
// dir, not the client's workspace. A session created by this bridge
// always has a cwd (session/new requires it); reject the rest loudly.
// (An id unknown to `list()` falls through to resume, which rejects with
// the backend's not-found error.)
const meta = (await sessionPersistence.list()).find(m => m.id === sessionId)
if (meta !== undefined) {
const persistedCwd = meta.cwd
@@ -673,8 +756,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
resumeSessionId: sessionId,
agentOptions: agentOptions(config),
})
// The bridge may have torn down (disposal / client disconnect) while resume() was
// pending.
// The bridge may have torn down (disposal / client disconnect) while
// resume() was pending. Its listeners are gone, so installing a record
// now would resurrect a live agent the bridge can no longer drive. Bail —
// and tear down the just-resumed agent (unregister + stop + remove its
// session) before throwing, so it does not leak: it has no SessionRecord,
// so quiesce() would never see it.
/* v8 ignore next 4 -- the in-memory test transport rejects the in-flight
session/load request the instant it closes (before this post-await
code runs), so the guard can't be hit in tests; it protects the real
@@ -699,7 +786,19 @@ export function apply(ctx: Context, config: AcpConfig): void {
pendingSwitches: {},
}
sessions.set(sessionId, record)
// Replay the persisted event log to the client as session/update.
// Replay the persisted event log to the client as session/update. Use
// the raw event log (NOT deriveMessages, which drops assistant/chunk
// and trace events): RFC 010's load contract reconstructs the streamed
// turns — user prompts (user/message → user_message_chunk), assistant
// text and reasoning (assistant/chunk), and tool calls/results.
//
// Replay through a THROWAWAY presenter, NOT `record.presenter`: a
// historical turn that was interrupted mid-tool (a `tool/call` with no
// matching `tool/result` in the persisted log) would otherwise leave a
// stale in-flight entry on the live presenter, which then serves all
// future live events for this session. The throwaway pairs call→result
// as the log replays in order (same as live) and is discarded after,
// so the record's presenter starts clean for the post-load live stream.
const replayPresenter = makePresenter(agent)
const replayTerminal: TerminalRendering = {
enabled: terminalEnabled,
@@ -731,10 +830,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
// waiting for a settle that never comes.
throw invalidParams('empty prompt')
}
// Install the in-flight slot before send() (send does not synchronously flip status to
// running; the session/event listener records the turn number and settle/rejects it).
// Install the in-flight slot BEFORE send() (send does not synchronously
// flip status to running; the session/event listener records the turn
// number and settle/rejects it). Capture the log length now as the
// A turn that ends in error rejects this promise (the codec never
// produces an error stop reason).
const stopReason = await new Promise<StopReason>((resolve, reject) => {
rec.inflight = { resolve, reject, turn: undefined, logWatermark: rec.agent.session.events.length }
rec.inflight = { resolve, reject, turn: undefined }
rec.agent.send([{ type: 'text', text }])
})
return { stopReason }
@@ -743,7 +845,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
cancel(params: CancelNotification): Promise<void> {
const rec = sessions.get(SessionId(params.sessionId))
if (rec === undefined) return Promise.resolve()
// Queue-aware cancellation drops pending prompts as well as the active step.
// session/cancel maps to the queue-aware agent.cancel(reason): it aborts
// a RUNNING step, clears the queued + steering FIFOs, and drops a
// turn that is about to start (the pre-step window) — so a queued-but-
// not-yet-started prompt never runs, and a prompt accepted right after
// cannot be batched into the cancelled turn. Scoped to THIS session's
// agent — a cancel in one session never touches another's stream or
// pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt
// as cancelled directly here: do NOT rely on the resulting turn/end to
// settle it, because cancel() may drop the turn before any turn/end is
// emitted, and removing this direct settle would move the RPC's
// resolution onto a later observer path, changing its timing.
rec.agent.cancel('session/cancel')
settlePrompt(rec, 'cancelled')
return Promise.resolve()
@@ -757,10 +869,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (typeof params.value !== 'string') {
throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`)
}
// The setters append one log-only event on this session's own log — the log is the
// store (the sandbox RFC § Per-session mode switching): execution, the prompt section,
// and the narrator all fold it from there, and a resumed session reports the override
// back through configOptionsFor.
// The setters append ONE log-only event on this session's own log —
// the log is the store (the sandbox RFC § Per-session mode switching): execution, the
// prompt section, and the narrator all fold it from there, and a
// resumed session reports the override back through
// configOptionsFor. A switch while a turn is OPEN anchors
// immediately (the next step sees it); an IDLE switch waits in
// pendingSwitches for the next `turn/start` (turn-enclosure: a bare
// between-turns append would be dropped as crash tail on reload).
// Values are validated against the same closed lists the options
// advertised; an id this composition never advertised (or an unknown
// one) rejects.
switch (params.configId) {
case 'sandbox-mode': {
const defaultMode = ctx.get('bash')?.sandboxMode
@@ -802,7 +921,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- Connection lifecycle --------------------------------------------------
// The transport stream.
// The transport stream. Production wires stdio (stdout carries the protocol);
// tests inject an in-memory pipe pair via config.stream to drive the bridge
// without a subprocess. ndJsonStream is the SDK's stdio framing helper. The
// AgentSideConnection constructor synchronously invokes makeAgent (assigning
// the outer `conn`), so `conn` is set before any agent method runs.
/* v8 ignore next 4 -- production stdio wiring; tests always inject config.stream */
const stream: Stream = config.stream ?? ndJsonStream(
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
@@ -812,11 +935,29 @@ export function apply(ctx: Context, config: AcpConfig): void {
/**
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
* quiescence"): for each session settle any pending prompt `cancelled`, then run that
* session's {@link AgentHandle} `dispose()` — which stops the loop (sets `disposed`, aborts
* the in-flight step), AWAITS the loop's exit (the final `turn/end` + `session/flush` are
* captured while `onAppend` is still attached), unregisters the agent, and removes its
* session from the store.
* quiescence"): for each session settle any pending prompt `cancelled`, then
* run that session's {@link AgentHandle} `dispose()` — which stops the loop
* (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the
* final `turn/end` + `session/flush` are captured while the store-owned publication hooks are still
* attached), unregisters the agent, and removes its session from the store.
* The per-session disposes run in parallel. Idempotent — clears the `sessions`
* map first and memoizes, so a second call (close racing dispose) is a no-op.
* Shared by Cordis disposal AND client disconnect (`conn.closed`).
*
* Per-agent disposal closes the queued-before-run window through the DISPOSED
* path, not `cancel()`: the start-disposer resolves `handle.disposed`, which
* wakes the parked loop, and `isDisposed()` breaks the loop before a
* queued-but-not-yet-running turn can start (a turn cut off mid-flight ends
* with reason `disposed`, not `aborted`). A bare client disconnect (resolves
* `conn.closed` WITHOUT disposing the fiber) thus leaves NO registered agent
* and NO session-store entry — not an idled-but-still-registered one. When the
* fiber IS disposed (whole-context or an ACP-only HMR
* `acpFiber.dispose()`), this same memoized teardown runs first; the factory's
* register+start+session effects are ALSO bound to the bridge fiber (the
* factory is reached through this bridge's traceable service proxy, so
* `AgentLoop.start`'s `this.ctx.effect(...)` binds to the CALLER context — the
* bridge fiber), so any agent this path did not reach is still reclaimed by
* fiber disposal.
*/
let quiescing: Promise<void> | undefined
const quiesce = (): Promise<void> => {
@@ -845,9 +986,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
return quiescing
}
// Client disconnect: when the ACP transport closes (editor quits, pipe EOF), the in-flight
// turn would otherwise keep running and its `session/update` writes would be silently
// swallowed by `notify()`.
// Client disconnect: when the ACP transport closes (editor quits, pipe EOF),
// the in-flight turn would otherwise keep running and its `session/update`
// writes would be silently swallowed by `notify()`. Tear the session down so
// a vanished client does not leave an orphaned running agent. `conn.closed`
// rejects/resolves once; contain any teardown throw (nothing else can act on
// it — the connection is already gone). The Cordis disposer below still runs
// on normal shutdown and is idempotent with this.
/* v8 ignore start -- the .catch arrow is a defensive guard: conn.closed
settling rejected or quiesce() throwing on an already-closed connection is
not reproducible through the in-memory test transport (it never severs
@@ -875,9 +1020,22 @@ export function agentOptions(config: AcpConfig): { model?: string } {
}
/**
* Validate the `cwd`/`additionalDirectories` contract shared by `session/new` and
* `session/load`: `cwd` must be absolute (a relative path would be ambiguous as a workspace
* root).
* Validate the `cwd`/`additionalDirectories` contract shared by `session/new`
* and `session/load`: `cwd` must be absolute (a relative path would be ambiguous
* as a workspace root). The persisted-cwd equality check for `session/load`
* happens after the metadata lookup; this validator only enforces request shape:
* - `session/new`: the validated `cwd` becomes the session's `SessionHeader.cwd`
* (via `agents.create({meta:{cwd}})`) and thus the default bash workdir.
* - `session/load`: the request `cwd` must be absolute AND must match the
* PERSISTED `header.cwd`, which stays authoritative for the bash workdir —
* the request cwd does not override it.
* Any absolute path is accepted (the per-session cwd flows to the bash executor
* — see `dsh-tool-bash`), so the server no longer has to launch in the
* workspace. `additionalDirectories` must still be empty: widening the
* tool/filesystem scope beyond the single cwd is a separate, unimplemented
* concern (a sandbox seam), and silently ignoring extra roots would desync the
* client's filesystem-scope UI. Both request shapes carry `cwd: string` and
* `additionalDirectories?: string[]`, so one validator covers both.
*/
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void {
if (!isAbsolute(params.cwd)) {
@@ -895,13 +1053,38 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
}
/**
* Translate one session event into zero or more ACP updates.
* Translate a single harness {@link SessionEvent} into the `session/update`
* notification(s) it produces, pushing each via `notify`. Shared by live
* streaming (`session/event`) and `session/load` replay so both paths emit an
* identical update stream from the same event log.
*
* - `assistant/chunk` text-delta/reasoning-delta → message/thought chunks
* - `user/message` → `user_message_chunk` during load replay only — so a
* loaded transcript reconstructs the USER side of each turn without echoing
* a live `session/prompt` back to the client
* - `tool/call` → `tool_call` (pending)
* - `tool/result` → `tool_call_update` (completed/failed)
*
* Tool-call presentation (title/kind/rawInput, and the completed-state content)
* is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
* special-cases tool names. `presenter` resolves those from the tool registry
* and remembers each call's `(name, args)` so the completed `tool/result` (which
* carries neither) can find its tool. A {@link nullToolPresenter} gives the
* generic fallback (title = tool name, raw args as input) when no registry is
* available (e.g. pure translator tests).
*
* Other event types (turn/step boundaries, context/message, …) produce
* no client update.
* @param sessionId - the ACP session id stamped on every emitted notification.
* @param event - the harness session event to translate.
* @param notify - best-effort update sink.
* @param presenter - tool render resolver; defaults to generic presentation.
* @param terminal - terminal rendering context; disabled by default.
* @param options - controls replay of user messages.
* @param notify - sink for each produced `session/update` notification; called
* zero or more times per event (best-effort UI feed, never load-bearing).
* @param presenter - resolves tool-owned render intent for tool events;
* defaults to the generic-fallback {@link nullToolPresenter}.
* @param terminal - the connection's terminal-rendering context; defaults to
* disabled (the plain-text console-block fallback).
* @param options - `includeUserMessages` (default `true`): live streaming
* passes `false` so a prompt the client just sent is not echoed back.
*/
export function streamSessionEventUpdate(
sessionId: SessionId,
@@ -988,11 +1171,26 @@ export interface TerminalRendering {
const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
/**
* Resolves tool-owned presentation for a session's tool-call events. A tool declares
* `presentCall`/`presentResult` (see `dsh-tools`) returning a `card`-tagged {@link
* ToolCallView}/{@link ToolResultView}; this looks them up by name in the registry and applies
* a generic fallback when a tool defines neither. The returned view is what {@link
* streamSessionEventUpdate} switches on.
* Resolves tool-owned presentation for a session's tool-call events. A tool
* declares `presentCall`/`presentResult` (see `dsh-tools`) returning a
* `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up
* by name in the registry and applies a generic fallback when a tool defines
* neither. The returned view is what {@link streamSessionEventUpdate} switches on.
*
* The `tool/result` session event does NOT carry the tool name or args — so to
* call a tool's `presentResult` (which needs both), the presenter remembers each
* `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the
* matching result. The map is bridge-LOCAL (not a change to the event schema or a
* core service): one presenter per live session
* (and a throwaway per `session/load` replay), and each entry is removed when its
* result arrives. In the normal loop a `tool/call` is always followed by a
* `tool/result` (the registry turns even a thrown tool into an isError result),
* so the map holds only currently-in-flight calls. The one exception is a step
* torn down mid-tool (an abort between `tool/call` and `tool/result`), which can
* leave a single stale entry per such call; this is bounded by the session
* lifetime (the whole presenter is dropped on teardown) and never affects
* correctness — a later result for a different callId is unaffected, and the
* stale entry's only cost is one map slot until the session ends.
*/
export class ToolPresenter {
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
@@ -1037,38 +1235,49 @@ export class ToolPresenter {
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
present = undefined
}
// No tool-owned presentation: fall back to the tool name as the title, the full parsed args
// as the raw input, and kind `other` (the generic card).
// No tool-owned presentation: fall back to the tool name as the title, the
// full parsed args as the raw input, and kind `other` (the generic card).
// The kind is never sniffed from the name — the bridge does not special-case
// tool names; a tool that wants a richer kind declares `presentCall`.
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
this.pending.set(callId, { name, args, card: view.card })
return view
}
/**
* Resolve completed presentation from the remembered tool call.
* @param callId - matching call id; unknown ids use raw content.
* @param content - fallback result content.
* @param isError - result error flag.
* @param meta - optional tool metadata.
* @returns tool-owned view or normalized generic fallback.
* Completed-state render intent for a `tool/result`; consumes the remembered
* `(name, args, card)`.
* @param callId - the id of the matching `tool/call`; an unknown or late id
* falls back to the raw content.
* @param content - the result's content blocks (the fallback and fill-in body).
* @param isError - whether the result is an error, forwarded to `presentResult`.
* @param meta - the result's machine-readable meta, forwarded when present.
* @returns the tool-owned view — an orphaned `terminal` result (no terminal
* call side) and a content-less `generic` are normalized — or the raw-content
* generic card when the tool defines no `presentResult` or threw.
*/
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
const call = this.pending.get(callId)
this.pending.delete(callId)
// No remembered call (unknown/late callId) → nothing to present from; raw content.
if (call === undefined) return { card: 'generic', content }
let present: ToolResultView | undefined
try {
present = this.tools.get(call.name, this.agent)
?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} })
} catch (error: unknown) {
// Presentation failure falls back without breaking replay or streaming.
// A throwing presentResult must not break streaming/replay: log + fall back.
this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`)
present = undefined
}
if (present === undefined) return { card: 'generic', content }
// A terminal result requires a terminal call card.
// Orphan guard: only honor a `terminal` result when the PENDING call was a
// terminal. A result-only terminal with no matching call-side terminal would
// orphan `_meta.terminal_output` to a terminal Zed never made — drop it back
// to the raw content.
if (present.card === 'terminal' && call.card !== 'terminal') return { card: 'generic', content }
// Preserve raw content when a generic presenter changes only metadata.
// A generic result that reformats no content keeps the RAW result content
// (the tool replaced only the title); fill it so the card is never blanked.
if (present.card === 'generic' && present.content === undefined) return { ...present, content }
return present
}
@@ -1116,14 +1325,24 @@ type AcpToolCallContent =
| { type: 'terminal'; terminalId: string }
/**
* Relativize a file card's TITLE path against the session workspace cwd, so a card reads `Read
* src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the reference ACP adapter's
* `toDisplayPath`.
* Relativize a file card's TITLE path against the session workspace cwd, so a
* card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the
* reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the
* card's `locations`/`diff` paths stay RAW (the editor opens the real path). The
* pure tool presenter can't see the session cwd, so this happens here where the
* bridge knows it. The rewrite is an exact substring replace of the known raw
* path (a card carries the same path in `locations[0]`/`diffs[0]`), never a
* heuristic. A path outside the workspace, or an absent/relative session cwd, is
* left unchanged.
*/
function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string {
if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
const rel = relativePath(sessionCwd, rawPath)
// Relativize only paths contained by the workspace; keep the workspace root absolute.
// Only relativize a target that stays INSIDE the workspace. `relative` prefixes
// a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone
// or `..<sep>…`), NOT a bare `..` char prefix, so a sibling like `..cache/x`
// (a real in-workspace name) still relativizes. Never relativize to the empty
// string (rawPath === cwd — a non-file target).
if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
return title.split(rawPath).join(rel)
}
@@ -1182,9 +1401,11 @@ function toolCallUpdate(callId: CallId, view: ToolCallView, terminal: TerminalRe
}
}
case 'terminal': {
// A terminal-rendered call gets a terminal CARD when the client supports it: the
// description renders ABOVE the card, then the terminal block, plus `_meta.terminal_info`
// (the cwd header).
// A terminal-rendered call gets a terminal CARD when the client supports it:
// the description renders ABOVE the card, then the terminal block, plus
// `_meta.terminal_info` (the cwd header). Without the capability it is an
// ordinary execute card whose body is the description and whose rawInput is
// the command; the output arrives as text on the result.
const asTerminal = terminal.enabled
const description: AcpToolCallContent[] = view.description !== undefined
? [{ type: 'content', content: { type: 'text', text: view.description } }]
@@ -1229,7 +1450,16 @@ function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExi
}
/**
* Build the `tool_call_update` (completed) `session/update` from a result render intent.
* Build the `tool_call_update` (completed) `session/update` from a result render
* intent. A `generic` result sends its reformatted content (or the raw result);
* a `terminal` result rides its output/exit on `_meta` when the client is capable
* (the terminal card consumes them and `content` is OMITTED — a
* `tool_call_update.content` REPLACES the call's content collection in Zed, so
* re-sending would clobber the terminal block the call installed) and otherwise
* derives the fenced ```console fallback from `output`. A `diff` result emits its
* `{ type: 'diff' }` content blocks (an applied hunk, or a whole-file diff for a
* create), which replace the diff the call installed — so the model-facing result
* text can never clobber it.
*/
function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate {
const status = isError ? 'failed' as const : 'completed' as const
@@ -1271,7 +1501,12 @@ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean
...view.title !== undefined ? { title: view.title } : {},
}
case 'diff': {
// Result diff content replaces the pending card's call-side diff.
// A result-time diff: emit one `{ type: 'diff' }` content block per entry
// (an applied hunk for an edit/overwrite, or a whole-file diff for a
// create), mirroring the call-side diff arm. `tool_call_update.content`
// REPLACES the call's content in an editor, so this result diff supersedes
// the diff the pending card installed (and keeps the model-facing result
// text from clobbering it).
const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText }))
// Relativize the replacement title against the session cwd from the diff
// path, exactly as the call-side card does — `tool_call_update.title`

View File

@@ -36,8 +36,10 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => {
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop stay up and the
// transport is still live.
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop
// stay up and the transport is still live. A late session/new must hit the
// `closed` guard and reject — NOT create an agent the disposed bridge can no
// longer stream or settle. Verify the world: no agent appeared.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
@@ -49,9 +51,14 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
// The factory (`ctx.agents.create`) is reached through the bridge's traceable service
// proxy, so `AgentLoop.start`'s `this.ctx.effect(...)` registration binds to the CALLER
// context — the bridge fiber — not the AgentLoop fiber.
// The factory (`ctx.agents.create`) is reached through the bridge's
// traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
// registration binds to the CALLER context — the bridge fiber — not the
// AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
// must therefore reclaim the agent's registry entry, even though agents/
// agent-loop stay up. This pins the fiber-ownership the bridge's teardown
// doc comment relies on; if a refactor rebinds the registration to the
// AgentLoop fiber, the agent would survive bridge dispose and this fails.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -63,8 +70,10 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
// After teardown (here a client disconnect sets `closed`), a late `session/new` must not
// create an orphan agent the bridge can no longer drive/settle.
// After teardown (here a client disconnect sets `closed`), a late
// `session/new` must NOT create an orphan agent the bridge can no longer
// drive/settle. The transport is gone so the RPC rejects; assert the world:
// no new agent appeared in the registry.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
@@ -76,7 +85,10 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => {
// The ACP transport closes (editor quits) while a turn runs.
// The ACP transport closes (editor quits) while a turn runs. The bridge must
// settle the in-flight prompt cancelled and DISPOSE the agent (the session's
// per-agent AgentHandle teardown) rather than leaving an orphaned running —
// or even idled-but-still-registered — agent whose updates are swallowed.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -94,7 +106,14 @@ describe('acp bridge — disposal & HMR safety', () => {
// The agent's loop has stopped: status `disposed`.
expect(agent.status).toBe('disposed')
// Await bridge quiescence without disposing root agent and session services.
// Await the bridge teardown to completion WITHOUT tearing down the root
// agents/sessions services (so we can still query them). acpFiber.dispose()
// invokes the SAME memoized quiesce() the disconnect started and awaits its
// promise — which resolves only after every rec.dispose() (loop exit +
// session removal) has finished, closing the whenIdle()/owned.dispose()
// microtask race. The AgentHandle dispose has run: the agent is unregistered
// and its session removed from the store, not merely idled (the old
// behavior). The services live on the root ctx, so they survive this.
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
@@ -103,6 +122,9 @@ describe('acp bridge — disposal & HMR safety', () => {
it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => {
// conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously.
// They must share one teardown promise: dispose() must NOT return before the
// disconnect teardown's whenIdle() has settled (a `record === undefined`-only
// guard would let the second caller return early mid-teardown).
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -135,10 +157,14 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop, AWAIT its exit (so
// the loop's final `turn/end` + `session/flush` fire through the still-attached
// `session.onAppend` → `session/event`), and only THEN detach onAppend + remove the
// session.
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
// AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
// through the still-attached store observer → `session/event`), and only
// THEN remove its publication hooks and session entry. If the order were inverted
// (detach first), the closing events would never reach persistence. Drive a
// CLEAN turn to completion, dispose JUST the bridge, then re-load the
// persisted log from disk and assert the closing turn/end is on disk — the
// world, not the agent's self-report.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -160,8 +186,18 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => {
// The teardown-order contract only earns its keep when the closing events are produced BY
// the dispose itself.
// The teardown-order contract only earns its keep when the closing events are
// produced BY the dispose itself. Here the model stream HANGS, so the turn is
// still open when teardown runs: the composite agent effect stops the loop,
// the loop unwinds and appends `turn/end {disposed}` + runs its final
// `session/flush` — all while the store-owned publication hooks are still attached (the session
// detach is the LAST disposer in the same effect's LIFO chain) — and only
// THEN is the session detached. If the order were inverted (or the session
// were a racing SIBLING effect), the abort-produced `turn/end` would never
// reach disk and a re-load would instead show crash-recovery's synthetic
// `interrupted` closer. Re-load from disk and assert the REAL `disposed`
// reason landed — proving the loop's own closing event was captured, not a
// recovered substitute.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -187,8 +223,11 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('per-session AgentHandle dispose leaves sibling agents untouched', async () => {
// The factory returns a per-agent AgentHandle whose dispose() tears down EXACTLY that agent
// + its session — RFC 011 isolation.
// The factory returns a per-agent AgentHandle whose dispose() tears down
// EXACTLY that agent + its session — RFC 011 isolation. Create two agents
// directly through the registry factory (the same path the ACP bridge uses),
// dispose one handle, and assert the other survives, registered and
// queryable, with its session still in the store.
const harness = await makeBridgeHarness({ storageDir, script: [] })
const handleA = await harness.ctx.agents.create({
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
@@ -212,8 +251,14 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => {
// The AgentHandle teardown folds session-detach, register, and loop-stop into one composite
// effect whose disposers run as a `.then()` chain.
// The AgentHandle teardown folds session-detach, register, and loop-stop
// into ONE composite effect whose disposers run as a `.then()` chain. The
// register disposer emits `agent/disposed`; if a listener throws and the
// emit is UNCONTAINED, the rejected chain skips the LATER session-detach
// disposer — stranding the session in the store with its publication hooks attached (a
// leak AND a durability hole, since the new design relies on detach
// running). The emit must be contained. Register a throwing listener, drive
// a clean turn, dispose, and assert the session was STILL removed.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
const handle = await harness.ctx.agents.create({
@@ -231,10 +276,11 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => {
// The handle's dispose() must memoize: the underlying cordis effect disposer is
// single-shot, so a second dispose() while the first is mid-teardown would otherwise
// resolve IMMEDIATELY (effect epoch already cleared) — before the first call's await
// agent.done + final flush finished.
// The handle's dispose() must memoize: the underlying cordis effect disposer
// is single-shot, so a second dispose() while the first is mid-teardown would
// otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the
// first call's await agent.done + final flush finished. Every caller must
// observe the same quiescence boundary.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
const handle = await harness.ctx.agents.create({
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },

View File

@@ -19,7 +19,9 @@ describe('acp bridge — demux & config edges', () => {
})
it('ignores events from an agent the bridge does not own (strict id demux)', async () => {
// A second agent created directly on the registry (not via the bridge) runs a turn.
// A second agent created directly on the registry (NOT via the bridge) runs
// a turn. Its session events must NOT produce ACP updates and
// must not settle anything — the bridge demuxes strictly by its own id.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })

View File

@@ -40,8 +40,9 @@ describe('acp bridge — turn outcomes', () => {
})
it('rejects the prompt RPC when a turn fails (no misleading end_turn)', async () => {
// ACP has no "error" stop reason; a failed turn must surface as a rejected session/prompt,
// not a normal end_turn that hides the failure from the client.
// ACP has no "error" stop reason; a failed turn must surface as a rejected
// session/prompt, not a normal end_turn that hides the failure from the
// client. The bridge rejects via the turn/end{error} log record.
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('provider boom')] })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
@@ -79,9 +80,12 @@ describe('acp bridge — turn outcomes', () => {
})
it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => {
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline stand-in, so this
// verifies the actual presentCall/presentResult the editor sees (docs/testing.md "prefer
// the real implementation over a mock").
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline
// stand-in, so this verifies the actual presentCall/presentResult the editor
// sees (docs/testing.md "prefer the real implementation over a mock").
// The mock MODEL still scripts the tool call (no real LLM needed), but the
// tool and executor are real: a real `echo` runs and its real output flows
// back through the bridge.
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -120,8 +124,11 @@ describe('acp bridge — turn outcomes', () => {
})
it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => {
// Drive the real bash tool, and advertise the Zed `_meta.terminal_output` capability in
// initialize.
// Drive the REAL bash tool, and advertise the Zed `_meta.terminal_output`
// capability in initialize. The bridge must then emit the terminal CARD: the
// description content block THEN a terminal content block + `_meta.terminal_info`
// (cwd header) on the call, and `_meta.terminal_output`/`terminal_exit` on the
// result — and OMIT the update's text content (it would clobber the card).
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -157,7 +164,11 @@ describe('acp bridge — turn outcomes', () => {
})
it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => {
// The session is created with the capability ON.
// The session is created with the capability ON. A SECOND initialize then
// turns it OFF at the connection level — but this session keeps its snapshot,
// so its bash call STILL renders as a terminal card (call + result agree).
// Without the snapshot, the result path would re-read the now-OFF capability
// and either clobber the card (content sent) or be inconsistent with the call.
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -181,9 +192,10 @@ describe('acp bridge — turn outcomes', () => {
})
it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {
// A buggy tool whose presentCall throws must not fail the live turn — the bridge's
// presenter contains the throw (logging via its onError sink) and falls back to the generic
// title=name presentation.
// A buggy tool whose presentCall throws must not fail the live turn — the
// bridge's presenter contains the throw (logging via its onError sink) and
// falls back to the generic title=name presentation. Exercises the real
// bridge wiring of the per-session presenter's error sink.
harness = await makeBridgeHarness({
storageDir,
script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')],
@@ -223,10 +235,9 @@ describe('acp bridge — turn outcomes', () => {
expect(failed).toHaveLength(1)
})
it('settles via the log fallback when a prior session/event listener throws (starvation)', async () => {
// A peer session/event listener that runs before the bridge's listener throws on turn/end
// (prepend: true puts it first). cordis emit stops at the throw, so the bridge's
// session/event listener never sees turn/end and cannot settle there.
it('settles successfully when an earlier turn/end observer throws', async () => {
// Session contains each post-commit observer failure, so a prepended peer
// cannot starve the bridge's live turn/end delivery.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
harness.ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/end') throw new Error('peer listener boom')
@@ -236,9 +247,7 @@ describe('acp bridge — turn outcomes', () => {
expect(res.stopReason).toBe('end_turn')
})
it('log fallback REJECTS when the starved turn ended in error', async () => {
// Same starvation as above, but the turn fails: the idle-fallback must
// reject the RPC from the logged turn/end{error}, not resolve.
it('still rejects a failed turn when an earlier turn/end observer throws', async () => {
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('starved boom')] })
harness.ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/end') throw new Error('peer listener boom')
@@ -248,21 +257,26 @@ describe('acp bridge — turn outcomes', () => {
.rejects.toThrow(/turn failed: starved boom/)
})
it('log fallback infers the owning turn when turn/START capture is starved', async () => {
// A peer listener throws on turn/START (not turn/end): the bridge never captures
// inflight.turn via the live stream.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('never runs')] })
it('captures and settles the owning turn when an earlier turn-start observer throws', async () => {
// Turn correlation still reaches the bridge after the throwing peer and
// captures inflight.turn via the live stream. A throwing turn/start listener
// Session contains post-commit callbacks independently.
// The model request and normal turn outcome therefore still occur.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
harness.ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/start') throw new Error('peer listener boom on start')
}, { prepend: true })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/turn failed:/)
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(result.stopReason).toBe('end_turn')
})
it('a between-turn injection does not settle the prompt early (message-trigger correlation)', async () => {
// A plugin injects context (a one-shot injection-triggered turn) right after the prompt is
// queued but before the prompt's own message turn runs.
// A plugin injects context (a one-shot injection-triggered turn) right after
// the prompt is queued but before the prompt's own message turn runs. The
// bridge must NOT mistake the injection turn's turn/end for the prompt's —
// it correlates only to message-triggered turns. The prompt settles on its
// OWN turn with the real model answer.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(AgentId(sessionId))!
@@ -310,9 +324,11 @@ describe('acp bridge — turn outcomes', () => {
})
it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => {
// Over the async JSON-RPC transport the loop usually wakes before cancel arrives, so this
// is a running/mid-step cancel (the synchronous pre-step DROP is unit-tested in
// agent-loop/cancel.spec.ts).
// Over the async JSON-RPC transport the loop usually wakes before cancel
// arrives, so this is a running/mid-step cancel (the synchronous pre-step
// DROP is unit-tested in agent-loop/cancel.spec.ts). The ACP-level guarantee:
// the prompt settles cancelled, the agent reaches idle, and no second/leaked
// turn runs afterward.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] })
const sessionId = await newSession(harness)
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
@@ -329,9 +345,10 @@ describe('acp bridge — turn outcomes', () => {
})
it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => {
// The ACP bridge settles the cancel RPC synchronously and accepts the next prompt WITHOUT
// awaiting quiescence — so this drives cancel→prompt with NO whenIdle() between, the
// production race.
// The ACP bridge settles the cancel RPC synchronously and accepts the next
// prompt WITHOUT awaiting quiescence — so this drives cancel→prompt with NO
// whenIdle() between, the production race. An idle cancel must be a no-op that
// does NOT drop the following prompt.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
// Cancel while idle (no prompt in flight) — a no-op.
@@ -367,8 +384,10 @@ describe('acp bridge — turn outcomes', () => {
})
it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => {
// Regression: prompt A runs; cancel settles A and frees the slot; A's aborted turn/end is
// still pending in the loop.
// Regression: prompt A runs; cancel settles A and frees the slot; A's
// aborted turn/end is still pending in the loop. Prompt B is sent before
// A's turn/end arrives. A's late turn/end (an EARLIER turn number) must NOT
// settle B — B owns a later turn. B then completes on its OWN turn/end.
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] })
const sessionId = await newSession(harness)

View File

@@ -2,11 +2,11 @@
User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service shallow-freezes a detached request record before dispatch, preserving the exact `agent` and `AbortSignal` identities while making later caller mutation unable to redirect scope, payload, cancellation, or either audit event. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything.
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its answerer phase always produces an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. `ApprovalRequest` is a readonly same-process contract: the service borrows the exact request, agent, session, and abort signal rather than cloning or freezing them. The request requires an open turn because the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask rejects before appending. Either audit append may reject before commit because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative audit append cannot reject the request or suppress its matching event.
The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md).

View File

@@ -1,9 +1,35 @@
/**
* Approval seam: `ctx.approval` answers exactly one question — "may this specific action
* proceed?" — by dispatching the `approval/request` waterfall to whatever answerers the
* deployment composed (an ACP editor prompt, an auto-decide policy, a scripted test listener)
* and returning a closed {@link ApprovalOutcome}.
* Scope-filtered dispatch: keyed to `req.agent`.
* Approval seam: `ctx.approval` answers exactly one question — "may this
* specific action proceed?" — by dispatching the `approval/request` waterfall
* to whatever answerers the deployment composed (an ACP editor prompt, an
* auto-decide policy, a scripted test listener) and returning a closed
* {@link ApprovalOutcome}. With no answerer the waterfall falls through to the
* built-in default `'unavailable'`: absence of a UI can never grant anything.
*
* The service is the MECHANISM (dispatch, cancellation, audit); answerers are
* the POLICY. It serves both ask paths the sandbox RFC names — the
* `tools/pre-execute` `ask` decision and the sandbox post-denial escalation —
* so every asker shares one outcome
* vocabulary and one audit trail. Grants are one-shot by design: an
* `'allowed-once'` outcome authorizes the single action it was asked about,
* never a class of future actions.
*
* Every request lands two log-only session events on the requesting agent's
* log (`approval/asked` / `approval/decided`, paired by
* {@link ApprovalRequestId}) — an audit trail, deliberately NOT part of the
* model-visible transcript: the model only ever sees the tool result the
* caller derives from the outcome.
*
* The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching):
* `effective = fold(the session's 'approval/policy' events, last one wins)
* ?? config.policy` — the session log is the store, so an override survives
* restart by replay. The service resolves `'never'` sessions to
* `'rejected'` inside `request()` before dispatching any answerer (no
* registration order, including a later `prepend`, can precede it); a prompt section states `'never'`
* (and only `'never'` — an availability promise is unknowable without
* asking); an `agent/pre-step` narrator explains a switch to the model in at
* most one coalesced notice per step.
*
* @module @deepseek-ai/dsh-user-approval
*/
@@ -26,8 +52,19 @@ declare module 'cordis' {
interface Events {
/**
* Waterfall asking the composed answerers to decide one approval request.
*
* @param req - the accepted decision (agent, tool identity, reason, signal).
* Dispatched only from {@link ApprovalService.request} — callers go through
* the service (which owns cancellation and the audit events), never through
* `ctx.waterfall` directly. A listener that can answer for this request's
* agent returns an outcome WITHOUT calling `next()` (the decision slot is
* single-occupancy, first listener to answer wins); a listener that does
* not recognize the agent MUST call `next()` so another answerer — or the
* fail-closed default `'unavailable'` — gets the question. Throwing is
* contained by the service and yields `'unavailable'`.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a
* listener registered through `agent.ctx` receives only that agent's
* questions, while a plain-context listener receives every agent's.
* `req` is a readonly same-process value borrowed from the caller.
* @param req - the pending decision (agent, tool identity, reason, signal).
* @mode waterfall
*/
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
@@ -184,12 +221,16 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean {
* THE write path for a session's approval-policy override: appends exactly
* one `approval/policy` event — the switch IS its event; nothing mutates
* policy state out of band. Takes effect on the session's next ask and next
* prompt assembly (the consumers fold on every read).
* prompt assembly (the consumers fold on every read). Rejects a value outside
* {@link APPROVAL_POLICIES} before appending anything.
* @param session - the session the override belongs to.
* @param policy - the policy every subsequent ask for this session resolves
* under (until the next switch).
*/
export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
if (!APPROVAL_POLICIES.includes(policy)) {
throw new TypeError('approval policy must be one of "ask" or "never"')
}
session.append('approval/policy', { policy })
}
@@ -198,9 +239,9 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi
* for an answerer to present it and for the audit events to reconstruct what
* was asked — it deliberately does NOT carry tool arguments: a UI answerer
* attaches the prompt to the already-streamed tool call via `callId` instead
* of re-rendering the call. `request()` synchronously copies and shallow-freezes
* this record before crossing an asynchronous boundary. Scalar fields are
* detached; the `agent` and `signal` identity capabilities are preserved.
* of re-rendering the call. This is a readonly same-process contract:
* `request()` borrows the request and its `agent` and `signal` capabilities
* directly rather than treating them as serialized input.
*/
export interface ApprovalRequest {
/**
@@ -208,21 +249,21 @@ export interface ApprovalRequest {
* UI answerer only answers for agents it owns) and receives the audit
* events on its session log.
*/
agent: Agent
readonly agent: Agent
/** The tool the question is about (presentation and audit). */
toolName: string
readonly toolName: string
/**
* The exact tool call being decided, when the asker has one — lets a UI
* attach the prompt to the tool call it already streamed.
*/
callId?: CallId
readonly callId?: CallId
/** The asker's human-readable explanation of WHY it is asking. */
reason?: string
readonly reason?: string
/**
* Aborting withdraws the question: the request settles `'cancelled'`
* immediately and a late answer from a still-pending answerer is discarded.
*/
signal?: AbortSignal
readonly signal?: AbortSignal
}
/** Plugin config. All optional — `static Config` supplies the defaults. */
@@ -233,13 +274,22 @@ export interface Config {
* (fail-closed with none); `'never'` auto-rejects every ask without
* prompting (the deterministic CI/unattended stance).
*/
policy?: ApprovalPolicy
readonly policy?: ApprovalPolicy
}
/**
* The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the `approval/request`
* waterfall and audits every ask/outcome pair to the requesting agent's session log. Stateless
* between requests — grants are returned to the caller, never stored here.
* The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the
* `approval/request` waterfall and audits every ask/outcome pair to the
* requesting agent's session log. Stateless between requests — grants are
* returned to the caller, never stored here.
*
* Owns the policy tier too (`effective = fold(the session's 'approval/policy'
* events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'`
* before dispatching any interactive answerer, a per-agent prompt section
* states a `'never'` policy (and only that one in prose — an `'ask'` promise
* could overclaim an answerer that headless compositions do not have), and an
* `agent/pre-step` narrator injects at most one coalesced notice when a
* session's effective policy moved past what the model was last told.
*/
export class ApprovalService extends Service {
static Config: z<Config> = z.object({
@@ -249,12 +299,14 @@ export class ApprovalService extends Service {
constructor(ctx: Context, public config: Config) {
super(ctx, 'approval')
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent)
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
// Visibility layer 1, scoped on the prompt registry so headless compositions mount the seam
// without it: state the one deterministic policy per session. 'ask' renders only a
// source-owned state marker — stating "you will be asked" would overclaim in a composition
// with no answerer.
// Visibility layer 1, scoped on the prompt registry so headless
// compositions mount the seam without it: state the one deterministic
// policy per session. 'ask' renders only a source-owned state marker —
// stating "you will be asked" would overclaim in a composition with no
// answerer. The marker, not deployment-controlled prose, is what the
// restart narrator reads back from the logged request header.
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.section({
name: 'approval:policy',
@@ -269,10 +321,16 @@ export class ApprovalService extends Service {
})
})
// Visibility layer 2: the boundary narrator. pre-step runs after prompt assembly but before
// the request history is derived, so the notice is seen by this step's request: idle-time
// flip-flops coalesce at the turn's first step (net-zero → nothing), and a mid-turn switch
// is narrated no later than the next step.
// Visibility layer 2: the boundary narrator. pre-step runs after prompt
// assembly but before the request history is derived, so the notice is
// seen by THIS step's request: idle-time flip-flops coalesce at the
// turn's first step (net-zero → nothing), and a mid-turn switch is
// narrated no later than the next step. What each session was last told
// is in-memory with a log-derived fallback (the folded header's system
// text), so restarts lose nothing. Attribution is positional: an
// override event after the log's last `request/header*` was a runtime
// switch by the user; otherwise the configured default moved under the
// session (operator/config).
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
ctx.on('agent/pre-step', (agent) => {
const session = agent.session
@@ -289,7 +347,7 @@ export class ApprovalService extends Service {
}
// Same fold effectivePolicy performs — override is scanned here anyway
// for POSITIONAL attribution; the default lives once, in the method.
const current = this.effectivePolicy(agent)
const current = this.effectivePolicy(session)
const header = session.requestHeader()
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
narrated.set(session, current)
@@ -305,26 +363,25 @@ export class ApprovalService extends Service {
}
/**
* Ask the composed answerers to decide one request.
*
* Ask the composed answerers to decide one readonly same-process request.
* The service borrows the request, agent, session, and live signal directly.
* The request requires an open turn because the audit pair must be enclosed
* by the durable log's commit/replay boundary; an idle ask rejects before
* appending anything. The answerer phase always produces an outcome: an
* aborted signal yields `'cancelled'`, a missing or throwing answerer yields
* `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
* normalized to `'unavailable'`. A failure that prevents either audit append
* from committing still rejects because returning an unlogged decision would
* violate the pair. Session contains post-commit observer failures, so an
* authoritative append cannot reject the request or suppress its matching
* audit event.
* @param req - the pending decision (agent, tool identity, reason, signal).
* @returns the closed outcome; `'allowed-once'` is the only grant.
* @throws when no turn is open or either audit event fails before the session
* append commit point.
*/
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
// Accept one immutable request shape before the first async boundary.
const agent = req.agent
const toolName = req.toolName
const callId = req.callId
const reason = req.reason
const signal = req.signal
const accepted: Readonly<ApprovalRequest> = Object.freeze({
agent,
toolName,
...callId !== undefined ? { callId } : {},
...reason !== undefined ? { reason } : {},
...signal !== undefined ? { signal } : {},
})
const session = accepted.agent.session
const session = req.agent.session
if (!hasOpenTurn(session.events)) {
throw new Error(
'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
@@ -333,61 +390,43 @@ export class ApprovalService extends Service {
)
}
const id = ApprovalRequestId(randomUUID())
this.appendAudit(session, 'approval/asked', id, () => {
session.append('approval/asked', {
id,
toolName: accepted.toolName,
...accepted.callId !== undefined ? { callId: accepted.callId } : {},
...accepted.reason !== undefined ? { reason: accepted.reason } : {},
})
})
const outcome = await this.decide(accepted)
this.appendAudit(session, 'approval/decided', id, () => {
session.append('approval/decided', { id, outcome })
session.append('approval/asked', {
id,
toolName: req.toolName,
...req.callId !== undefined ? { callId: req.callId } : {},
...req.reason !== undefined ? { reason: req.reason } : {},
})
const outcome = await this.decide(req, session)
session.append('approval/decided', { id, outcome })
return outcome
}
/**
* Append one audit event while distinguishing a post-append observer throw from a failure
* that prevented the event entering the log.
*
* @param session - the captured session receiving both audit events.
* @param type - the audit event currently being appended.
* @param id - the request id, used to identify the contained failure.
* @param append - the single concrete `Session.append` call.
*/
private appendAudit(
session: Session,
type: 'approval/asked' | 'approval/decided',
id: ApprovalRequestId,
append: () => void,
): void {
const length = session.events.length
try {
append()
} catch (error) {
if (session.events.length === length) throw error
this.ctx.logger.warn(`approval request "${id}": ${type} observer threw after the event was appended`)
}
}
/**
* The session's effective policy: its own `approval/policy` fold, else the
* configured default (the schema already defaulted an omitted policy to
* `'ask'`; the `??` only narrows the optional-input TYPE).
* @param agent - the agent whose session's policy applies.
* @returns the policy every ask for this agent resolves under right now.
* @param session - the exact accepted session whose policy applies.
* @returns the policy every ask for this session resolves under right now.
*/
private effectivePolicy(agent: Agent): ApprovalPolicy {
return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask'
private effectivePolicy(session: Session): ApprovalPolicy {
return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask'
}
/** Dispatch the waterfall, contained and raced against the accepted signal. */
private async decide(req: Readonly<ApprovalRequest>): Promise<ApprovalOutcome> {
if (req.signal?.aborted) return 'cancelled'
// Enforce never before dispatch so listener order cannot bypass it.
if (this.effectivePolicy(req.agent) === 'never') return 'rejected'
/**
* Dispatch the waterfall, contained and raced against the request signal.
* @param req - the borrowed public request.
* @param session - the request agent's session used for policy lookup.
* @returns the normalized closed outcome.
*/
private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
const signal = req.signal
if (signal?.aborted) return 'cancelled'
// The 'never' policy is decided HERE, before any dispatch: a listener
// registered with `prepend: true` after this service mounts would sit
// ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
// documented promise that 'never' rejects deterministically regardless
// of registration order — only the service's own request path can.
if (this.effectivePolicy(session) === 'never') return 'rejected'
// Enter the promise chain BEFORE dispatching: a listener that throws
// SYNCHRONOUSLY (before its first await) must land in the same rejection
// path as an async one — `Promise.resolve(call())` would let it escape
@@ -405,10 +444,12 @@ export class ApprovalService extends Service {
// tool call open — the seam contains its callbacks.
() => 'unavailable',
)
const signal = req.signal
if (signal === undefined) return answer
return await new Promise<ApprovalOutcome>((resolve) => {
const onAbort = () => { resolve('cancelled') }
const onAbort = () => {
signal.removeEventListener('abort', onAbort)
resolve('cancelled')
}
signal.addEventListener('abort', onAbort, { once: true })
void answer.then((outcome) => {
signal.removeEventListener('abort', onAbort)

View File

@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, scopeHost } from '@deepseek-ai/dsh-scope'
import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -78,77 +79,38 @@ describe('ApprovalService.request', () => {
expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName'])
})
it('snapshots request identity, scope, payload, and audit before deferred dispatch', async () => {
it('borrows the exact readonly request for scoped dispatch and audit', async () => {
const ctx = await mounted()
const { agent: acceptedAgent, appended: acceptedAudit } = fakeAgent()
const { agent: replacementAgent, appended: replacementAudit } = fakeAgent()
const host = await scopeHost(ctx, ['approval'])
const acceptedScope = host.mint(acceptedAgent)
const replacementScope = host.mint(replacementAgent)
const dispatchStarted = Promise.withResolvers<'started'>()
const answer = Promise.withResolvers<ApprovalOutcome>()
const originalSignal = new AbortController().signal
const replacementSignal = new AbortController().signal
let heardBy: 'accepted' | 'replacement' | undefined
const { agent, appended } = fakeAgent()
let scope!: Scope
const scopeFiber = await ctx.plugin(Object.assign((inner: Context) => {
scope = createScope(inner, agent)
}, { inject: ['approval'] }))
let received: ApprovalRequest | undefined
let carrier: unknown
acceptedScope.ctx.on('approval/request', function (req) {
heardBy = 'accepted'
scope.ctx.on('approval/request', function (req) {
received = req
carrier = carrierKeyOf(this)
dispatchStarted.resolve('started')
return answer.promise
return Promise.resolve<ApprovalOutcome>('allowed-once')
})
replacementScope.ctx.on('approval/request', function (req) {
heardBy = 'replacement'
received = req
carrier = carrierKeyOf(this)
dispatchStarted.resolve('started')
return answer.promise
})
const request = requestOf(acceptedAgent, {
toolName: 'original-tool',
callId: CallId('original-call'),
reason: 'original reason',
signal: originalSignal,
const request = requestOf(agent, {
toolName: 'scoped-tool',
callId: CallId('scoped-call'),
reason: 'scoped reason',
})
const pending = ctx.approval.request(request)
// request() has returned, but the answerer dispatch is deliberately queued
// in a microtask. Mutating the caller-owned record must not redirect it.
request.agent = replacementAgent
request.toolName = 'mutated-before-dispatch'
request.callId = CallId('mutated-call')
request.reason = 'mutated reason'
request.signal = replacementSignal
await dispatchStarted.promise
// Mutation while the answer is pending must not redirect the final audit.
request.toolName = 'mutated-after-dispatch'
request.reason = 'mutated again'
answer.resolve('allowed-once')
await expect(pending).resolves.toBe('allowed-once')
expect(heardBy).toBe('accepted')
expect(carrier).toBe(acceptedAgent)
expect(received).not.toBe(request)
expect(Object.isFrozen(received)).toBe(true)
expect(received).toMatchObject({
agent: acceptedAgent,
toolName: 'original-tool',
callId: 'original-call',
reason: 'original reason',
signal: originalSignal,
await expect(ctx.approval.request(request)).resolves.toBe('allowed-once')
expect(carrier).toBe(agent)
expect(received).toBe(request)
expect(appended).toHaveLength(2)
expect(appended[0]?.data).toMatchObject({
toolName: 'scoped-tool',
callId: 'scoped-call',
reason: 'scoped reason',
})
expect(acceptedAudit).toHaveLength(2)
expect(acceptedAudit[0]?.data).toMatchObject({
toolName: 'original-tool',
callId: 'original-call',
reason: 'original reason',
})
expect(acceptedAudit[1]?.data).toMatchObject({ outcome: 'allowed-once' })
expect(acceptedAudit[1]?.data['id']).toBe(acceptedAudit[0]?.data['id'])
expect(replacementAudit).toEqual([])
await host.dispose()
expect(appended[1]?.data).toMatchObject({ outcome: 'allowed-once' })
expect(appended[1]?.data['id']).toBe(appended[0]?.data['id'])
await scopeFiber.dispose()
})
it('contains an approval/asked observer throw after append and still completes the pair', async () => {
@@ -171,7 +133,7 @@ describe('ApprovalService.request', () => {
const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
expect(decided?.data.id).toBe(asked?.data.id)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/asked observer threw'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after asked append'))
})
it('contains an approval/decided observer throw after append and still resolves', async () => {
@@ -194,10 +156,10 @@ describe('ApprovalService.request', () => {
const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/decided observer threw'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after decided append'))
})
it('does not misclassify a pre-append failure as an observer failure', async () => {
it('propagates an append failure that prevented audit log growth', async () => {
const ctx = await mounted()
const failure = new Error('append failed before log growth')
const agent = {
@@ -236,9 +198,12 @@ describe('ApprovalService.request', () => {
const ctx = await mounted()
const { agent: agentA } = fakeAgent()
const { agent: agentB } = fakeAgent()
const host = await scopeHost(ctx, ['approval'])
const scopeA = host.mint(agentA)
const scopeB = host.mint(agentB)
let scopeA!: Scope
let scopeB!: Scope
const scopesFiber = await ctx.plugin(Object.assign((inner: Context) => {
scopeA = createScope(inner, agentA)
scopeB = createScope(inner, agentB)
}, { inject: ['approval'] }))
const heard: string[] = []
ctx.on('approval/request', (req, next) => {
heard.push(req.agent === agentA ? 'global:A' : 'global:B')
@@ -257,14 +222,16 @@ describe('ApprovalService.request', () => {
await expect(ctx.approval.request(requestOf(agentB))).resolves.toBe('unavailable')
expect(heard).toEqual(['global:A', 'scoped:A', 'global:B', 'scoped:B'])
await host.dispose()
await scopesFiber.dispose()
})
it('keys the scoped dispatch carrier to the exact request agent', async () => {
const ctx = await mounted()
const { agent } = fakeAgent()
const host = await scopeHost(ctx, ['approval'])
const scope = host.mint(agent)
let scope!: Scope
const scopeFiber = await ctx.plugin(Object.assign((inner: Context) => {
scope = createScope(inner, agent)
}, { inject: ['approval'] }))
let seenKey: object | undefined
scope.ctx.on('approval/request', function (req, next) {
seenKey = carrierKeyOf(this)
@@ -275,7 +242,7 @@ describe('ApprovalService.request', () => {
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
expect(seenKey).toBe(agent)
await host.dispose()
await scopeFiber.dispose()
})
it('contains a throwing answerer as unavailable', async () => {
@@ -420,6 +387,15 @@ describe('approval policy (the approval/policy fold)', () => {
expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } })
})
it('rejects a policy outside the closed vocabulary before appending', () => {
const append = vi.fn()
const session = { append } as unknown as Session
expect(() => { setApprovalPolicy(session, 'sometimes' as Parameters<typeof setApprovalPolicy>[1]) })
.toThrow('approval policy must be one of "ask" or "never"')
expect(append).not.toHaveBeenCalled()
})
it('defaults a schema-less construction to ask (the ?? narrows the optional TYPE)', async () => {
// Direct construction bypasses the plugin schema (the SystemPrompt-test
// precedent for covering a defaulted Config field's type-narrowing ??).