diff --git a/docs/capability-seams.md b/docs/capability-seams.md index c71c74003e..abddc73ed2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -73,6 +73,7 @@ flowchart LR svc_workflows["ctx.workflows
Workflow script engine"] pkg_workflow_workerthread["workflow-workerthread"] pkg_tool_workflow["tool-workflow"] + pkg_acp --> svc_approval pkg_acp --> svc_userInteraction pkg_agent --> svc_agents pkg_agent_loop --> svc_agentLoop @@ -164,7 +165,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. | -| `ctx.approval` | `seam` | [`approval`](../packages/approval/approval) | - | [`tools`](../packages/core/tools) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | +| `ctx.approval` | `seam` | [`approval`](../packages/approval/approval) | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7866a123f2..ed175f250b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -31,7 +31,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:236`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:241`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-agent` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5b50b30cc2..57b1f108ca 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -19,7 +19,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `approval/request` | `waterfall` | [`packages/approval/approval/src/index.ts:52`](../packages/approval/approval/src/index.ts) | [`approval`](../packages/approval/approval) (`waterfall`) | - | +| `approval/request` | `waterfall` | [`packages/approval/approval/src/index.ts:52`](../packages/approval/approval/src/index.ts) | [`approval`](../packages/approval/approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 6237fac953..2bcd15c967 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -196,6 +196,7 @@ flowchart TD pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_tools pkg_acp --> pkg_agent + pkg_acp --> pkg_approval pkg_acp --> pkg_llm pkg_acp --> pkg_session pkg_acp --> pkg_session_persistence @@ -315,7 +316,7 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`approval`](../packages/approval/approval), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 5b3c936651..c78943284d 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -37,4 +37,4 @@ This example is the home of the harness's **snapshot tests** — they boot this ## MVP limitations -The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: prompts support ACP's baseline `text` and `resource_link` blocks only, `additionalDirectories` and `mcpServers` are rejected, and the tool-permission gate is deferred (`TODO(rfc010-permission-gate)` — tools run with the executor's full authority). See `packages/ui/acp/README.md` for the full contract. +The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: prompts support ACP's baseline `text` and `resource_link` blocks only, and `additionalDirectories` and `mcpServers` are rejected. Permission prompts (`session/request_permission`) are wired through the approval seam, but this example composes no ask-producing policy, so tools run with the executor's full authority. See `packages/ui/acp/README.md` for the full contract. diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 714791fa3e..f1572991ee 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -80,8 +80,9 @@ function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawn return Promise.resolve() }, requestPermission(_params: RequestPermissionRequest): Promise { - // Permission gate is deferred (TODO(rfc010-permission-gate)); the bridge - // never requests permission yet, so just allow if it ever does. + // This example composes no ask-producing policy (no hooks), so the + // bridge never prompts here; answer cancelled (fail closed) if it ever + // does — an unexpected prompt must not grant anything. return Promise.resolve({ outcome: { outcome: 'cancelled' } }) }, }) diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 209b1a81cb..ff1738f462 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -35,4 +35,4 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. Fixture roles, record/replay semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). +Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript). diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index c0f5788fca..bd197b298b 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -85,6 +85,8 @@ export type InputStep = | { op: 'promptExpectError'; text: string } | { op: 'promptAndCancel'; text: string } | { op: 'cancel' } + | { op: 'setConfigOption'; configId: string; value: string } + | { op: 'setConfigOptionExpectError'; configId: string; value: string } /** A scenario's `input.json`: an ordered list of input steps. */ export interface InputScript { @@ -406,6 +408,24 @@ async function runStep( await client.cancel({ sessionId }) return } + case 'setConfigOption': { + const sessionId = getSessionId() + if (sessionId === undefined) throw new Error('snapshot-harness: setConfigOption before newSession') + await client.setSessionConfigOption({ sessionId, configId: step.configId, value: step.value }) + return + } + case 'setConfigOptionExpectError': { + const sessionId = getSessionId() + if (sessionId === undefined) throw new Error('snapshot-harness: setConfigOptionExpectError before newSession') + // The bridge rejects an unknown id / out-of-vocabulary value; the SDK + // surfaces that as a rejected RPC — swallow it so the run completes and + // the error frame is captured in the transcript. + await client.setSessionConfigOption({ sessionId, configId: step.configId, value: step.value }).then( + () => { throw new Error('snapshot-harness: expected set_config_option to be rejected but it succeeded') }, + () => { /* expected: the bridge rejected the id or value */ }, + ) + return + } default: throw new Error(`snapshot-harness: unknown input op ${JSON.stringify(step)}`) } diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index cf41412046..861c9d2b04 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -58,6 +58,13 @@ interface Behavior { strayBucketFile?: boolean /** Delete the sessions root entirely (harvest must yield no logs). */ deleteSessionsRoot?: boolean + /** + * Vocabulary for `session/set_config_option`: allowed values per config id. + * A set naming an unknown id or an out-of-vocabulary value rejects (the + * real bridge's rule); a valid set answers with the complete refreshed + * option state, `currentValue` updated. Absent: every set rejects. + */ + configOptions?: Record } const sessionsRoot = process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? '' @@ -81,6 +88,8 @@ let sessionCwd = '' let parkedPromptId: number | string | null = null /** Resolvers for permission-probe responses, keyed by outbound request id. */ const pendingPermission = new Map void>() +/** Per-run `session/set_config_option` state: config id → current value (first vocabulary entry until set). */ +const currentConfig: Record = {} function send(frame: Record): void { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`) @@ -195,6 +204,32 @@ function handleFrame(frame: Record): void { case 'session/prompt': void handlePrompt(id as number | string) return + case 'session/set_config_option': { + const vocabulary = behavior.configOptions + const configId = params.configId as string + const value = params.value as string + const values = vocabulary?.[configId] + if (values === undefined) { + respondError(id as number | string, `unknown config option ${configId}`) + return + } + if (!values.includes(value)) { + respondError(id as number | string, `unknown ${configId} value ${value}`) + return + } + currentConfig[configId] = value + // The real bridge's contract: every set answers with the COMPLETE + // refreshed option state, not just the changed entry. + respond(id as number | string, { + configOptions: Object.entries(vocabulary as Record).map(([cid, vs]) => ({ + id: cid, + type: 'select', + currentValue: currentConfig[cid] ?? vs[0], + options: vs.map(v => ({ value: v, name: v })), + })), + }) + return + } case 'session/cancel': if (parkedPromptId !== null) { const parked = parkedPromptId diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 683d2aaf80..b0817a8d04 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -168,6 +168,8 @@ describe('runScenario', () => { [{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/], [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/], [{ op: 'cancel' }, /cancel before newSession/], + [{ op: 'setConfigOption', configId: 'sandbox-mode', value: 'read-only' }, /setConfigOption before newSession/], + [{ op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' }, /setConfigOptionExpectError before newSession/], ] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => { const { fixtureFile } = await scenario({}) await expect(runScenario( @@ -176,6 +178,53 @@ describe('runScenario', () => { )).rejects.toThrow(message) }) + it('setConfigOption switches a value and receives the complete refreshed option state', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + configOptions: { 'sandbox-mode': ['read-only', 'workspace-write'], 'approval-policy': ['ask', 'never'] }, + }) + const result = await runScenario( + { + steps: [...boot, + { op: 'setConfigOption', configId: 'sandbox-mode', value: 'workspace-write' }, + { op: 'setConfigOption', configId: 'approval-policy', value: 'never' }], + }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + // Every set answers with the FULL state: the second response carries the + // first switch's value too — the complete-refreshed-state contract. + const frames = result.rawStdout.trim().split('\n').map(line => JSON.parse(line) as { result?: { configOptions?: { id: string; currentValue: string }[] } }) + const states = frames + .map(f => f.result?.configOptions) + .filter(options => options !== undefined) + .map(options => Object.fromEntries((options as { id: string; currentValue: string }[]).map(o => [o.id, o.currentValue]))) + expect(states).toEqual([ + { 'sandbox-mode': 'workspace-write', 'approval-policy': 'ask' }, + { 'sandbox-mode': 'workspace-write', 'approval-policy': 'never' }, + ]) + }) + + it('setConfigOptionExpectError swallows the rejection for unknown ids and out-of-vocabulary values', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ configOptions: { 'sandbox-mode': ['read-only'] } }) + const result = await runScenario( + { + steps: [...boot, + { op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' }, + { op: 'setConfigOptionExpectError', configId: 'reasoning-effort', value: 'max' }], + }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + expect(result.rawStdout).toContain('unknown sandbox-mode value yolo') + expect(result.rawStdout).toContain('unknown config option reasoning-effort') + }) + + it('setConfigOptionExpectError throws when the set unexpectedly succeeds', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ configOptions: { 'sandbox-mode': ['read-only'] } }) + await expect(runScenario( + { steps: [...boot, { op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'read-only' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + )).rejects.toThrow(/expected set_config_option to be rejected/) + }) + it('rejects an unknown input op', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({}) const bogus = { op: 'reticulate' } as unknown as InputStep diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 49abfbff6a..0820db0cb5 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -31,12 +31,11 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | | `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | +| `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../../approval/approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" | ## Multi-session -The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` 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. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.) - -Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload. +The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` 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. ## Per-session cwd @@ -67,13 +66,16 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal 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. +## Permission prompts + +The bridge registers an `approval/request` waterfall listener — the ACP answerer of the [approval seam](../../approval/approval/README.md). When `ctx.approval` routes an `ask` for an agent the bridge owns, the listener resolves the owning session through the reverse map and issues `session/request_permission` with the request's `callId` as the `toolCall` reference (the editor attaches the prompt to the already-streamed call) and the one-shot options `allow_once`/`reject_once` (`allow_always` is deferred to the approval RFC's grant-storage question). Outcomes map `allow-once → allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled → cancelled`. A request for an agent the bridge does NOT own — or one without a `callId` to attach to — delegates via `next()` so another answerer or the seam's fail-closed `unavailable` default takes it. A rejected `requestPermission` RPC (client gone mid-prompt) propagates to the ApprovalService, which contains it as `unavailable`. Whether a call asks at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment; without such policy, tools keep the executor's full authority. + ## Disposal & disconnect Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../../core/agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). ## Known limitations (tracked TODOs) -- **`TODO(rfc010-permission-gate)`** — the `tools/pre-execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land. - **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented. ## stdout is the protocol diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 6162fae137..ff27c3f2d5 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th ## At a glance -The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship — plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). +The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), and resumable session replay. The largest **unbuilt** areas are **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship — plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). ## 1. Agent methods (client → agent) @@ -25,8 +25,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i | `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. | | `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. | | `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. | -| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes not modeled (see [§6 Modes](#6-session-modes--config-options--models)). | -| `session/set_config_option` | S | ❌ | ✅ | ✅ | Config options not modeled. | +| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry the two orthogonal knobs (see [§6](#6-session-modes--config-options--models)). | +| `session/set_config_option` | S | ❌ | ✅ | ✅ | Config options not modeled yet — the sandbox RFC's per-session mode switching stages them ([sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)). | | model selection | S | ❌ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses the legacy `unstable_setSessionModel` ext method. | | `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. | | `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. | @@ -39,7 +39,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | Method | Stable | Bridge | Claude | Codex | Notes | |---|---|---|---|---|---| | `session/update` | S | ✅ | ✅ | ✅ | The bridge's primary output channel (see [§4](#4-sessionupdate-variants)). | -| `session/request_permission` | S | ❌ | ✅ | ✅ | **The biggest gap.** Tools run with the executor's full authority; no user authorization round-trip. The `agent→sessionId` reverse map is already in place to route a future permission request. Tracked `TODO(rfc010-permission-gate)`. | +| `session/request_permission` | S | ✅ | ✅ | ✅ | The bridge answers the [`ctx.approval`](../../approval/approval/README.md) seam for the agents it owns: an `ask` from a hook/plugin becomes an editor prompt attached to the streamed tool call, one-shot `allow_once`/`reject_once` options only. Whether a call asks is policy (nothing asks by default); `allow_always` is deferred (grant storage). | | `fs/read_text_file` | S | ❌ | ✅ | ❌ | The harness reads files directly (it does not see the editor's unsaved buffer state). Claude delegates; Codex does not. | | `fs/write_text_file` | S | ❌ | ✅ | ❌ | Same — direct writes, no editor delegation. | | `terminal/create` | S | ❌ | ❌ | ❌ | Neither reference adapter drives the client terminal API either — both, like the bridge, render shell output as tool-call content + a `_meta` channel (see [§5 Terminal](#terminal-rendering)). | @@ -86,7 +86,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). | | `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. | | `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. | -| `config_option_update` | S | ❌ | ✅ | ✅ | No config options. | +| `config_option_update` | S | ❌ | ✅ | ✅ | No config options yet. | | `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). | | `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. | @@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult ## 6. Session modes / config options / models -❌ None modeled. Both reference adapters ship modes (Claude: a "plan" auto-mode; Codex: read-only / agent / agent-full-access mapping to its approval+sandbox policy), the newer config-option surface, and runtime model selection. The harness fixes the model per-bridge via `AcpConfig.model`. These are coupled to the unbuilt **permission gate** (a mode often selects an approval policy), so they are natural follow-ups to it. +Session modes and config options are not modeled yet: the sandbox RFC's per-session mode switching ([sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)) stages config options as the surface (modes are slated for removal in ACP v2, and one mode list cannot carry two orthogonal knobs). Runtime model selection is also not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector). ## 7. Content blocks @@ -140,15 +140,14 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them Ranked by how commonly the reference adapters ship them and how much UX they unlock: -1. **Permission gate** — `session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired and shared with `ask_user_question` routing. Foundational, and a prerequisite for modes. -2. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`. -3. **Modes / config options / model selection** — coupled to the permission gate. -4. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries. -5. **Slash commands** (`available_commands_update`). -6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). -7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). -9. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`). -10. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. +1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`. +2. **Modes / config options / model selection** — the permission round-trip landed with the approval seam; the config surface (`sandbox-mode`/`approval-policy` options) is the sandbox RFC's staged config phase. +3. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries. +4. **Slash commands** (`available_commands_update`). +5. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). +6. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). +7. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`). +8. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. ## Out of scope diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 4ebc8485ce..8c9f731363 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -28,6 +28,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-approval": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -38,9 +39,12 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-approval": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 928ab7da77..5959f0f9c4 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -22,8 +22,10 @@ * `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. The `tools/pre-execute` permission gate is - * deferred — see the TODO(rfc010-permission-gate) note below. + * `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 @@ -74,6 +76,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto // Context (the bridge injects it and reads `list()` for load cwd validation). import type {} from '@deepseek-ai/dsh-session-persistence' +// Side-effect type import: declaration-merges the `approval/request` waterfall +// the bridge answers for its own agents (see the approval answerer below). +import type {} from '@deepseek-ai/dsh-approval' import { UserInteractionError, type AskUserQuestionAnswer, @@ -555,6 +560,37 @@ export function apply(ctx: Context, config: AcpConfig): void { 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 today, sandbox escalation later) 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 + // a request without a callId has nothing to attach to — delegate. + if (sessionId === undefined || req.callId === undefined) return next() + return conn.requestPermission({ + sessionId, + toolCall: { toolCallId: req.callId }, + options: [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, + ], + }).then(({ outcome }) => { + if (outcome.outcome === 'cancelled') return 'cancelled' + // Only the two advertised options exist; an unknown optionId from a + // non-conforming client counts as a rejection, never a grant. + return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected' + }) + }) + // --- The ACP Agent method surface ----------------------------------------- const makeAgent = (connection: AgentSideConnection): AcpAgent => { @@ -765,6 +801,7 @@ export function apply(ctx: Context, config: AcpConfig): void { settlePrompt(rec, 'cancelled') return Promise.resolve() }, + } } diff --git a/packages/ui/acp/tests/approval.spec.ts b/packages/ui/acp/tests/approval.spec.ts new file mode 100644 index 0000000000..84b816067c --- /dev/null +++ b/packages/ui/acp/tests/approval.spec.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { CallId } from '@deepseek-ai/dsh-llm' +import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-approval' +import { makeBridgeHarness, type BridgeHarness } from './harness.ts' + +/** + * The bridge's `approval/request` answerer: an ask for an agent the bridge + * owns becomes a `session/request_permission` prompt attached to the tool + * call; foreign or call-less requests delegate down to the fail-closed + * default. Driven through `ctx.approval` — the same path dsh-tools' ask + * routing takes — against the harness's scriptable client. + */ +describe('acp bridge — approval answerer', () => { + let storageDir: string + let harness: BridgeHarness | undefined + + beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-approval-')) }) + afterEach(async () => { + await harness?.dispose() + harness = undefined + await rm(storageDir, { recursive: true, force: true }) + }) + + async function ownedAgentRequest( + h: BridgeHarness, overrides: Partial = {}, + ): Promise<{ agent: Agent; request: ApprovalRequest }> { + await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = h.ctx.agents.get(AgentId(sessionId)) + if (agent === undefined) throw new Error('newSession created no agent') + // In production an ask always fires mid-turn (tool execution); open one so + // request()'s turn-enclosure precondition holds for the direct drive below. + agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + return { agent, request: { agent, toolName: 'echo', callId: CallId('call-9'), ...overrides } } + } + + it('prompts the editor for an owned agent and maps allow-once → allowed-once', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.ctx.plugin(ApprovalService) + harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + + const { request } = await ownedAgentRequest(harness) + await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once') + + expect(harness.permissionRequests).toHaveLength(1) + const wire = harness.permissionRequests[0] + expect(wire?.toolCall).toEqual({ toolCallId: 'call-9' }) + expect(wire?.options.map(o => ({ optionId: o.optionId, kind: o.kind }))).toEqual([ + { optionId: 'allow-once', kind: 'allow_once' }, + { optionId: 'reject-once', kind: 'reject_once' }, + ]) + }) + + it('maps reject-once → rejected', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.ctx.plugin(ApprovalService) + harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } }) + + const { request } = await ownedAgentRequest(harness) + await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected') + }) + + it('maps a client cancellation → cancelled', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.ctx.plugin(ApprovalService) + harness.onPermission = () => ({ outcome: { outcome: 'cancelled' } }) + + const { request } = await ownedAgentRequest(harness) + await expect(harness.ctx.approval.request(request)).resolves.toBe('cancelled') + }) + + it('treats an unknown optionId from a non-conforming client as a rejection, never a grant', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.ctx.plugin(ApprovalService) + harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-always-i-insist' } }) + + const { request } = await ownedAgentRequest(harness) + await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected') + }) + + it('delegates a foreign agent down to the fail-closed default', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.ctx.plugin(ApprovalService) + harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + + // Not created through the bridge: no bySession entry, so the answerer must + // call next() — nobody else answers, so the seam fails closed. + const foreign = { session: { events: [{ type: 'turn/start' }], append: () => ({}) } } as unknown as Agent + await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') })) + .resolves.toBe('unavailable') + expect(harness.permissionRequests).toHaveLength(0) + }) + + it('delegates a call-less request — the protocol prompt must attach to a tool call', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.ctx.plugin(ApprovalService) + harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + + const { agent } = await ownedAgentRequest(harness) + await expect(harness.ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable') + expect(harness.permissionRequests).toHaveLength(0) + }) +}) diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 9c2358c455..7c80e7459c 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -34,6 +34,12 @@ }, { "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../approval/approval" + }, + { + "path": "../../bash/bash" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b6542bbe0..bef41d9bdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -985,6 +985,12 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-approval': + specifier: workspace:^ + version: link:../../approval/approval + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../bash/bash '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local @@ -994,6 +1000,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../fs/fs-policy + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5061b12ddb..be6d8b7121 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -164,7 +164,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'approval', title: 'Approval seam', mode: 'seam', - implementations: [], + implementations: ['acp'], consumers: ['tools'], note: 'One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`.', },