Merge remote-tracking branch 'origin/master' into worktree-hooks-a-taxonomy

# Conflicts:
#	docs/rfc/README.md
This commit is contained in:
Tianyi Cui
2026-07-04 02:15:52 +08:00
207 changed files with 16514 additions and 534 deletions

View File

@@ -28,7 +28,7 @@ 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 owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) |
| `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) |
## Multi-session
@@ -42,18 +42,24 @@ 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, and optional `content` blocks shown alongside) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.)
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 inferred from the 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

View File

@@ -99,9 +99,9 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; 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 | | ✅ | ✅ | No file-location hints emitted. |
| `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`. |
| `rawOutput` | S | ❌ | ⚠️ | ✅ | Not emitted. |
@@ -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 + location tool rendering**`diff` content and `locations` for edit tools.
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.

View File

@@ -38,12 +38,15 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"

View File

@@ -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,
@@ -62,12 +62,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 { ToolCallKind, 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'
@@ -813,66 +813,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 } : {},
...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': {
@@ -915,44 +862,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[]
/** 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
@@ -962,7 +885,7 @@ 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.
@@ -977,10 +900,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) {
@@ -988,50 +911,37 @@ 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.terminal !== undefined ? { terminal: present.terminal } : {},
}
// No tool-owned presentation: fall back to the tool name as the title and the
// full parsed args as the raw input (the generic card).
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: toolKindFor(name), 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
}
}
@@ -1041,8 +951,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 }),
call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }),
result: (_callId, content) => ({ card: 'generic', content }),
}
/** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */
@@ -1075,20 +985,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. */
@@ -1098,12 +1109,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')
}
}

View File

@@ -19,7 +19,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import {
ClientSideConnection,
@@ -165,6 +168,15 @@ export async function makeBridgeHarness(options: {
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
*/
withTodo?: boolean
/**
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
* and assert their tool-owned presentation (title/kind/`locations`) on the
* wire — the shipping tools, not a stand-in. `fsCwd` sets the local backend's
* base directory (default: `storageDir`).
*/
withFs?: boolean
fsCwd?: string
} = { storageDir: '' }): Promise<BridgeHarness> {
const adapter = new MockAdapter(options.script ?? [])
@@ -183,6 +195,11 @@ export async function makeBridgeHarness(options: {
if (options.withTodo) {
await ctx.plugin(ToolTodo)
}
if (options.withFs) {
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
}
ctx.llm.registerAdapter(['mock'], adapter)
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the

View File

@@ -1,8 +1,13 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionNotification } from '@agentclientprotocol/sdk'
import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import FsLocal from '@deepseek-ai/dsh-fs-local'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
/** Collect the updates a single event produces (no presenter → generic fallback). */
@@ -20,7 +25,7 @@ function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
}
/** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistry, 'get'> {
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> {
const map = new Map(tools.map(t => [t.name, t]))
return { get: name => map.get(name) }
}
@@ -158,7 +163,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',
@@ -166,9 +171,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}` }],
}),
}
@@ -242,8 +248,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(
@@ -330,29 +336,106 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' })
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
})
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 —
// read's follow-along `locations` and edit's `diff` content block. (AGENTS.md
// "prefer the real implementation over a mock".)
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
const presenter = new ToolPresenter(ctx.tools)
const [readCall] = updatesWith(presenter, evt('tool/call', {
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 (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' }) })
@@ -366,7 +449,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: [
@@ -385,33 +468,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',
@@ -421,24 +504,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()
})
})

View File

@@ -15,7 +15,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
| `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent |
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:coding` leaves load it and pass `--expose-internals`.
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-core`, `hmr`, and the two leaf backends.
@@ -31,12 +31,12 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
## The bin
`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:coding` scripts invoke it that way.
`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:repl` scripts invoke it that way.
## Example leaf `cordis.yml`
```yaml
# A real coding agent: hmr + the DeepSeek adapter + local bash, then this app.
# A REPL agent demo: hmr + the DeepSeek adapter + local bash, then this app.
- id: hmr
name: '@cordisjs/plugin-hmr'
config:

View File

@@ -6,7 +6,7 @@
* duplicated in their `start.ts`: load the gitignored repo-root `.env`, then
* drive the cordis Loader against the config path (default `./cordis.yml`).
*
* Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:coding`
* Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:repl`
* scripts invoke it with the example's config.
*
* @module @deepseek-ai/dsh-stdio-agent/bin
@@ -105,7 +105,7 @@ function assertEntriesLoaded(ctx: Context): void {
*
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
* resolved by the cordis Loader's internal module loader, which is only active
* under `node --expose-internals` (the flag the `demo:echo`/`demo:coding` scripts
* under `node --expose-internals` (the flag the `demo:echo`/`demo:repl` scripts
* pass). Without it the Loader falls back to resolving relative to its own module
* and cannot find the config's plugins, so a consumer running the built bin must
* pass `--expose-internals` (or install the plugins where node hoists them).