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