Merge remote-tracking branch 'origin/master' into codex/ask-user-question
# Conflicts: # docs/architecture.md # docs/cordis-catalog/events-and-services.md # docs/core-data-structures/core.md # docs/module-graph.md # docs/tool-catalog/tools.md # packages/README.md # packages/core/README.md # packages/core/tools/tests/gen-tool-catalog.spec.ts # packages/support/README.md # packages/support/ui-stdio/README.md # packages/ui/acp-agent/tests/built-bin.e2e.ts # packages/ui/acp/README.md # packages/ui/stdio-agent/README.md # packages/ui/stdio-agent/package.json # packages/ui/stdio-agent/src/index.ts # packages/ui/stdio-agent/src/stdio-chat.ts # packages/ui/stdio-agent/tests/built-bin.e2e.ts # packages/ui/stdio-agent/tests/readline.spec.ts # packages/ui/stdio-agent/tests/stdio-chat.spec.ts # packages/web/web/package.json # packages/web/web/tsconfig.json # pnpm-lock.yaml # scripts/gen-tool-catalog.ts
This commit is contained in:
@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
|
||||
|
||||
`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', 'tools', 'userInteraction']` — 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). `userInteraction` lets the bridge provide ACP-backed answers for tools such as `ask_user_question`.
|
||||
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — 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). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session.
|
||||
|
||||
### Config
|
||||
|
||||
@@ -16,8 +16,8 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
|
||||
|---|---|---|
|
||||
| `model` | — | Model name for created agents (must have a registered adapter). |
|
||||
| `systemPrompt` | — | Per-agent system prompt. |
|
||||
| `agentName` | `deepseek-harness-acp` | Server name reported in `initialize`. |
|
||||
| `agentVersion` | `0.0.1` | Server version reported in `initialize`. |
|
||||
|
||||
The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config.
|
||||
|
||||
## ACP method mapping
|
||||
|
||||
@@ -28,12 +28,12 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
|
||||
| `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.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` (title/kind/rawInput/content/locations owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) |
|
||||
| `elicitation/create` | `ctx.userInteraction` provider | `ask_user_question` pauses the tool call and asks the ACP client for a session-scoped form; choices use a `choice` single-select field, free-form answers use `answer`/`custom_answer`, and cancel/decline returns a structured `UserInteractionError` |
|
||||
| `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` requests to ACP form elicitations; recommended options become defaults, option descriptions are shown in enum titles, optionless requests remain free-form even when `allowCustom` is false |
|
||||
|
||||
## 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 — and `ctx.userInteraction` requests, which carry only the calling `Agent` — demux in O(1). Every `session/event`, `agent/status`, and ask-user elicitation 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)`.)
|
||||
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)`.)
|
||||
|
||||
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.
|
||||
|
||||
@@ -43,22 +43,28 @@ Each session runs in its own workspace, recorded as the session's `SessionHeader
|
||||
|
||||
## 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, optional `content` blocks shown alongside, and optional `locations` — `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along) 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 `dsh-tool-fs` `read`/`write`/`edit` tools set a `Read/Write/Edit <path>` title, a `read`/`edit` kind, and a `locations` entry for the file. (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.)
|
||||
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) and `presentResult(args, result)` (completed state) on its `dsh-tools` definition, each returning a **`card`-tagged render intent** — a discriminated union the bridge switches on. `presentCall` returns a `ToolCallView`, one of three cards:
|
||||
|
||||
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.
|
||||
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, a `kind` for the icon, the salient `rawInput` for a detail view, optional `content` blocks shown alongside, and optional `locations` (`FileLocation[]` = `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along).
|
||||
- `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card).
|
||||
- `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview.
|
||||
|
||||
`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind `other` — the bridge never sniffs a kind from the tool name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path.
|
||||
|
||||
The `tool/result` session event does not carry 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`:
|
||||
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 `terminal` card variant of its render intent (`dsh-tools`: `{ card: 'terminal', title, description?, cwd? }` from `presentCall`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` from `presentResult`); 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.
|
||||
- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the card's explicit absolute `cwd`, else a relative `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). The card's `description` renders as a content block BEFORE the terminal block, so the description sits above the card.
|
||||
- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the terminal card's `output`) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the card reported a structured `exitCode`/`signal`. 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/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
|
||||
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 a ` ```console ` text block the bridge DERIVES by fencing the terminal result's `output` (the tool no longer double-encodes the fences) — 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/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [the render-intent-union RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
|
||||
|
||||
## Settle-exactly-once
|
||||
|
||||
A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream), NOT the `agent/turn-start`/`agent/turn-end` events. One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the one signal that always fires (`closeTurn` appends it unconditionally, even when a boundary emit throws and the `agent/turn-end` EVENT is skipped). 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 `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
|
||||
A `session/prompt` resolves (or rejects) exactly once, 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.
|
||||
|
||||
## Disposal & disconnect
|
||||
|
||||
@@ -66,7 +72,7 @@ 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. [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.
|
||||
- **`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
|
||||
|
||||
@@ -63,7 +63,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `sessionCapabilities.*` | S | ❌ | ✅ | ✅ | None advertised (list/delete/resume/close/additionalDirectories/fork all off). |
|
||||
| `auth.logout` | S | ❌ | ✅ | ✅ | Not advertised. |
|
||||
| `authMethods[]` | S | ⚠️ | ✅ | ✅ | Advertised as empty (no auth required to reach the model). |
|
||||
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | From `agentName` / `agentVersion` config. |
|
||||
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | Fixed literals: `deepseek-harness-acp` / `0.0.1` (not config). |
|
||||
| `_meta` custom caps | S | ❌ | ✅ | — | E.g. Claude's `claudeCode.promptQueueing`. The bridge advertises no custom `_meta`. |
|
||||
|
||||
### 3b. `clientCapabilities` (consumed by the bridge)
|
||||
@@ -96,10 +96,10 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
|
||||
|
||||
| Feature | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; richer mapping possible. |
|
||||
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit` declared by each tool's `presentCall`; presenter-less tools render `other` (no name sniffing); richer mapping possible. |
|
||||
| `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress` → `completed`/`failed`. |
|
||||
| `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. |
|
||||
| `diff` content | S | ❌ | ✅ | ✅ | No structured diff rendering for edits (would need a diffing edit tool + presenter). |
|
||||
| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }`. For an edit or an overwrite it carries the applied hunk(s) with surrounding context (one per `replace_all` site), computed from the before/after text and persisted on the `tool/result` event as `meta`; for a create (no before-image) it is an args-derived whole-file diff. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; a successful mutation ALWAYS returns the result diff (an ACP `tool_call_update.content` replaces the call's content, so the result diff — not the model-facing text — is what survives). |
|
||||
| `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. |
|
||||
| `locations` (follow-along) | S | ✅ | ✅ | ✅ | The `read`/`write`/`edit` tools emit `{ path, line? }` file-location hints via `presentCall`. |
|
||||
| `rawInput` | S | ✅ | ⚠️ | ✅ | Parsed tool args surfaced as `rawInput`. |
|
||||
@@ -147,7 +147,6 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl
|
||||
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).
|
||||
8. **Diff tool rendering** — structured `diff` content for edit tools (the `locations` follow-along hint already ships on `read`/`write`/`edit`).
|
||||
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.
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
|
||||
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
|
||||
*
|
||||
* The mapping is total over the kinds the loop actually produces today
|
||||
* (`completed`/`aborted`/`error`/`disposed`/`max-tokens`). `TurnEndReason` is
|
||||
* (`completed`/`aborted`/`error`/`disposed`/`max-tokens`/`rejected`).
|
||||
* `TurnEndReason` is
|
||||
* merge-extensible, so an unknown future kind falls through to `end_turn` —
|
||||
* the safest default (the turn DID end; we just lack a more specific wire
|
||||
* reason) — rather than throwing into the SDK, which would reject an unknown
|
||||
@@ -34,6 +35,10 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
|
||||
* for any non-bridge caller / property test.)
|
||||
* - `disposed` → `cancelled` (the agent was torn down mid-turn — closest to a
|
||||
* cancellation from the client's perspective)
|
||||
* - `rejected` → `cancelled` (the prompt was blocked by an `agent/prompt-submit`
|
||||
* hook before any step ran — ACP has no "rejected" reason, and a
|
||||
* blocked prompt is, from the client's view, the prompt not being
|
||||
* carried out; `cancelled` is the closest legal wire reason)
|
||||
*/
|
||||
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
switch (reason.kind) {
|
||||
@@ -45,6 +50,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
return 'cancelled'
|
||||
case 'disposed':
|
||||
return 'cancelled'
|
||||
case 'rejected':
|
||||
return 'cancelled'
|
||||
case 'error':
|
||||
return 'end_turn'
|
||||
// Merge-extensible: an unknown future TurnEndReason kind still has to
|
||||
@@ -62,8 +69,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
* client as message content. Today only `text` maps; `resource_link` is an
|
||||
* ACP prompt-only input rendered into text by {@link acpPromptToText};
|
||||
* `reasoning` is surfaced via `agent_thought_chunk`
|
||||
* streaming rather than as a message block, and `tool-call`/`tool-result`/
|
||||
* `image` are handled by the tool-call update path or not advertised.
|
||||
* streaming rather than as a message block, and `tool-call`/`tool-result`
|
||||
* are handled by the tool-call update path.
|
||||
*/
|
||||
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
|
||||
switch (block.type) {
|
||||
@@ -71,7 +78,7 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock |
|
||||
return { type: 'text', text: block.text }
|
||||
// reasoning → streamed as agent_thought_chunk, not a message block
|
||||
// tool-call / tool-result → the tool_call / tool_call_update path
|
||||
// image → not advertised
|
||||
// plugin-added block types → not surfaced
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* `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/execute` permission gate is
|
||||
* `session/update` notifications. The `tools/pre-execute` permission gate is
|
||||
* deferred — see the TODO(rfc010-permission-gate) note below.
|
||||
*
|
||||
* stdout is the protocol: this plugin must run in an example that loads NO
|
||||
@@ -36,7 +36,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { isAbsolute, relative as relativePath, resolve as resolvePath, sep as pathSep } from 'node:path'
|
||||
import Schema from 'schemastery'
|
||||
import {
|
||||
AgentSideConnection,
|
||||
@@ -65,12 +65,12 @@ import {
|
||||
type StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } 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'
|
||||
@@ -239,10 +239,6 @@ export interface AcpConfig {
|
||||
model?: string
|
||||
/** Per-agent system prompt. */
|
||||
systemPrompt?: string
|
||||
/** Agent/server name reported to the client in `initialize`. */
|
||||
agentName?: string
|
||||
/** Agent/server version reported to the client in `initialize`. */
|
||||
agentVersion?: string
|
||||
/**
|
||||
* Transport stream override. Production omits this (the plugin wires
|
||||
* `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
|
||||
@@ -256,8 +252,6 @@ export interface AcpConfig {
|
||||
export const Config: Schema<AcpConfig> = Schema.object({
|
||||
model: Schema.string(),
|
||||
systemPrompt: Schema.string(),
|
||||
agentName: Schema.string().default('deepseek-harness-acp'),
|
||||
agentVersion: Schema.string().default('0.0.1'),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -321,22 +315,16 @@ interface SessionRecord {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the in-flight prompt's settle from the harness event stream. A turn
|
||||
* can end three ways the bridge must all handle (AGENTS.md "honor cross-seam
|
||||
* contracts on BOTH sides"): the normal `agent/turn-end` event; a `turn/end`
|
||||
* session event WITHOUT the agent event (a boundary emit threw inside the loop,
|
||||
* which still appends `turn/end`); or the agent erroring/settling to idle. The
|
||||
* first of these to fire settles the prompt; `settle` is then cleared so the
|
||||
* others are no-ops (settle-exactly-once).
|
||||
* Drive the in-flight prompt's settle from the harness event stream. The bridge
|
||||
* settles off the durable log: the `turn/end` session event on the
|
||||
* `session/event` feed for the prompt's own turn, with the agent
|
||||
* erroring/settling to idle as a fallback (docs/defensive-patterns.md "honor
|
||||
* cross-seam contracts on BOTH sides") for the case where a throwing peer `session/event` listener
|
||||
* starved the bridge's listener before it saw the boundary. The first of these
|
||||
* to fire settles the prompt; `settle` is then cleared so the others are no-ops
|
||||
* (settle-exactly-once).
|
||||
*/
|
||||
export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// TODO(double-default): these literals duplicate the Config schema defaults
|
||||
// (`agentName`/`agentVersion` `.default(...)` above). The Loader applies the
|
||||
// schema before apply() runs, so the `??` only fires for direct-apply unit
|
||||
// tests. Pick one home for the default to avoid drift.
|
||||
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
|
||||
@@ -437,7 +425,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// sessionUpdate returns a promise; a closed connection rejects it. The
|
||||
// update is best-effort UI feed, never load-bearing for correctness, so a
|
||||
// throwing/rejecting send must not break the turn (the chunk is emitted
|
||||
// inside the model step — see AGENTS.md "contain callback exceptions").
|
||||
// inside the model step — see docs/defensive-patterns.md "contain callback exceptions").
|
||||
/* v8 ignore next 3 -- the rejection only fires on a stdout/connection write
|
||||
failure (closed pipe), which the in-memory test transport never induces;
|
||||
the swallow is a defensive best-effort guard like the loop's emit traps */
|
||||
@@ -472,15 +460,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// the canonical log: every assistant/chunk and tool/call/result is logged, so
|
||||
// translating from the log makes live streaming and `session/load` replay
|
||||
// share the identical path (streamSessionEventUpdate). Both the owning-turn
|
||||
// capture and the settle key off the log's own `turn/start`/`turn/end` — NOT
|
||||
// the `agent/turn-start`/`agent/turn-end` EVENTS, which a throwing PEER
|
||||
// listener (cordis `emit` stops at the first throw) or a boundary-emit failure
|
||||
// can skip. `closeTurn` appends `turn/end` to the log unconditionally, and
|
||||
// `turn/start` is appended before any step runs, so within this one listener
|
||||
// we always see the prompt's turn-start (tag `inflight.turn`) then its
|
||||
// turn-end (settle). A `turn/end` settles the prompt ONLY when it is the
|
||||
// prompt's OWN turn (`inflight.turn === event.data.turn`) — a previous,
|
||||
// already-cancelled turn whose end arrives late is ignored (see
|
||||
// capture and the settle key off the log's own `turn/start`/`turn/end` — the
|
||||
// durable boundary events (there is no agent/* turn mirror). `closeTurn`
|
||||
// appends `turn/end` to the log unconditionally, and `turn/start` is appended
|
||||
// before any step runs, so within this one listener we always see the
|
||||
// prompt's turn-start (tag `inflight.turn`) then its turn-end (settle). A
|
||||
// `turn/end` settles the prompt ONLY when it is the prompt's OWN turn
|
||||
// (`inflight.turn === event.data.turn`) — a previous, already-cancelled turn
|
||||
// whose end arrives late is ignored (see
|
||||
// SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
|
||||
// has no error stop reason); other reasons resolve via the codec. Demux
|
||||
// strictly by session id: a `session/event` is routed to its own record, so
|
||||
@@ -584,7 +571,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
|
||||
return Promise.resolve({
|
||||
protocolVersion,
|
||||
agentInfo: { name: agentName, version: agentVersion },
|
||||
// Fixed server identity: this bridge IS the harness ACP server, so the
|
||||
// branding is a literal, not config (no shipped surface sets it).
|
||||
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
// Baseline prompt blocks only: text plus resource_link rendered as
|
||||
@@ -793,7 +782,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
conn = new AgentSideConnection(makeAgent, stream)
|
||||
|
||||
/**
|
||||
* Tear ALL live sessions down to quiescence (AGENTS.md "dispose must reach
|
||||
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
|
||||
* quiescence"): for each session settle any pending prompt `cancelled`, then
|
||||
* run that session's {@link AgentHandle} `dispose()` — which stops the loop
|
||||
* (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the
|
||||
@@ -967,67 +956,13 @@ 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: present.title,
|
||||
kind: present.kind,
|
||||
status: 'in_progress',
|
||||
...present.rawInput !== undefined ? { rawInput: present.rawInput } : {},
|
||||
...present.locations !== undefined ? { locations: present.locations } : {},
|
||||
...callContent.length > 0 ? { content: callContent } : {},
|
||||
...asTerminal
|
||||
? { _meta: { terminal_info: { terminal_id: event.data.callId, cwd: terminalCwd(present.terminal, terminal.cwd) } } }
|
||||
: {},
|
||||
},
|
||||
})
|
||||
const view = presenter.call(event.data.callId, event.data.name, event.data.arguments)
|
||||
notify({ sessionId, update: toolCallUpdate(event.data.callId, view, terminal) })
|
||||
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',
|
||||
...asTerminal ? {} : { content: toolResultContent(present.content) },
|
||||
...present.title !== undefined ? { title: present.title } : {},
|
||||
...terminalResultMeta,
|
||||
},
|
||||
})
|
||||
const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta)
|
||||
notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) })
|
||||
return
|
||||
}
|
||||
case 'todo/write': {
|
||||
@@ -1070,46 +1005,20 @@ export interface TerminalRendering {
|
||||
/** 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[]
|
||||
/** Files this call reads/modifies (mapped to ACP `tool_call.locations`), for editor follow-along. */
|
||||
locations?: { path: string; line?: number }[]
|
||||
/** 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.
|
||||
* declares `presentCall`/`presentResult` (see `dsh-tools`) returning a
|
||||
* `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up
|
||||
* by name in the registry and applies a generic fallback when a tool defines
|
||||
* neither. The returned view is what {@link streamSessionEventUpdate} switches on.
|
||||
*
|
||||
* The `tool/result` session event 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
|
||||
* The `tool/result` session event does NOT carry the tool name or args — so to
|
||||
* call a tool's `presentResult` (which needs both), the presenter remembers each
|
||||
* `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the
|
||||
* matching result. The map is bridge-LOCAL (not a change to the event schema or a
|
||||
* core service): one presenter per live session
|
||||
* (and a throwaway per `session/load` replay), and each entry is removed when its
|
||||
* result arrives. In the normal loop a `tool/call` is always followed by a
|
||||
* `tool/result` (the registry turns even a thrown tool into an isError result),
|
||||
* so the map holds only currently-in-flight calls. The one exception is a step
|
||||
* torn down mid-tool (an abort between `tool/call` and `tool/result`), which can
|
||||
@@ -1119,14 +1028,14 @@ interface ResolvedResultPresentation {
|
||||
* stale entry's only cost is one map slot until the session ends.
|
||||
*/
|
||||
export class ToolPresenter {
|
||||
private readonly pending = new Map<CallId, { name: string; args: unknown; isTerminal: boolean }>()
|
||||
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
|
||||
|
||||
/**
|
||||
* @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
|
||||
* `session/load` replay (docs/defensive-patterns.md "contain callback exceptions at the
|
||||
* boundary"). Defaults to a no-op for callers that don't supply a logger.
|
||||
*/
|
||||
constructor(
|
||||
@@ -1134,10 +1043,10 @@ export class ToolPresenter {
|
||||
private readonly onError: (message: string) => void = () => {},
|
||||
) {}
|
||||
|
||||
/** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */
|
||||
call(callId: CallId, name: string, argsJson: string): ResolvedCallPresentation {
|
||||
/** Pending-state render intent for a `tool/call`; remembers `(name, args, card)` for the matching result. */
|
||||
call(callId: CallId, name: string, argsJson: string): ToolCallView {
|
||||
const args = parseToolArguments(argsJson)
|
||||
let present: ToolCallPresentation | undefined
|
||||
let present: ToolCallView | undefined
|
||||
try {
|
||||
present = this.tools.get(name)?.presentCall?.(args)
|
||||
} catch (error: unknown) {
|
||||
@@ -1145,51 +1054,39 @@ export class ToolPresenter {
|
||||
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.locations !== undefined ? { locations: present.locations } : {},
|
||||
...present.terminal !== undefined ? { terminal: present.terminal } : {},
|
||||
}
|
||||
// No tool-owned presentation: fall back to the tool name as the title, the
|
||||
// full parsed args as the raw input, and kind `other` (the generic card).
|
||||
// The kind is never sniffed from the name — the bridge does not special-case
|
||||
// tool names; a tool that wants a richer kind declares `presentCall`.
|
||||
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
|
||||
this.pending.set(callId, { name, args, card: view.card })
|
||||
return view
|
||||
}
|
||||
|
||||
/** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */
|
||||
result(callId: CallId, content: ContentBlock[], isError: boolean): ResolvedResultPresentation {
|
||||
/** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */
|
||||
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
|
||||
const call = this.pending.get(callId)
|
||||
this.pending.delete(callId)
|
||||
// No remembered call (unknown/late callId) → nothing to present from; raw content.
|
||||
if (call === undefined) return { content }
|
||||
let present: ToolResultPresentation | undefined
|
||||
if (call === undefined) return { card: 'generic', content }
|
||||
let present: ToolResultView | undefined
|
||||
try {
|
||||
present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError })
|
||||
present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} })
|
||||
} 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 } : {},
|
||||
}
|
||||
if (present === undefined) return { card: 'generic', content }
|
||||
// Orphan guard: only honor a `terminal` result when the PENDING call was a
|
||||
// terminal. A result-only terminal with no matching call-side terminal would
|
||||
// orphan `_meta.terminal_output` to a terminal Zed never made — drop it back
|
||||
// to the raw content.
|
||||
if (present.card === 'terminal' && call.card !== 'terminal') return { card: 'generic', content }
|
||||
// A generic result that reformats no content keeps the RAW result content
|
||||
// (the tool replaced only the title); fill it so the card is never blanked.
|
||||
if (present.card === 'generic' && present.content === undefined) return { ...present, content }
|
||||
return present
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1199,16 +1096,8 @@ export class ToolPresenter {
|
||||
* 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): 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'
|
||||
return 'other'
|
||||
call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: 'other', rawInput: parseToolArguments(argsJson) }),
|
||||
result: (_callId, content) => ({ card: 'generic', content }),
|
||||
}
|
||||
|
||||
/** Parse a tool-call arguments JSON string for `rawInput`; raw string on failure. */
|
||||
@@ -1233,20 +1122,121 @@ function toolResultContent(blocks: ContentBlock[]): { type: 'content'; content:
|
||||
return out
|
||||
}
|
||||
|
||||
/** The `session/update` payload for a `tool_call` / `tool_call_update`. */
|
||||
type ToolCallSessionUpdate = SessionNotification['update']
|
||||
|
||||
/** An ACP tool-call content block (a text/image `content`, a `diff`, or a `terminal`). */
|
||||
type AcpToolCallContent =
|
||||
| { type: 'content'; content: AcpContentBlock }
|
||||
| { type: 'diff'; path: string; oldText: string | null; newText: string }
|
||||
| { type: 'terminal'; terminalId: string }
|
||||
|
||||
/**
|
||||
* 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").
|
||||
* Relativize a file card's TITLE path against the session workspace cwd, so a
|
||||
* card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the
|
||||
* reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the
|
||||
* card's `locations`/`diff` paths stay RAW (the editor opens the real path). The
|
||||
* pure tool presenter can't see the session cwd, so this happens here where the
|
||||
* bridge knows it. The rewrite is an exact substring replace of the known raw
|
||||
* path (a card carries the same path in `locations[0]`/`diffs[0]`), never a
|
||||
* heuristic. A path outside the workspace, or an absent/relative session cwd, is
|
||||
* left unchanged.
|
||||
*/
|
||||
function 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
|
||||
function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string {
|
||||
if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
|
||||
const rel = relativePath(sessionCwd, rawPath)
|
||||
// Only relativize a target that stays INSIDE the workspace. `relative` prefixes
|
||||
// a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone
|
||||
// or `..<sep>…`), NOT a bare `..` char prefix, so a sibling like `..cache/x`
|
||||
// (a real in-workspace name) still relativizes. Never relativize to the empty
|
||||
// string (rawPath === cwd — a non-file target).
|
||||
if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
|
||||
return title.split(rawPath).join(rel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the terminal card's header cwd. A `TerminalCallView.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 view gives no cwd, the
|
||||
* session workspace cwd is the default. Returns `undefined` only when neither the
|
||||
* view nor the session supplies one (Zed then shows "current directory").
|
||||
*/
|
||||
function terminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
|
||||
if (viewCwd === undefined) return sessionCwd
|
||||
if (isAbsolute(viewCwd)) return viewCwd
|
||||
return sessionCwd !== undefined ? resolvePath(sessionCwd, viewCwd) : viewCwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `tool_call` (pending) `session/update` from a tool's render intent.
|
||||
* Switches on `view.card`: a `generic` card maps title/kind/rawInput/content/
|
||||
* locations; a `diff` card emits `{ type: 'diff' }` content blocks (the editor's
|
||||
* inline diff) plus follow-along locations; a `terminal` card renders as a
|
||||
* terminal when the client is capable (a `terminal` content block + the
|
||||
* `_meta.terminal_info` cwd header) and otherwise falls back to a generic execute
|
||||
* card whose body is the description. File-card titles are relativized against the
|
||||
* session cwd (see {@link displayTitle}).
|
||||
*/
|
||||
function toolCallUpdate(callId: CallId, view: ToolCallView, terminal: TerminalRendering): ToolCallSessionUpdate {
|
||||
switch (view.card) {
|
||||
case 'generic':
|
||||
return {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: callId,
|
||||
// Relativize the title against the session cwd when the card carries a
|
||||
// file location (a read/file card); a location-less card (bash, todo)
|
||||
// has no path to relativize, so the title is used as-is.
|
||||
title: displayTitle(view.title, view.locations?.[0]?.path, terminal.cwd),
|
||||
kind: view.kind ?? 'other',
|
||||
status: 'in_progress',
|
||||
...view.rawInput !== undefined ? { rawInput: view.rawInput } : {},
|
||||
...view.locations !== undefined ? { locations: view.locations } : {},
|
||||
...view.content !== undefined && view.content.length > 0 ? { content: toolResultContent(view.content) } : {},
|
||||
}
|
||||
case 'diff': {
|
||||
const rawPath = view.locations?.[0]?.path ?? view.diffs[0]?.path
|
||||
const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText }))
|
||||
return {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: callId,
|
||||
title: displayTitle(view.title, rawPath, terminal.cwd),
|
||||
kind: 'edit',
|
||||
status: 'in_progress',
|
||||
...view.locations !== undefined ? { locations: view.locations } : {},
|
||||
...content.length > 0 ? { content } : {},
|
||||
}
|
||||
}
|
||||
case 'terminal': {
|
||||
// A terminal-rendered call gets a terminal CARD when the client supports it:
|
||||
// the description renders ABOVE the card, then the terminal block, plus
|
||||
// `_meta.terminal_info` (the cwd header). Without the capability it is an
|
||||
// ordinary execute card whose body is the description and whose rawInput is
|
||||
// the command; the output arrives as text on the result.
|
||||
const asTerminal = terminal.enabled
|
||||
const description: AcpToolCallContent[] = view.description !== undefined
|
||||
? [{ type: 'content', content: { type: 'text', text: view.description } }]
|
||||
: []
|
||||
const content: AcpToolCallContent[] = [
|
||||
...description,
|
||||
...asTerminal ? [{ type: 'terminal' as const, terminalId: callId }] : [],
|
||||
]
|
||||
return {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: callId,
|
||||
title: view.title,
|
||||
kind: 'execute',
|
||||
status: 'in_progress',
|
||||
rawInput: view.title,
|
||||
...content.length > 0 ? { content } : {},
|
||||
...asTerminal
|
||||
? { _meta: { terminal_info: { terminal_id: callId, cwd: terminalCwd(view.cwd, terminal.cwd) } } }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
default:
|
||||
return assertNever(view, 'ToolCallView.card')
|
||||
}
|
||||
}
|
||||
|
||||
/** The `terminal_exit` `_meta` entry for a completed terminal call. */
|
||||
@@ -1256,12 +1246,89 @@ interface TerminalExitMeta {
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* from a 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 } }
|
||||
function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExitMeta {
|
||||
if (view.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: view.signal } }
|
||||
if (view.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: view.exitCode } }
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `tool_call_update` (completed) `session/update` from a result render
|
||||
* intent. A `generic` result sends its reformatted content (or the raw result);
|
||||
* a `terminal` result rides its output/exit on `_meta` when the client is capable
|
||||
* (the terminal card consumes them and `content` is OMITTED — a
|
||||
* `tool_call_update.content` REPLACES the call's content collection in Zed, so
|
||||
* re-sending would clobber the terminal block the call installed) and otherwise
|
||||
* derives the fenced ```console fallback from `output`. A `diff` result emits its
|
||||
* `{ type: 'diff' }` content blocks (an applied hunk, or a whole-file diff for a
|
||||
* create), which replace the diff the call installed — so the model-facing result
|
||||
* text can never clobber it.
|
||||
*/
|
||||
function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate {
|
||||
const status = isError ? 'failed' as const : 'completed' as const
|
||||
switch (view.card) {
|
||||
case 'terminal': {
|
||||
const output = view.output ?? ''
|
||||
if (terminal.enabled) {
|
||||
return {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status,
|
||||
...view.title !== undefined ? { title: view.title } : {},
|
||||
_meta: {
|
||||
terminal_output: { terminal_id: callId, data: output },
|
||||
...terminalExitMeta(callId, view),
|
||||
},
|
||||
}
|
||||
}
|
||||
// No terminal capability: the bridge derives the fenced ```console fallback.
|
||||
const fenced = `\`\`\`console\n${output.replace(/\n+$/, '')}\n\`\`\``
|
||||
return {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status,
|
||||
content: [{ type: 'content', content: { type: 'text', text: fenced } }],
|
||||
...view.title !== undefined ? { title: view.title } : {},
|
||||
}
|
||||
}
|
||||
case 'generic':
|
||||
return {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status,
|
||||
// The presenter fills a generic result's content from the raw result, so
|
||||
// `content` is always defined here; the guard keeps this total for a
|
||||
// directly-constructed view.
|
||||
/* v8 ignore next -- content always defined via the presenter (see above) */
|
||||
...view.content !== undefined ? { content: toolResultContent(view.content) } : {},
|
||||
...view.title !== undefined ? { title: view.title } : {},
|
||||
}
|
||||
case 'diff': {
|
||||
// A result-time diff: emit one `{ type: 'diff' }` content block per entry
|
||||
// (an applied hunk for an edit/overwrite, or a whole-file diff for a
|
||||
// create), mirroring the call-side diff arm. `tool_call_update.content`
|
||||
// REPLACES the call's content in an editor, so this result diff supersedes
|
||||
// the diff the pending card installed (and keeps the model-facing result
|
||||
// text from clobbering it).
|
||||
const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText }))
|
||||
// Relativize the replacement title against the session cwd from the diff
|
||||
// path, exactly as the call-side card does — `tool_call_update.title`
|
||||
// replaces the card header, so a raw absolute path here would undo the
|
||||
// pending card's relativized title.
|
||||
const title = view.title !== undefined ? displayTitle(view.title, view.diffs[0]?.path, terminal.cwd) : undefined
|
||||
return {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status,
|
||||
...content.length > 0 ? { content } : {},
|
||||
...title !== undefined ? { title } : {},
|
||||
}
|
||||
}
|
||||
default:
|
||||
return assertNever(view, 'ToolResultView.card')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('acp bridge', () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// e2e/integration tests own their resources (AGENTS.md): dispose even on
|
||||
// e2e/integration tests own their resources (docs/testing.md): dispose even on
|
||||
// failure so a flaky run never leaks a context or persistence dir.
|
||||
if (harness) await harness.dispose()
|
||||
harness = undefined
|
||||
@@ -33,7 +33,7 @@ describe('acp bridge', () => {
|
||||
expect(res.protocolVersion).toBe(PROTOCOL_VERSION)
|
||||
expect(res.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(res.agentCapabilities?.promptCapabilities).toMatchObject({ image: false, audio: false })
|
||||
expect(res.agentInfo?.name).toBe('deepseek-harness-acp')
|
||||
expect(res.agentInfo).toEqual({ name: 'deepseek-harness-acp', version: '0.0.1' })
|
||||
})
|
||||
|
||||
it('session/new creates a session and a full prompt turn streams text then settles end_turn', async () => {
|
||||
@@ -325,14 +325,13 @@ describe('acp bridge', () => {
|
||||
await expect(harness.client.authenticate({ methodId: 'whatever' })).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('honors agentName/agentVersion/systemPrompt config', async () => {
|
||||
it('honors systemPrompt config', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { agentName: 'custom-agent', agentVersion: '9.9.9', systemPrompt: 'be terse' },
|
||||
config: { systemPrompt: 'be terse' },
|
||||
})
|
||||
const res = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
expect(res.agentInfo).toMatchObject({ name: 'custom-agent', version: '9.9.9' })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// Create + prompt so the systemPrompt config flows through agentOptions and
|
||||
// reaches the model request.
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ describe('turnEndToStopReason', () => {
|
||||
expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens')
|
||||
expect(turnEndToStopReason({ kind: 'aborted', reason: 'x' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'rejected', reason: 'blocked by hook' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn')
|
||||
})
|
||||
|
||||
@@ -32,9 +34,9 @@ describe('harnessBlockToAcpContent', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' })
|
||||
})
|
||||
|
||||
it('returns undefined for non-text blocks (reasoning/tool/image)', () => {
|
||||
it('returns undefined for non-text blocks (reasoning / plugin-added)', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'reasoning', text: 'think' })).toBeUndefined()
|
||||
expect(harnessBlockToAcpContent({ type: 'image', url: 'https://x/y.png', mimeType: 'image/png' })).toBeUndefined()
|
||||
expect(harnessBlockToAcpContent({ type: 'chart', data: 'x' } as unknown as ContentBlock)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function makeBridgeHarness(options: {
|
||||
* 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
|
||||
* tests verify the SHIPPING tool, not a stand-in (docs/testing.md "prefer the real
|
||||
* implementation over a mock in tests").
|
||||
*/
|
||||
withBash?: boolean
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('acp bridge — session/load replay', () => {
|
||||
// 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
|
||||
// shipping tool (withBash), not a stand-in (docs/testing.md "prefer the real
|
||||
// implementation over a mock in tests").
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
|
||||
@@ -50,32 +50,34 @@ describe('streamSessionEventUpdate', () => {
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput (generic fallback, no presenter)', () => {
|
||||
it('maps tool/call to an in_progress tool_call with kind other 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',
|
||||
toolCallId: 'c1',
|
||||
title: 'bash',
|
||||
kind: 'execute',
|
||||
// The fallback never sniffs a kind from the tool name — even a name a
|
||||
// first-party tool uses (`bash`) renders `other`; kinds are tool-owned
|
||||
// via presentCall.
|
||||
kind: 'other',
|
||||
status: 'in_progress',
|
||||
rawInput: { command: 'ls' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('infers tool kinds: read*/write*/edit*/other', () => {
|
||||
const kind = (name: string): unknown =>
|
||||
updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c'), name, arguments: '' }))[0]
|
||||
expect((kind('read_file') as { kind: string }).kind).toBe('read')
|
||||
expect((kind('write') as { kind: string }).kind).toBe('edit')
|
||||
expect((kind('edit_file') as { kind: string }).kind).toBe('edit')
|
||||
expect((kind('frobnicate') as { kind: string }).kind).toBe('other')
|
||||
})
|
||||
|
||||
it('falls back to the raw argument string when tool arguments are not JSON', () => {
|
||||
const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: 'not json' }))[0]
|
||||
expect((update as { rawInput: unknown }).rawInput).toBe('not json')
|
||||
})
|
||||
|
||||
it('parses EMPTY tool arguments to an empty-object rawInput (a zero-arg call, not the raw-string fallback)', () => {
|
||||
// `JSON.parse('')` throws, so without the empty-string guard a zero-arg
|
||||
// call would render `rawInput: ''` via the non-JSON fallback; the guard
|
||||
// normalizes it to `{}`.
|
||||
const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'noop', arguments: '' }))[0]
|
||||
expect((update as { rawInput: unknown }).rawInput).toEqual({})
|
||||
})
|
||||
|
||||
it('maps tool/result to completed/failed tool_call_update with text content', () => {
|
||||
const ok = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }))
|
||||
expect(ok).toEqual([{
|
||||
@@ -91,7 +93,7 @@ describe('streamSessionEventUpdate', () => {
|
||||
it('drops non-text tool-result content (text-only)', () => {
|
||||
const update = updatesFor(evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'),
|
||||
content: [{ type: 'image', url: 'https://x/y.png' }],
|
||||
content: [{ type: 'reasoning', text: 'private' }],
|
||||
isError: false,
|
||||
}))[0]
|
||||
expect((update as { content: unknown[] }).content).toEqual([])
|
||||
@@ -163,7 +165,7 @@ describe('todosToPlan', () => {
|
||||
})
|
||||
|
||||
describe('ToolPresenter (tool-owned presentation via the tool registry)', () => {
|
||||
/** A tool whose presentCall/presentResult mirror what tool-bash declares. */
|
||||
/** A tool whose presentCall/presentResult return generic-card views. */
|
||||
const bashLike: ToolDefinition = {
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
@@ -171,9 +173,10 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
|
||||
execute: async () => [],
|
||||
presentCall: (args: unknown) => {
|
||||
const a = args as { command: string; description: string }
|
||||
return { title: a.description, kind: 'execute', rawInput: a.command }
|
||||
return { card: 'generic', title: a.description, kind: 'execute', rawInput: a.command }
|
||||
},
|
||||
presentResult: (_args: unknown, result: { content: { type: string }[] }) => ({
|
||||
card: 'generic',
|
||||
content: [{ type: 'text', text: `wrapped:${result.content.length}` }],
|
||||
}),
|
||||
}
|
||||
@@ -247,8 +250,8 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
|
||||
description: 'm',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: () => ({ title: 'Doing a thing' }),
|
||||
presentResult: () => ({ title: 'Did the thing' }),
|
||||
presentCall: () => ({ card: 'generic', title: 'Doing a thing' }),
|
||||
presentResult: () => ({ card: 'generic', title: 'Did the thing' }),
|
||||
}
|
||||
const presenter = new ToolPresenter(registryOf(minimal))
|
||||
const updates = updatesWith(
|
||||
@@ -286,7 +289,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
|
||||
|
||||
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
|
||||
// session/load replay (docs/defensive-patterns.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 = {
|
||||
@@ -336,11 +339,50 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
|
||||
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
|
||||
})
|
||||
|
||||
it('forwards a tool-owned `locations` onto the wire tool_call (REAL fs read/edit tools)', async () => {
|
||||
it('an unknown render-intent card throws via the exhaustiveness guard (closed union)', () => {
|
||||
// The bridge switches on `view.card` and ends with assertNever: a rogue card
|
||||
// (only reachable by a cast — the union is closed) must throw, so adding a
|
||||
// real variant later fails to compile at the switch instead of silently
|
||||
// dropping the card.
|
||||
const rogue: ToolDefinition = {
|
||||
name: 'rogue',
|
||||
description: 'r',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
// A card value outside the union — forced with a cast (no valid input reaches this).
|
||||
presentCall: () => ({ card: 'chart', title: 'nope' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
|
||||
}
|
||||
const presenter = new ToolPresenter(registryOf(rogue))
|
||||
expect(() => updatesWith(presenter, evt('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}',
|
||||
}))).toThrow('unreachable variant')
|
||||
})
|
||||
|
||||
it('an unknown render-intent RESULT card throws via the exhaustiveness guard (closed union)', () => {
|
||||
// The result-side renderer is also an exhaustive switch + assertNever: a rogue
|
||||
// result card (only reachable by a cast) must throw, so adding a real result
|
||||
// variant later fails to compile at the switch.
|
||||
const rogue: ToolDefinition = {
|
||||
name: 'rogue',
|
||||
description: 'r',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'r' }),
|
||||
presentResult: () => ({ card: 'chart' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentResult']>>,
|
||||
}
|
||||
const presenter = new ToolPresenter(registryOf(rogue))
|
||||
expect(() => updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }),
|
||||
)).toThrow('unreachable variant')
|
||||
})
|
||||
|
||||
it('forwards fs-tool render intents onto the wire (REAL read → generic locations, edit → diff content)', async () => {
|
||||
// Use the SHIPPING fs tools (not a stand-in), booted through their real
|
||||
// plugins, so the wire tool_call carries the actual presentCall output —
|
||||
// including `locations` for editor follow-along. (AGENTS.md "prefer the real
|
||||
// implementation over a mock".)
|
||||
// read's follow-along `locations` and edit's `diff` content block. (docs/testing.md
|
||||
// "prefer the real implementation over a mock".)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -352,44 +394,50 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
|
||||
turn: 1, step: 1, callId: CallId('r1'), name: 'read',
|
||||
arguments: JSON.stringify({ file_path: 'src/a.ts', offset: 12 }),
|
||||
}))
|
||||
// A generic card: the read window is in the title, the offset drives the
|
||||
// follow-along location line. No rawInput (the window lives in the title).
|
||||
expect(readCall).toMatchObject({
|
||||
sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts', kind: 'read',
|
||||
rawInput: 'offset 12', locations: [{ path: 'src/a.ts', line: 12 }],
|
||||
sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts (from line 12)', kind: 'read',
|
||||
locations: [{ path: 'src/a.ts', line: 12 }],
|
||||
})
|
||||
expect((readCall as { rawInput?: unknown }).rawInput).toBeUndefined()
|
||||
|
||||
const [editCall] = updatesWith(presenter, evt('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('e1'), name: 'edit',
|
||||
arguments: JSON.stringify({ file_path: 'src/b.ts', old_string: 'x', new_string: 'y' }),
|
||||
}))
|
||||
// A diff card: `edit` kind, a `{ type: 'diff' }` content block carrying the
|
||||
// literal old→new replacement, plus the follow-along location.
|
||||
expect(editCall).toMatchObject({
|
||||
sessionUpdate: 'tool_call', toolCallId: 'e1', title: 'Edit src/b.ts', kind: 'edit',
|
||||
locations: [{ path: 'src/b.ts' }],
|
||||
content: [{ type: 'diff', path: 'src/b.ts', oldText: 'x', newText: 'y' }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
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 => ({
|
||||
// A tool that renders as a terminal — a stand-in for tool-bash's shape, letting
|
||||
// us drive the bridge's terminal mapping without the real executor. `callCard`
|
||||
// selects a terminal call view (optionally with a cwd) or a generic one (for the
|
||||
// orphan-guard test); `resultTerminal` is the terminal result view's output/exit.
|
||||
type CallCard = { card: 'terminal'; cwd?: string } | { card: 'generic' }
|
||||
type ResultTerm = { title?: string; output?: string; exitCode?: number; signal?: string }
|
||||
const termTool = (callCard: CallCard, 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 } : {},
|
||||
}),
|
||||
presentCall: (args: unknown) => {
|
||||
const command = (args as { command: string }).command
|
||||
const description = (args as { description: string }).description
|
||||
if (callCard.card === 'terminal') {
|
||||
return { card: 'terminal', title: command, description, ...callCard.cwd !== undefined ? { cwd: callCard.cwd } : {} }
|
||||
}
|
||||
return { card: 'generic', title: command, kind: 'execute', rawInput: command, content: [{ type: 'text', text: description }] }
|
||||
},
|
||||
presentResult: () => ({ card: 'terminal', ...resultTerminal }),
|
||||
})
|
||||
|
||||
const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
|
||||
@@ -403,7 +451,7 @@ describe('terminal-card mapping (capability-gated)', () => {
|
||||
}
|
||||
|
||||
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)
|
||||
const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent)
|
||||
expect(call).toMatchObject({
|
||||
sessionUpdate: 'tool_call',
|
||||
content: [
|
||||
@@ -422,33 +470,33 @@ describe('terminal-card mapping (capability-gated)', () => {
|
||||
})
|
||||
|
||||
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)
|
||||
const [absCall] = termUpdates(termTool({ card: 'terminal', 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)
|
||||
const [relCall] = termUpdates(termTool({ card: 'terminal', 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)
|
||||
const [noSessionCwd] = termUpdates(termTool({ card: 'terminal', 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)
|
||||
const [, update] = termUpdates(termTool({ card: 'terminal' }, { 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 [, update] = termUpdates(termTool({ card: 'terminal' }, { 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)
|
||||
it('capability OFF: no terminal block or _meta; the description content and the bridge-derived fenced result render', () => {
|
||||
const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent)
|
||||
expect(call).toEqual({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
@@ -458,24 +506,311 @@ describe('terminal-card mapping (capability-gated)', () => {
|
||||
rawInput: 'echo hi',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'Greet' } }],
|
||||
})
|
||||
// The bridge derives the fenced ```console fallback from the terminal output.
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'fallback' } }],
|
||||
content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }],
|
||||
})
|
||||
})
|
||||
|
||||
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).
|
||||
it('orphan guard: a result-side terminal with a GENERIC call is dropped (no orphan terminal_output)', () => {
|
||||
// presentCall is a generic card, but presentResult returns a terminal view —
|
||||
// the bridge must not emit _meta.terminal_output for a terminal Zed never made.
|
||||
const [call, update] = termUpdates(termTool({ card: 'generic' }, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent)
|
||||
// The call was generic → 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.
|
||||
// The result falls back to the RAW result content (the tool/result event's text); NO terminal _meta.
|
||||
expect((update as { _meta?: unknown })._meta).toBeUndefined()
|
||||
expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'fallback' } }])
|
||||
expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'hi\n' } }])
|
||||
})
|
||||
|
||||
it('capability ON: a terminal result title replaces the completed-card title; missing output emits empty data', () => {
|
||||
// A terminal result MAY carry a replacement title and MAY omit output (a run
|
||||
// that produced nothing) — the _meta carries empty data, not a dropped key.
|
||||
const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', exitCode: 0 }), true, '/w', callEvent, resultEvent)
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
title: 'Ran echo',
|
||||
_meta: { terminal_output: { terminal_id: 'c1', data: '' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('capability OFF: a terminal result title rides on the fenced fallback update', () => {
|
||||
const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', output: 'hi\n' }), false, '/w', callEvent, resultEvent)
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }],
|
||||
title: 'Ran echo',
|
||||
})
|
||||
})
|
||||
|
||||
it('a terminal call with NO description and NO capability is a bare execute card (no content key)', () => {
|
||||
// A terminal view whose presentCall omits `description`, with the capability
|
||||
// OFF: no description block and no terminal block → the card carries no content.
|
||||
const noDesc: ToolDefinition = {
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: (args: unknown) => ({ card: 'terminal', title: (args as { command: string }).command }),
|
||||
}
|
||||
const [call] = termUpdates(noDesc, false, undefined, callEvent)
|
||||
expect(call).toEqual({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'echo hi',
|
||||
kind: 'execute',
|
||||
status: 'in_progress',
|
||||
rawInput: 'echo hi',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('diff-card mapping', () => {
|
||||
// A stand-in diff tool, letting us drive the bridge's diff arm across shapes
|
||||
// the shipping fs tools don't emit (no locations, empty diffs).
|
||||
const diffTool = (view: unknown): ToolDefinition => ({
|
||||
name: 'writer',
|
||||
description: 'writes a file',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: () => view as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
|
||||
})
|
||||
function callUpdate(tool: ToolDefinition, cwd: string | undefined): SessionNotification['update'] {
|
||||
const presenter = new ToolPresenter(registryOf(tool))
|
||||
const out: SessionNotification['update'][] = []
|
||||
streamSessionEventUpdate(
|
||||
SessionId('s1'),
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'writer', arguments: '{}' }),
|
||||
n => out.push(n.update),
|
||||
presenter,
|
||||
{ enabled: false, cwd },
|
||||
)
|
||||
return out[0]!
|
||||
}
|
||||
|
||||
it('a diff with NO locations relativizes the title off the first diff path; omits the locations key', () => {
|
||||
const update = callUpdate(diffTool({ card: 'diff', title: 'Write /work/proj/a.txt', diffs: [{ path: '/work/proj/a.txt', oldText: null, newText: 'x' }] }), '/work/proj')
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'Write a.txt',
|
||||
kind: 'edit',
|
||||
status: 'in_progress',
|
||||
content: [{ type: 'diff', path: '/work/proj/a.txt', oldText: null, newText: 'x' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a diff with an EMPTY diffs array omits the content key (no diff blocks to send)', () => {
|
||||
const update = callUpdate(diffTool({ card: 'diff', title: 'Write nothing', diffs: [] }), undefined)
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'Write nothing',
|
||||
kind: 'edit',
|
||||
status: 'in_progress',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('result-time diff card (REAL fs edit tool → tool_call_update diff blocks)', () => {
|
||||
// Drive the SHIPPING fs edit tool through the bridge: the pending tool/call
|
||||
// installs the call-time snippet, then the tool/result carries the tool's
|
||||
// computed applied-hunk `meta`, which presentResult narrows into a `diff`
|
||||
// result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses
|
||||
// the REAL tool (not a stand-in) per the anti-mock convention, mirroring the
|
||||
// call-side diff test above.
|
||||
async function fsCtx(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FsLocal)
|
||||
await ctx.plugin(ToolFs)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
|
||||
return out
|
||||
}
|
||||
|
||||
it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' })
|
||||
// The applied hunk the tool would compute and persist on the result meta.
|
||||
const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
|
||||
const [, resultUpdate] = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
|
||||
)
|
||||
expect(resultUpdate).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'e1',
|
||||
status: 'completed',
|
||||
title: 'Edit src/b.ts',
|
||||
content: [{ type: 'diff', path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('an error result carries NO diff card (falls back to raw content)', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' })
|
||||
const [, resultUpdate] = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'Error: boom' }], isError: true }),
|
||||
)
|
||||
expect(resultUpdate).toMatchObject({ sessionUpdate: 'tool_call_update', status: 'failed' })
|
||||
expect(resultUpdate).not.toHaveProperty('content', expect.arrayContaining([expect.objectContaining({ type: 'diff' })]))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => {
|
||||
// A `tool_call_update.title` replaces the card header, so the result-side
|
||||
// diff must relativize its title exactly as the pending card did — otherwise
|
||||
// a completed absolute-path edit flips `Edit src/b.ts` back to the raw
|
||||
// absolute path. The diff/location paths stay absolute (the editor opens the
|
||||
// real path). Drive the REAL fs edit tool with an absolute in-workspace path.
|
||||
const ctx = await fsCtx()
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' })
|
||||
const meta = { diffs: [{ path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
|
||||
const out: SessionNotification['update'][] = []
|
||||
const rendering = { enabled: false, cwd: '/work/proj' }
|
||||
for (const event of [
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
|
||||
]) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, rendering)
|
||||
expect(out[1]).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'e1',
|
||||
status: 'completed',
|
||||
title: 'Edit src/b.ts',
|
||||
content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => {
|
||||
// A synthetic tool whose presentResult yields a `diff` card with no hunks and
|
||||
// no title — the shipping fs tools never emit this (edit always has a hunk;
|
||||
// write always falls back to a whole-file diff), so a stand-in is the only way
|
||||
// to exercise the empty-content AND absent-title branches of the result-side
|
||||
// diff arm.
|
||||
const emptyDiffTool: ToolDefinition = {
|
||||
name: 'writer',
|
||||
description: 'writes a file',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: () => ({ card: 'diff', title: 'Write x', diffs: [{ path: 'x', oldText: null, newText: 'y' }] }),
|
||||
presentResult: () => ({ card: 'diff', diffs: [] }),
|
||||
}
|
||||
const presenter = new ToolPresenter(registryOf(emptyDiffTool))
|
||||
const [, resultUpdate] = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('w1'), name: 'writer', arguments: '{}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('w1'), content: [{ type: 'text', text: 'ok' }], isError: false }),
|
||||
)
|
||||
expect(resultUpdate).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'w1',
|
||||
status: 'completed',
|
||||
})
|
||||
expect(resultUpdate).not.toHaveProperty('content')
|
||||
expect(resultUpdate).not.toHaveProperty('title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => {
|
||||
// The bridge relativizes a file card's TITLE against the session workspace cwd
|
||||
// (mirroring the reference adapter's toDisplayPath), while leaving locations/
|
||||
// diff paths RAW. Drive it with the REAL fs tools so the title/locations come
|
||||
// from the shipping presentCall, and pass an ABSOLUTE file path (which a real
|
||||
// editor forwards). The presenter is pure/args-only; the cwd is known only here.
|
||||
async function fsCtx(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FsLocal)
|
||||
await ctx.plugin(ToolFs)
|
||||
return ctx
|
||||
}
|
||||
function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] {
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
const out: SessionNotification['update'][] = []
|
||||
streamSessionEventUpdate(
|
||||
SessionId('s1'),
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name, arguments: JSON.stringify(args) }),
|
||||
n => out.push(n.update),
|
||||
presenter,
|
||||
{ enabled: false, cwd: sessionCwd },
|
||||
)
|
||||
return out[0]!
|
||||
}
|
||||
|
||||
it('read: an absolute path inside the workspace relativizes the TITLE; the location path stays absolute', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/src/a.ts', offset: 5 })
|
||||
expect(update).toMatchObject({
|
||||
title: 'Read src/a.ts (from line 5)',
|
||||
locations: [{ path: '/work/proj/src/a.ts', line: 5 }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('edit: the diff TITLE relativizes; the diff/location paths stay absolute (the editor opens the real path)', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, '/work/proj', 'edit', { file_path: '/work/proj/src/b.ts', old_string: 'x', new_string: 'y' })
|
||||
expect(update).toMatchObject({
|
||||
title: 'Edit src/b.ts',
|
||||
locations: [{ path: '/work/proj/src/b.ts' }],
|
||||
content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'x', newText: 'y' }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('a path OUTSIDE the workspace is left as-is (no `..` title)', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/etc/passwd' })
|
||||
expect((update as { title: string }).title).toBe('Read /etc/passwd')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => {
|
||||
// `/work/proj/..cache/x` is INSIDE the workspace — its relative form
|
||||
// `..cache/x` begins with the chars `..` but is NOT a parent segment. The
|
||||
// guard tests for a `..` SEGMENT, so this relativizes (matching the reference
|
||||
// adapter, which accepts any target under `cwd + sep`).
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' })
|
||||
expect((update as { title: string }).title).toBe('Read ..cache/x.ts')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('no session cwd → the absolute title is left unchanged', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, undefined, 'read', { file_path: '/work/proj/src/a.ts' })
|
||||
expect((update as { title: string }).title).toBe('Read /work/proj/src/a.ts')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('a relative path is passed through unchanged (already display-friendly)', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: 'src/a.ts' })
|
||||
expect((update as { title: string }).title).toBe('Read src/a.ts')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -66,7 +66,10 @@ describe('acp bridge — turn outcomes', () => {
|
||||
const toolCalls = harness.updates.filter(u => u.sessionUpdate === 'tool_call')
|
||||
const toolUpdates = harness.updates.filter(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(toolCalls).toHaveLength(1)
|
||||
expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'execute', status: 'in_progress' })
|
||||
// The inline stand-in declares no presentCall, so the generic fallback
|
||||
// renders kind `other` (kinds are tool-owned; the bridge never sniffs the
|
||||
// name — the REAL dsh-tool-bash test below covers the execute card).
|
||||
expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'other', status: 'in_progress' })
|
||||
expect(toolUpdates).toHaveLength(1)
|
||||
expect(toolUpdates[0]).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
|
||||
@@ -79,7 +82,7 @@ describe('acp bridge — turn outcomes', () => {
|
||||
it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => {
|
||||
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline
|
||||
// stand-in, so this verifies the actual presentCall/presentResult the editor
|
||||
// sees (AGENTS.md "prefer the real implementation over a mock in tests").
|
||||
// sees (docs/testing.md "prefer the real implementation over a mock").
|
||||
// The mock MODEL still scripts the tool call (no real LLM needed), but the
|
||||
// tool and executor are real: a real `echo` runs and its real output flows
|
||||
// back through the bridge.
|
||||
|
||||
Reference in New Issue
Block a user