Merge remote-tracking branch 'origin/master' into codex/pr48-repo-hardening-rfcs

# Conflicts:
#	docs/adr/README.md
#	docs/rfc/009-session-persistence-and-resumability.md
#	docs/rfc/README.md
#	docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md
#	docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md
#	examples/acp-agent/tests/acp.e2e.ts
#	packages/acp/README.md
#	packages/acp/src/index.ts
#	packages/acp/tests/stream-update.spec.ts
#	packages/agent-loop/src/loop.ts
#	packages/tools/src/index.ts
This commit is contained in:
Tianyi Cui
2026-06-18 23:41:14 +08:00
104 changed files with 2586 additions and 590 deletions

View File

@@ -1,14 +1,14 @@
# @deepseek-ai/dsh-acp
The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. **N concurrent sessions per connection** (RFC 011): each maps to its own `LoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md)): each maps to its own `LoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT an [ADR 0009](../../docs/adr/0009-capability-seams.md) capability seam. It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../docs/rfc/implemented/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
## Service / plugin
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
`inject: ['agents', 'sessions', 'sessionPersistence']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`.
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools']` — programs against the interface packages only (never `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 the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation).
### Config
@@ -23,14 +23,14 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
| ACP method | Harness seam | Notes |
|---|---|---|
| `initialize` | static | negotiate `protocolVersion`; advertise baseline text/resource-link prompt support, no image/audio/embedded resources, and `loadSession: true` |
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed (RFC 011), keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` are rejected until those scopes are implemented |
| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message``user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, and the requested `cwd` must match it so editor UI and bash execution agree on the workspace. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
| `session/prompt` | `agent.send()` | accepts ACP baseline `text` and `resource_link` blocks; rejects image/audio/embedded resources and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message``user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
| `session/cancel` | `agent.abort()` | aborts a running step + settles the prompt `cancelled` for ONLY that session — a cancel never touches another session's stream or prompt (see limitation below) |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay only), `tool_call`/`tool_call_update` |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) |
## Multi-session (RFC 011)
## 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. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
@@ -38,7 +38,22 @@ Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and
## Per-session cwd
Each session runs in its own workspace, recorded as the session's `SessionHeader.cwd`. On `session/new` the (absolute) request `cwd` becomes that header cwd; on `session/load` the resumed session keeps its PERSISTED header cwd and the request `cwd` must match it (a mismatch is rejected up front) so the editor never believes tools run in one workspace while bash runs in another. A load whose persisted session has no absolute cwd is also rejected via a metadata-only `list()` check, BEFORE resume constructs an agent. `dsh-tool-bash` then defaults the bash workdir to the calling agent's `session.header.cwd` (an explicit model `workdir` still wins; a relative one resolves against the session cwd; with no session cwd the executor falls back to its own config / `process.cwd()`). So the server no longer has to be launched in the workspace — an editor can open any project folder, and N sessions over one connection can each target a different directory. (`additionalDirectories` and `mcpServers` are still rejected: widening tool/filesystem/protocol scope is separate work.)
Each session runs in its own workspace, recorded as the session's `SessionHeader.cwd`. On `session/new` the (absolute) request `cwd` becomes that header cwd; on `session/load` the resumed session keeps its PERSISTED header cwd and the request `cwd` must be absolute and equal to it, so the editor and bash executor agree on the workspace before an agent is constructed. A load whose persisted session has no absolute cwd is REJECTED up front via a metadata-only `list()` check, BEFORE resume constructs an agent (else bash would silently fall back to the server's launch dir, and a post-resume reject would leak the registered agent). `dsh-tool-bash` then defaults the bash workdir to the calling agent's `session.header.cwd` (an explicit model `workdir` still wins; a relative one resolves against the session cwd; with no session cwd the executor falls back to its own config / `process.cwd()`). So the server no longer has to be launched in the workspace — an editor can open any project folder, and N sessions over one connection can each target a different directory. (`additionalDirectories` is still rejected: widening the tool/filesystem scope beyond the single cwd is a separate sandbox concern.)
## Tool-call presentation
How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, and optional `content` blocks shown alongside) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.)
The `tool/result` session event carries only `{ callId, content, isError }` — not the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
## Terminal card (capability-gated)
A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the neutral `terminal` field on its presentation (`dsh-tools`: a `{ cwd?, output?, exitCode?, signal? }` shape on `ToolCallPresentation`/`ToolResultPresentation`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`:
- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the tool's explicit absolute `terminal.cwd`, else a relative `terminal.cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). Any pending `content` the tool supplied (e.g. bash's `description`) renders BEFORE the terminal block, so the description sits above the card.
- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the captured output) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the tool reported a structured exit. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call.
When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md).
## Settle-exactly-once
@@ -50,14 +65,14 @@ Teardown reaches quiescence: for EVERY live session settle any pending prompt as
## Known limitations (tracked TODOs)
- **`TODO(rfc010-permission-gate)`** — the `tools/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. RFC 010/011 stay `proposed` until the gate (and per-session permission ownership) land.
- **`TODO(rfc010-permission-gate)`** — the `tools/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/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land.
- **`TODO(rfc010-cancel-prestep)`** — `session/cancel` (and teardown/disconnect) is honest RPC/UI cancellation plus best-effort abort: a *running* step is aborted, but a turn that is queued-but-not-yet-started (the gap before `agent.abort()` has an `AbortController` to signal) may still run to completion. This same window means disposal/disconnect can return while one short queued turn per session still runs, and a prompt accepted right after a pre-step cancel can be batched into the cancelled turn (the loop merges queued messages into one turn). A loop-level queue-aware cancel will close this; the single-in-flight-per-session rule bounds the worst case to one extra prompt per session.
- **`TODO(rfc010-agent-disposal)`** — the factory (`ctx.agents.create`/`resume`) returns no per-agent disposer, so teardown aborts+drains each agent but cannot individually unregister it; on a bare client disconnect (no host dispose) the idled agents linger in `ctx.agents` until the host context disposes. A reconnect spins up a fresh context, so this strands no work; a per-agent disposal seam is the follow-up.
- **`additionalDirectories` / `mcpServers`** — rejected. A session operates in its single `cwd` and no MCP bridge is wired yet; silently ignoring requested roots or servers would desync client expectations.
- **`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
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and RFC 010 § Risks. A stderr exporter is fine for logging.
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
## Running
@@ -68,8 +83,7 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa
"agent_servers": {
"DeepSeek Harness": {
"command": "pnpm",
"args": ["--dir", "/path/to/deepseek-harness", "run", "demo:acp"],
"env": { "DEEPSEEK_API_KEY": "sk-…" }
"args": ["--dir", "/path/to/deepseek-harness", "run", "demo:acp"]
}
}
}

View File

@@ -29,16 +29,19 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -8,7 +8,7 @@
* the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory,
* and `dsh-session-persistence` (for `session/load`). It maps:
*
* - `initialize` → protocol-version negotiation, baseline prompt capabilities
* - `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
@@ -34,7 +34,7 @@
import type { Context } from 'cordis'
import { Readable, Writable } from 'node:stream'
import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import Schema from 'schemastery'
import {
AgentSideConnection,
@@ -60,6 +60,7 @@ import {
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools'
// 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'
@@ -73,8 +74,10 @@ import {
export const name = 'acp'
// 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`.
export const inject = ['agents', 'sessions', 'sessionPersistence']
// 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']
/**
* Build an ACP "invalid params" error whose human detail rides in the message.
@@ -131,6 +134,24 @@ export const Config: Schema<AcpConfig> = Schema.object({
interface SessionRecord {
sessionId: string
agent: Agent
/**
* Resolves tool-owned presentation for THIS session's tool calls and remembers
* each in-flight call's `(name, args)` so the matching `tool/result` can find
* its tool. Per-session so two concurrent sessions never cross their in-flight
* tool state.
*/
presenter: ToolPresenter
/**
* Whether THIS session renders shell tools as terminal cards — snapshotted
* from the client's `_meta.terminal_output` capability at session creation
* (`session/new`/`session/load`), NOT re-read live. A capability snapshot per
* session means the `tool_call` (which registers the terminal) and the matching
* `tool_call_update` (which streams its output) ALWAYS agree, even if a later
* `initialize` mutates the connection-level capability between them — otherwise
* a re-`initialize` mid-call could orphan a `terminal_output` (call non-terminal,
* result terminal) or clobber the card (call terminal, result non-terminal).
*/
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
@@ -172,6 +193,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
const agentName = config.agentName ?? 'deepseek-harness-acp'
const agentVersion = config.agentVersion ?? '0.0.1'
// 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
const tools = ctx.tools
// A new ToolPresenter per session (and a throwaway per load replay), each given
// this warn sink so a throwing tool presenter is logged, not propagated.
const makePresenter = (): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) })
// 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
@@ -187,6 +223,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
// 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`. 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
// exists after `newSession`, which the client calls after construction), so
@@ -225,7 +266,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
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 */
void Promise.resolve(conn.sessionUpdate(notification)).catch((error: unknown) => {
ctx.logger.warn(`acp: session/update failed: ${String(error)}`)
logger.warn(`acp: session/update failed: ${String(error)}`)
})
}
@@ -259,7 +300,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
ctx.on('session/event', (session, event: SessionEvent) => {
const rec = sessions.get(session.header.id)
if (rec === undefined) return
streamSessionEventUpdate(rec.sessionId, event, notify, { includeUserMessages: false })
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') {
@@ -355,12 +399,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
// exactly PROTOCOL_VERSION; any other requested version negotiates
// down to ours (the client disconnects if it can't speak it).
const protocolVersion = params.protocolVersion === PROTOCOL_VERSION ? params.protocolVersion : PROTOCOL_VERSION
// Remember the Zed terminal-output `_meta` capability: when set, bash and
// other shell tools render as a terminal card (see streamSessionEventUpdate
// + the terminal-rendering RFC). `_meta` is `{[k]: unknown} | null`, so
// narrow defensively to a strict boolean true.
terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
return Promise.resolve({
protocolVersion,
agentInfo: { name: agentName, version: agentVersion },
agentCapabilities: {
loadSession: true,
// Baseline text/resource_link only: no image/audio/embedded resource, no mcpCapabilities.
// Baseline prompt blocks only: text plus resource_link rendered as
// text. No image/audio/embeddedContext, no mcpCapabilities.
promptCapabilities: { image: false, audio: false, embeddedContext: false },
},
authMethods: [],
@@ -376,15 +426,16 @@ export function apply(ctx: Context, config: AcpConfig): void {
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
assertOpen()
validateWorkspaceParams(params)
validateMcpServers(params)
const sessionId = randomUUID()
const agent = ctx.agents.create({
const agent = agents.create({
agentId: sessionId,
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
bySession.set(agent, sessionId)
sessions.set(sessionId, { sessionId, agent, inflight: undefined })
sessions.set(sessionId, { sessionId, agent, presenter: makePresenter(), terminalEnabled: terminalOutputCap, inflight: undefined })
return Promise.resolve({ sessionId })
},
@@ -394,6 +445,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
throw invalidParams(`session ${params.sessionId} is already loaded`)
}
validateWorkspaceParams(params)
validateMcpServers(params)
// 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
@@ -413,7 +465,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// 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 ctx.sessionPersistence.list()).find(m => m.id === params.sessionId)
const meta = (await sessionPersistence.list()).find(m => m.id === params.sessionId)
if (meta !== undefined && (meta.cwd === undefined || !isAbsolute(meta.cwd))) {
throw invalidParams(
`session ${params.sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`,
@@ -422,7 +474,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (meta !== undefined && meta.cwd !== params.cwd) {
throw invalidParams(`session ${params.sessionId} cwd mismatch: persisted ${meta.cwd}, requested ${params.cwd}`)
}
const agent = await ctx.agents.resume({
const agent = await agents.resume({
agentId: params.sessionId,
resumeSessionId: params.sessionId,
agentOptions: agentOptions(config),
@@ -440,14 +492,34 @@ export function apply(ctx: Context, config: AcpConfig): void {
throw invalidParams('connection closed during session/load')
}
bySession.set(agent, params.sessionId)
sessions.set(params.sessionId, { sessionId: params.sessionId, agent, inflight: undefined })
// Snapshot the terminal capability ONCE for this session (used by both
// the replay below and the post-load live stream) so a later
// `initialize` can't desync the call/result of a tool card.
const terminalEnabled = terminalOutputCap
const record: SessionRecord = {
sessionId: params.sessionId, agent, presenter: makePresenter(), terminalEnabled, inflight: undefined,
}
sessions.set(params.sessionId, record)
// 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()
const replayTerminal: TerminalRendering = {
enabled: terminalEnabled,
cwd: agent.session.header.cwd,
}
for (const event of agent.session.events) {
streamSessionEventUpdate(params.sessionId, event, notify)
streamSessionEventUpdate(params.sessionId, event, notify, replayPresenter, replayTerminal)
}
return {}
} finally {
@@ -462,7 +534,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
throw invalidParams('a prompt is already in flight for this session')
}
if (promptHasUnsupportedContent(params.prompt)) {
throw invalidParams('only text and resource_link prompt content is supported; image/audio/resource blocks are rejected rather than silently dropped')
throw invalidParams('only text and resource_link prompt content is supported; image/audio/embedded resource blocks are rejected rather than silently dropped')
}
const text = acpPromptToText(params.prompt)
if (text.trim().length === 0) {
@@ -543,13 +615,19 @@ export function apply(ctx: Context, config: AcpConfig): void {
* loop-level change); the single-in-flight-per-session rule bounds the worst
* case to one short queued turn per session.
*
* The agents themselves are NOT individually disposed/unregistered here — the
* factory (`ctx.agents.create`/`resume`) registers each on the AgentLoop fiber
* and returns no per-agent disposer, so registry entries are reclaimed when
* the host context disposes. On a bare client disconnect (without a host
* dispose) the idled agents linger in `ctx.agents` until shutdown; a reconnect
* spins up a fresh context, so this does not strand work. A per-agent disposal
* seam is a follow-up (TODO(rfc010-agent-disposal)).
* The agents are NOT individually disposed/unregistered here. The factory
* (`ctx.agents.create`/`resume`) registers each via `AgentLoop.start`'s
* `this.ctx.effect(...)`; because the factory is reached through this bridge's
* traceable service proxy, that effect's `this.ctx` is the CALLER context (the
* bridge fiber), so every registry entry is bound to the bridge fiber and is
* reclaimed when the bridge fiber disposes (whole-context dispose, or an
* ACP-only HMR `acpFiber.dispose()` — both unregister all the bridge's
* agents). What this teardown path handles is a bare client disconnect, which
* resolves `conn.closed` WITHOUT disposing the fiber: each live agent is
* idled+aborted here but stays in `ctx.agents` until the fiber is disposed.
* Since a reconnect spins up a fresh context, the lingering idle agents strand
* no work. A per-agent disposal seam (unregister on disconnect) is a follow-up
* (TODO(rfc010-agent-disposal)).
*/
let quiescing: Promise<void> | undefined
const quiesce = (): Promise<void> => {
@@ -587,7 +665,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
mid-run), and there is nothing else to act on once the connection is gone —
the swallow mirrors notify(). */
void conn.closed.then(quiesce).catch((error: unknown) => {
ctx.logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
})
/* v8 ignore stop */
@@ -609,28 +687,31 @@ export function agentOptions(config: AcpConfig): { model?: string; systemPrompt?
/**
* 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). What the cwd is USED for differs by method, and this
* validator only enforces shape:
* 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` is shape-checked only; the RESUMED
* session keeps its PERSISTED `header.cwd`, which stays authoritative for the
* bash workdir — the request cwd does not override it.
* - `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` and `mcpServers` must still be empty:
* widening tool/filesystem/protocol scope is separate, unimplemented work, and
* silently ignoring requested roots/servers would desync the client's UI. Both
* request shapes carry the same workspace/scope fields, so one validator covers
* both.
* 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[]; mcpServers?: unknown[] }): void {
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void {
if (!isAbsolute(params.cwd)) {
throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
}
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
throw invalidParams('additionalDirectories is not supported in this MVP')
}
}
function validateMcpServers(params: { mcpServers?: unknown[] }): void {
if (params.mcpServers !== undefined && params.mcpServers.length > 0) {
throw invalidParams('mcpServers is not supported in this MVP')
}
@@ -649,6 +730,14 @@ function validateWorkspaceParams(params: { cwd: string; additionalDirectories?:
* - `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, usage, …) produce
* no client update.
*/
@@ -656,6 +745,8 @@ export function streamSessionEventUpdate(
sessionId: string,
event: SessionEvent,
notify: (notification: SessionNotification) => void,
presenter: Pick<ToolPresenter, 'call' | 'result'> = nullToolPresenter,
terminal: TerminalRendering = noTerminalRendering,
options: { includeUserMessages?: boolean } = {},
): void {
const includeUserMessages = options.includeUserMessages ?? true
@@ -683,27 +774,64 @@ export function streamSessionEventUpdate(
return
}
case 'tool/call': {
const present = presenter.call(event.data.callId, event.data.name, event.data.arguments)
// A terminal-rendered call (a shell command) gets a terminal CARD when the
// client supports it: a `terminal` content block plus `_meta.terminal_info`
// (the cwd header). Otherwise it is an ordinary tool_call and the output
// arrives as text on the result. See the terminal-rendering RFC.
const asTerminal = present.terminal !== undefined && terminal.enabled
// The tool's pending content (e.g. bash's `description`) renders ABOVE the
// card; when the card is shown, append the terminal block AFTER it so the
// description sits over the command (Zed renders content blocks in order).
// Without the capability the description still renders as the card's body.
const callContent: ({ type: 'content'; content: AcpContentBlock } | { type: 'terminal'; terminalId: string })[] = [
...present.content !== undefined ? toolResultContent(present.content) : [],
...asTerminal ? [{ type: 'terminal' as const, terminalId: event.data.callId }] : [],
]
notify({
sessionId,
update: {
sessionUpdate: 'tool_call',
toolCallId: event.data.callId,
title: event.data.name,
kind: toolKindFor(event.data.name),
title: present.title,
kind: present.kind,
status: 'in_progress',
rawInput: parseToolArguments(event.data.arguments),
...present.rawInput !== undefined ? { rawInput: present.rawInput } : {},
...callContent.length > 0 ? { content: callContent } : {},
...asTerminal
? { _meta: { terminal_info: { terminal_id: event.data.callId, cwd: terminalCwd(present.terminal, terminal.cwd) } } }
: {},
},
})
return
}
case 'tool/result': {
const present = presenter.result(event.data.callId, event.data.content, event.data.isError)
const term = present.terminal
// When the call rendered as a terminal AND the client is capable, the output
// and exit status ride on `_meta` (the terminal card consumes them) and the
// text `content` is OMITTED: a `tool_call_update.content` REPLACES the call's
// content collection in Zed, so sending the fenced ```console block here
// would clobber the terminal content block the call installed. The incapable
// path keeps sending `content` (the fenced fallback is the only rendering).
const asTerminal = term?.output !== undefined && terminal.enabled
const terminalResultMeta = asTerminal
? {
_meta: {
terminal_output: { terminal_id: event.data.callId, data: term.output },
...terminalExitMeta(event.data.callId, term),
},
}
: {}
notify({
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: event.data.callId,
status: event.data.isError ? 'failed' : 'completed',
content: toolResultContent(event.data.content),
...asTerminal ? {} : { content: toolResultContent(present.content) },
...present.title !== undefined ? { title: present.title } : {},
...terminalResultMeta,
},
})
return
@@ -715,8 +843,155 @@ export function streamSessionEventUpdate(
}
}
/**
* Per-connection terminal-rendering context threaded into
* {@link streamSessionEventUpdate}: whether the client advertised the
* `_meta.terminal_output` capability, and the session's workspace cwd (the
* default terminal-card header when a tool doesn't supply its own). Kept out of
* the pure translator's required params so the no-capability / no-presenter
* tests stay terse.
*/
export interface TerminalRendering {
enabled: boolean
/** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */
cwd: string | undefined
}
/** Default: terminal rendering off (the ` ```console ` text fallback path). */
const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
/**
* Resolved pending-state presentation the bridge feeds into a `tool_call`
* update: a title is always present (tool name when the tool gives none), `kind`
* and `rawInput` are optional.
*/
interface ResolvedCallPresentation {
title: string
kind: ToolCallKind
rawInput?: unknown
/** UI content shown on the pending call (e.g. a bash description text block above the card). */
content?: ContentBlock[]
/** Tool's request to render as a terminal (the pending side carries the cwd). */
terminal?: ToolTerminal
}
/** Resolved completed-state presentation fed into a `tool_call_update`. */
interface ResolvedResultPresentation {
/** UI content for the result (harness blocks; the tool may reformat, else the raw result). */
content: ContentBlock[]
/** Optional replacement title for the completed call. */
title?: string
/** Tool's terminal output/exit for a terminal-rendered call (the result side). */
terminal?: ToolTerminal
}
/**
* Resolves tool-owned presentation for a session's tool-call events. A tool
* declares `presentCall`/`presentResult` (see `dsh-tools`); this looks them up
* by name in the registry and applies the generic fallback when a tool defines
* neither.
*
* The `tool/result` session event carries only `{ callId, content, isError }` —
* NOT the tool name or args — so to call a tool's `presentResult` (which needs
* both), the presenter remembers each `tool/call`'s `{ name, args }` 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<string, { name: string; args: unknown; isTerminal: boolean }>()
/**
* @param tools the registry to resolve tool definitions by name.
* @param onError invoked when a tool's `presentCall`/`presentResult` THROWS;
* the presenter swallows the error and falls back to the generic
* presentation so a buggy display callback can never fail a live turn or a
* `session/load` replay (AGENTS.md "contain callback exceptions at the
* boundary"). Defaults to a no-op for callers that don't supply a logger.
*/
constructor(
private readonly tools: Pick<ToolRegistry, 'get'>,
private readonly onError: (message: string) => void = () => {},
) {}
/** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */
call(callId: string, name: string, argsJson: string): ResolvedCallPresentation {
const args = parseToolArguments(argsJson)
let present: ToolCallPresentation | undefined
try {
present = this.tools.get(name)?.presentCall?.(args)
} catch (error: unknown) {
// A throwing presentCall must not break streaming: log and fall back.
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
present = undefined
}
if (present === undefined) {
// No tool-owned presentation: fall back to the tool name as the title and
// the full parsed args as the raw input (the pre-seam behavior). A generic
// call is never a terminal, so a later result can't emit terminal output.
this.pending.set(callId, { name, args, isTerminal: false })
return { title: name, kind: toolKindFor(name), rawInput: args }
}
// Remember whether THIS call rendered as a terminal, so `result()` only emits
// terminal output/exit for a call that actually registered a terminal — a
// `presentResult().terminal` without a matching `presentCall().terminal`
// would otherwise orphan `_meta.terminal_output` to a terminal Zed never made.
this.pending.set(callId, { name, args, isTerminal: present.terminal !== undefined })
return {
title: present.title,
kind: present.kind ?? 'other',
rawInput: present.rawInput,
...present.content !== undefined ? { content: present.content } : {},
...present.terminal !== undefined ? { terminal: present.terminal } : {},
}
}
/** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */
result(callId: string, content: ContentBlock[], isError: boolean): ResolvedResultPresentation {
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 { content }
let present: ToolResultPresentation | undefined
try {
present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError })
} catch (error: unknown) {
// 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 { content }
return {
content: present.content ?? content,
...present.title !== undefined ? { title: present.title } : {},
// Only propagate terminal output/exit when the PENDING call registered a
// terminal (finding: orphan terminal output otherwise). A result-only
// terminal with no matching call-side terminal is dropped.
...present.terminal !== undefined && call.isTerminal ? { terminal: present.terminal } : {},
}
}
}
/**
* The no-op presenter used when no tool registry is available (e.g. the pure
* translator tests): every tool gets the generic fallback presentation, and
* results pass their raw content through unchanged.
*/
export const nullToolPresenter: Pick<ToolPresenter, 'call' | 'result'> = {
call: (_callId, name, argsJson) => ({ title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }),
result: (_callId, content) => ({ content }),
}
/** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */
function toolKindFor(name: string): 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other' {
function toolKindFor(name: string): ToolCallKind {
if (name === 'bash' || name === 'bash_output' || name === 'bash_kill') return 'execute'
if (name === 'read' || name.startsWith('read')) return 'read'
if (name === 'write' || name === 'edit' || name.startsWith('edit')) return 'edit'
@@ -745,4 +1020,35 @@ function toolResultContent(blocks: ContentBlock[]): { type: 'content'; content:
return out
}
export default apply
/**
* Resolve the terminal card's header cwd. The tool's `terminal.cwd` (a model
* `workdir`) wins when ABSOLUTE; a RELATIVE one resolves against the session
* cwd (matching how `dsh-tool-bash` resolves a relative workdir for execution,
* so the header matches where the command actually ran); when the tool gives no
* cwd, the session workspace cwd is the default. Returns `undefined` only when
* neither the tool nor the session supplies one (Zed then shows "current
* directory").
*/
function terminalCwd(term: ToolTerminal | undefined, sessionCwd: string | undefined): string | undefined {
const toolCwd = term?.cwd
if (toolCwd === undefined) return sessionCwd
if (isAbsolute(toolCwd)) return toolCwd
return sessionCwd !== undefined ? resolvePath(sessionCwd, toolCwd) : toolCwd
}
/** The `terminal_exit` `_meta` entry for a completed terminal call. */
interface TerminalExitMeta {
terminal_exit?: { terminal_id: string; exit_code?: number; signal?: string }
}
/**
* Build the optional `terminal_exit` portion of a `tool_call_update`'s `_meta`
* from the tool's terminal result: a `signal` death yields `{signal}`, an
* `exitCode` yields `{exit_code}`, and neither yields nothing (the card simply
* shows no exit pill). Spread into the `_meta` object alongside `terminal_output`.
*/
function terminalExitMeta(callId: string, term: ToolTerminal): TerminalExitMeta {
if (term.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: term.signal } }
if (term.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: term.exitCode } }
return {}
}

View File

@@ -38,7 +38,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// 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: [], childFiber: true })
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
await harness.acpFiber.dispose() // tear down ONLY the bridge
@@ -48,6 +48,25 @@ describe('acp bridge — disposal & HMR safety', () => {
await harness.dispose()
})
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. 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: [] })
expect(harness.ctx.agents.get(sessionId)).toBeDefined()
await harness.acpFiber.dispose() // tear down ONLY the bridge
expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
await harness.dispose()
})
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

View File

@@ -18,6 +18,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import {
ClientSideConnection,
ndJsonStream,
@@ -148,8 +150,14 @@ export async function makeBridgeHarness(options: {
script?: (StreamChunk[] | 'hang')[]
config?: Partial<AcpConfig>
storageDir: string
/** Mount the bridge in a disposable child fiber (for the ACP-only-HMR test). */
childFiber?: boolean
/**
* Plug the REAL `dsh-bash-local` executor + `dsh-tool-bash` tools (instead of
* a test's own inline tool). Lets a test drive the actual `bash` tool — its
* real `presentCall`/`presentResult` — through the bridge, so tool-call UI
* tests verify the SHIPPING tool, not a stand-in (AGENTS.md "prefer the real
* implementation over a mock in tests").
*/
withBash?: boolean
} = { storageDir: '' }): Promise<BridgeHarness> {
const adapter = new MockAdapter(options.script ?? [])
@@ -161,6 +169,10 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
if (options.withBash) {
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash)
}
ctx.llm.registerAdapter(['mock'], adapter)
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the
@@ -225,21 +237,24 @@ export async function makeBridgeHarness(options: {
// override means "no model at all".
const cfg: AcpConfig = { stream: agentStream, ...options.config }
if (!(options.config && 'model' in options.config)) cfg.model = 'mock'
// By default apply the bridge directly on the root ctx (services ungated). For
// the ACP-only-HMR test, `childFiber: true` mounts it in a CHILD fiber instead
// so the test can dispose JUST the bridge while the rest of the harness stays
// up — its disposer (`harness.acpFiber.dispose()`) tears down only the
// bridge's listeners/effect. (Child-fiber service tracing gates the async
// persistence path, so the load-replay tests use the default direct mount.)
if (options.childFiber) {
harness.acpFiber = await ctx.plugin({
name: 'acp-test',
inject: ['agents', 'sessions', 'sessionPersistence'],
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
})
} else {
AcpPlugin.apply(ctx, cfg)
}
// Mount the bridge the way production does: as a cordis PLUGIN (via
// `ctx.plugin` with the real `inject`), NOT `AcpPlugin.apply(ctx, cfg)`
// directly on the root ctx. The plugin fiber is the faithful reproduction —
// the bridge's `apply` runs inside the fiber's injection scope, and its ACP
// handlers later run from the JSON-RPC read loop OUTSIDE that scope, exactly
// as under the example's cordis.yml. (Mounting directly on root made every
// service an ungated property and hid the "cannot get property … without
// inject" failure that bit a real Zed session.) `harness.acpFiber.dispose()`
// tears down JUST the bridge (its listeners + effect) for the HMR test.
harness.acpFiber = await ctx.plugin({
name: 'acp-test',
// Use the bridge's REAL exported `inject` so this never drifts from the
// plugin's actual dependency list (adding a service to the bridge must not
// require editing the harness — a hardcoded list silently broke when `tools`
// was added). The bridge programs against the interface packages only.
inject: [...AcpPlugin.inject],
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
})
harness.client = new ClientSideConnection(makeClient, clientStream)
return harness

View File

@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
/** Concatenate the text of all agent_message_chunk updates. */
function messageText(updates: CapturedUpdate[]): string {
@@ -56,6 +56,79 @@ describe('acp bridge — session/load replay', () => {
expect(userText).toBe('remember this')
})
it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => {
// A turn with a REAL bash tool call is persisted, then loaded by a fresh
// bridge. The replayed tool_call/tool_call_update must carry the tool's OWN
// presentation — identical to how it streamed live — via a throwaway
// presenter that pairs call→result as the log replays in order. Uses the
// shipping tool (withBash), not a stand-in (AGENTS.md "prefer the real
// implementation over a mock in tests").
live = await makeBridgeHarness({
storageDir,
withBash: true,
script: [toolCallResponse('c1', 'bash', { command: 'echo hello', description: 'Print a greeting' }), textResponse('done')],
})
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
await live.dispose()
live = undefined
// A fresh bridge — also with the real bash tool, since the presentation is
// resolved from the live registry at replay time — loads the session.
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
expect(call).toMatchObject({ toolCallId: 'c1', title: 'echo hello', kind: 'execute', rawInput: 'echo hello' })
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// Capability OFF on this loader: the description renders as a content block, no terminal block.
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
expect(update?.sessionUpdate).toBe('tool_call_update')
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
const content = update.content as { content: { text: string } }[]
expect(content[0]?.content.text).toBe('```console\nhello\n```')
})
it('replays a persisted bash call as a TERMINAL card when the loader advertises the capability', async () => {
// The presentation is resolved at replay time, so a loader that advertised
// _meta.terminal_output must reconstruct the terminal card (content + _meta)
// from the persisted log — identical to how it would have streamed live.
live = await makeBridgeHarness({
storageDir,
withBash: true,
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
})
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
await live.dispose()
live = undefined
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// Replay reconstructs the terminal card: description block, then terminal block.
expect(call.content).toEqual([
{ type: 'content', content: { type: 'text', text: 'Greet' } },
{ type: 'terminal', terminalId: 'c1' },
])
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
// Terminal mode: content omitted, output + exit on _meta — matching live.
expect(update.content).toBeUndefined()
const meta = update._meta as { terminal_output?: { data: string }; terminal_exit?: { exit_code?: number } }
expect(meta.terminal_output?.data).toBe('hi\n')
expect(meta.terminal_exit?.exit_code).toBe(0)
})
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
// A session/load is mid-resume() when the client transport closes. The load
// must NOT end up with a live registered agent for the connection that is

View File

@@ -2,21 +2,29 @@ import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionNotification } from '@agentclientprotocol/sdk'
import { streamSessionEventUpdate, agentOptions } from '../src/index.ts'
import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts'
/** Collect the updates a single event produces. */
/** Collect the updates a single event produces (no presenter → generic fallback). */
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
streamSessionEventUpdate('s1', event, n => out.push(n.update))
return out
}
/** Collect the updates emitted by the live prompt stream (user echo suppressed). */
function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
streamSessionEventUpdate('s1', event, n => out.push(n.update), { includeUserMessages: false })
streamSessionEventUpdate('s1', event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false })
return out
}
/** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistry, 'get'> {
const map = new Map(tools.map(t => [t.name, t]))
return { get: name => map.get(name) }
}
function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
return { type, seq: 0, time: 0, data } as SessionEvent
}
@@ -37,7 +45,7 @@ describe('streamSessionEventUpdate', () => {
.toEqual([])
})
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput', () => {
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput (generic fallback, no presenter)', () => {
const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }))
expect(updates).toEqual([{
sessionUpdate: 'tool_call',
@@ -112,6 +120,291 @@ describe('streamSessionEventUpdate', () => {
})
})
describe('ToolPresenter (tool-owned presentation via the tool registry)', () => {
/** A tool whose presentCall/presentResult mirror what tool-bash declares. */
const bashLike: ToolDefinition = {
name: 'bash',
description: 'run a command',
parameters: {},
execute: async () => [],
presentCall: (args: unknown) => {
const a = args as { command: string; description: string }
return { title: a.description, kind: 'execute', rawInput: a.command }
},
presentResult: (_args: unknown, result: { content: { type: string }[] }) => ({
content: [{ type: 'text', text: `wrapped:${result.content.length}` }],
}),
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter)
return out
}
it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => {
const presenter = new ToolPresenter(registryOf(bashLike))
const [update] = updatesWith(presenter, evt('tool/call', {
turn: 1, step: 1, callId: CallId('c1'), name: 'bash',
arguments: JSON.stringify({ command: 'ls -la', description: 'List files' }),
}))
expect(update).toEqual({
sessionUpdate: 'tool_call',
toolCallId: 'c1',
title: 'List files',
kind: 'execute',
status: 'in_progress',
rawInput: 'ls -la',
})
})
it('tool/result uses the tool to reformat content (resolved by the remembered tool/call)', () => {
const presenter = new ToolPresenter(registryOf(bashLike))
const updates = updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }),
)
expect(updates[1]).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'wrapped:1' } }],
})
})
it('a result with NO preceding call (unknown callId) falls back to the raw content', () => {
const presenter = new ToolPresenter(registryOf(bashLike))
// No tool/call for c9 → presenter has nothing remembered → generic fallback.
const [update] = updatesWith(presenter, evt('tool/result', {
turn: 1, step: 1, callId: CallId('c9'), content: [{ type: 'text', text: 'raw' }], isError: false,
}))
expect(update).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c9',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'raw' } }],
})
})
it('a tool with no presentCall/presentResult gets the generic fallback (title = name)', () => {
const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, execute: async () => [] }
const presenter = new ToolPresenter(registryOf(plain))
const [update] = updatesWith(presenter, evt('tool/call', {
turn: 1, step: 1, callId: CallId('c1'), name: 'plain', arguments: '{"a":1}',
}))
expect(update).toMatchObject({ title: 'plain', kind: 'other', rawInput: { a: 1 } })
})
it('a presentation that omits kind/content/rawInput uses the defaults (kind other, raw result content kept)', () => {
// A minimal tool-owned presentation: presentCall returns only a title (no
// kind → defaults to `other`, no rawInput → omitted); presentResult returns
// only a title (no content → the raw result content is kept).
const minimal: ToolDefinition = {
name: 'mini',
description: 'm',
parameters: {},
execute: async () => [],
presentCall: () => ({ title: 'Doing a thing' }),
presentResult: () => ({ title: 'Did the thing' }),
}
const presenter = new ToolPresenter(registryOf(minimal))
const updates = updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'mini', arguments: '{}' }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'kept' }], isError: false }),
)
// No kind → 'other'; no rawInput key at all.
expect(updates[0]).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'Doing a thing', kind: 'other', status: 'in_progress' })
// Title replaced; content falls back to the raw result content.
expect(updates[1]).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'kept' } }],
title: 'Did the thing',
})
})
it('holds ONLY in-flight calls: the callId entry is removed once its result is presented', () => {
const presenter = new ToolPresenter(registryOf(bashLike))
updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'o' }], isError: false }),
)
// A SECOND result for the same callId now finds nothing remembered, so it
// falls back to raw content (proving the first result consumed the entry —
// the map does not retain finished calls).
const [late] = updatesWith(presenter, evt('tool/result', {
turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'late' }], isError: false,
}))
expect(late).toMatchObject({ content: [{ type: 'content', content: { type: 'text', text: 'late' } }] })
})
it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => {
// A buggy tool whose display callbacks throw must NOT fail a live turn or a
// session/load replay (AGENTS.md "contain callback exceptions at the
// boundary"). The presenter swallows the throw, reports via onError, and
// falls back to the generic presentation.
const boom: ToolDefinition = {
name: 'boom',
description: 'b',
parameters: {},
execute: async () => [],
presentCall: () => { throw new Error('call boom') },
presentResult: () => { throw new Error('result boom') },
}
const errors: string[] = []
const presenter = new ToolPresenter(registryOf(boom), msg => errors.push(msg))
const updates = updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{"a":1}' }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }),
)
// tool/call fell back to title=name, raw args as rawInput.
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom', kind: 'other', rawInput: { a: 1 } })
// tool/result fell back to the raw content.
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
// Both throws were reported, not propagated.
expect(errors).toHaveLength(2)
expect(errors[0]).toContain('presentCall threw')
expect(errors[1]).toContain('presentResult threw')
})
it('contains a throwing presenter even with the DEFAULT (no-op) onError sink', () => {
// Constructed without an onError sink (the default `() => {}`): a throwing
// presenter is still swallowed and falls back generically — the absence of a
// logger must not turn a display bug into a propagated exception.
const boom: ToolDefinition = {
name: 'boom',
description: 'b',
parameters: {},
execute: async () => [],
presentCall: () => { throw new Error('call boom') },
presentResult: () => { throw new Error('result boom') },
}
const presenter = new ToolPresenter(registryOf(boom))
const updates = updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{}' }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }),
)
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' })
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
})
})
describe('terminal-card mapping (capability-gated)', () => {
// A tool that asks to render as a terminal — a stand-in for tool-bash's shape,
// letting us drive the bridge's terminal mapping without the real executor.
type CallTerm = { cwd?: string } | undefined
type ResultTerm = { output?: string; exitCode?: number; signal?: string } | undefined
const termTool = (callTerminal: CallTerm, resultTerminal: ResultTerm): ToolDefinition => ({
name: 'bash',
description: 'run a command',
parameters: {},
execute: async () => [],
presentCall: (args: unknown) => ({
title: (args as { command: string }).command,
kind: 'execute',
rawInput: (args as { command: string }).command,
content: [{ type: 'text', text: (args as { description: string }).description }],
...callTerminal !== undefined ? { terminal: callTerminal } : {},
}),
presentResult: () => ({
content: [{ type: 'text', text: 'fallback' }],
...resultTerminal !== undefined ? { terminal: resultTerminal } : {},
}),
})
const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false })
function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
const presenter = new ToolPresenter(registryOf(tool))
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter, { enabled, cwd })
return out
}
it('capability ON: description content THEN terminal block; cwd from the session header when the tool gives none', () => {
const [call, update] = termUpdates(termTool({}, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent)
expect(call).toMatchObject({
sessionUpdate: 'tool_call',
content: [
{ type: 'content', content: { type: 'text', text: 'Greet' } },
{ type: 'terminal', terminalId: 'c1' },
],
_meta: { terminal_info: { terminal_id: 'c1', cwd: '/work/proj' } },
})
// The update OMITS content (it would clobber the terminal block) and carries output + exit.
expect(update).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
_meta: { terminal_output: { terminal_id: 'c1', data: 'hi\n' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } },
})
})
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
const [absCall] = termUpdates(termTool({ cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
const [relCall] = termUpdates(termTool({ cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent)
// Relative workdir resolved against the session cwd — the card header matches
// where execution actually ran (tool-bash resolves the same way).
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir')
// No session cwd to resolve against → the relative tool cwd is passed through as-is.
const [noSessionCwd] = termUpdates(termTool({ cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent)
expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only')
})
it('capability ON: a signal kill maps to terminal_exit.signal', () => {
const [, update] = termUpdates(termTool({}, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent)
expect((update as unknown as { _meta: { terminal_exit: unknown } })._meta.terminal_exit).toEqual({ terminal_id: 'c1', signal: 'SIGKILL' })
})
it('capability ON: a terminal result with output but NO exit/signal emits terminal_output and NO exit pill', () => {
// A terminal-rendering tool that reports no structured exit (neither exitCode
// nor signal) — the card shows output but no exit pill.
const [, update] = termUpdates(termTool({}, { output: 'partial' }), true, '/w', callEvent, resultEvent)
const meta = (update as unknown as { _meta: { terminal_output?: unknown; terminal_exit?: unknown } })._meta
expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'partial' })
expect(meta.terminal_exit).toBeUndefined()
})
it('capability OFF: no terminal block or _meta; the description content and fenced result still render', () => {
const [call, update] = termUpdates(termTool({}, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent)
expect(call).toEqual({
sessionUpdate: 'tool_call',
toolCallId: 'c1',
title: 'echo hi',
kind: 'execute',
status: 'in_progress',
rawInput: 'echo hi',
content: [{ type: 'content', content: { type: 'text', text: 'Greet' } }],
})
expect(update).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'fallback' } }],
})
})
it('orphan guard: a result-side terminal with NO call-side terminal is dropped (no orphan terminal_output)', () => {
// presentCall declares NO terminal, but presentResult returns one — the
// bridge must not emit _meta.terminal_output for a terminal Zed never made.
const [call, update] = termUpdates(termTool(undefined, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent)
// The call had no terminal → ordinary tool_call (description content, no _meta).
expect((call as { _meta?: unknown })._meta).toBeUndefined()
expect((call as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'Greet' } }])
// The result falls back to text content; NO terminal _meta.
expect((update as { _meta?: unknown })._meta).toBeUndefined()
expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'fallback' } }])
})
})
describe('agentOptions', () => {
it('includes only the fields present in config', () => {
expect(agentOptions({})).toEqual({})

View File

@@ -14,8 +14,8 @@ import {
} from './harness.ts'
/** Boilerplate: initialize + create one session, returning its id. */
async function newSession(h: BridgeHarness): Promise<string> {
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
async function newSession(h: BridgeHarness, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
return sessionId
}
@@ -75,6 +75,145 @@ describe('acp bridge — turn outcomes', () => {
expect(callIdx).toBeLessThan(updIdx)
})
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 (AGENTS.md "prefer the real implementation over a mock in tests").
// 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,
script: [
toolCallResponse('c1', 'bash', { command: 'echo hello', description: 'Print a greeting' }),
textResponse('done'),
],
})
const sessionId = await newSession(harness)
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
// presentCall: execute kind, title IS the command (an execute card hides
// rawInput, so the command is the title), the description rides as a content
// text block, the command is also rawInput for non-terminal UIs.
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
expect(call).toMatchObject({
toolCallId: 'c1',
title: 'echo hello',
kind: 'execute',
rawInput: 'echo hello',
status: 'in_progress',
})
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// Capability OFF: the description renders as the only content block (no terminal block).
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
// presentResult: the REAL command output, wrapped in a fenced console block.
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
expect(update?.sessionUpdate).toBe('tool_call_update')
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
const content = update.content as { content: { type: string; text: string } }[]
expect(content[0]?.content.text).toBe('```console\nhello\n```')
// Capability OFF (the default newSession): NO terminal _meta on either update.
expect((call as { _meta?: unknown })._meta).toBeUndefined()
expect((update as { _meta?: unknown })._meta).toBeUndefined()
})
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. 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,
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
})
// Capability lives under clientCapabilities._meta.terminal_output.
const sessionId = await newSession(harness, { _meta: { terminal_output: true } })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// The description content block FIRST (renders above the card), then a
// terminal content block keyed by the callId; terminal_info carries the
// session cwd (the bridge fills it from the session header).
expect(call.content).toEqual([
{ type: 'content', content: { type: 'text', text: 'Greet' } },
{ type: 'terminal', terminalId: 'c1' },
])
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
// In terminal mode the text content is OMITTED (a tool_call_update.content
// REPLACES the call's content — it would clobber the terminal block).
expect(update.content).toBeUndefined()
// Output rides on _meta.terminal_output; the parsed exit on _meta.terminal_exit.
const meta = update._meta as {
terminal_output?: { terminal_id: string; data: string }
terminal_exit?: { terminal_id: string; exit_code?: number; signal?: string }
}
expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'hi\n' })
expect(meta.terminal_exit).toEqual({ terminal_id: 'c1', exit_code: 0 })
})
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. 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,
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
// A re-initialize that DROPS the capability after the session exists.
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// Still a terminal card (the session's snapshot, not the mutated connection cap).
expect((call._meta as { terminal_info?: unknown }).terminal_info).toBeDefined()
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
// The result AGREES with the call: terminal output present, content omitted.
expect(update.content).toBeUndefined()
expect((update._meta as { terminal_output?: unknown }).terminal_output).toBeDefined()
})
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. 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')],
})
harness.ctx.tools.register(defineTool({
name: 'kaboom',
description: 'explodes when presented',
parameters: { x: { type: 'number' } },
async execute() { return [{ type: 'text', text: 'ok' }] },
presentCall: () => { throw new Error('present boom') },
}))
const sessionId = await newSession(harness)
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(res.stopReason).toBe('end_turn') // the turn completed despite the throw
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
// Generic fallback: title is the tool name, raw args as rawInput.
expect(call).toMatchObject({ toolCallId: 'c1', title: 'kaboom', kind: 'other', rawInput: { x: 1 } })
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
})
it('a failing tool yields a failed tool_call_update', async () => {
harness = await makeBridgeHarness({
storageDir,

View File

@@ -12,6 +12,7 @@
{ "path": "../llm" },
{ "path": "../session" },
{ "path": "../agent" },
{ "path": "../tools" },
{ "path": "../session-persistence" }
]
}