refactor(tools): tagged render-intent union for tool-call presentation

Replace the "bag of optional fields" tool-presentation types
(ToolCallPresentation / ToolResultPresentation / ToolTerminal) with a
card-tagged discriminated union — the standing FIXME(tool-presentation).
A tool declares one render intent per call/result and the ACP bridge
switches on `card`:

  ToolCallView   = generic | terminal | diff
  ToolResultView = generic | terminal

The `diff` card is new: fs write/edit now emit an ACP {type:'diff'}
content block (an editor's inline diff), which the old shapes could not
express. The bridge also relativizes a file card's title against the
session cwd (mirroring claude-agent-acp's toDisplayPath) while keeping
locations/diff paths raw, and derives the no-capability fenced console
fallback from a terminal result's output. read gains the window-in-title
(`Read foo.txt (5 - 8)`) and an always-set location line, matching the
reference adapter field-for-field.

Migrates all three producer families (tool-fs, tool-bash, tool-todo) and
the sole consumer (the ACP bridge) together — the source does not compile
piecewise. Adds snapshot coverage for the terminal _meta path (a new
capability-advertising scenario) and re-records the fs goldens to show the
diff cards. Applied-hunk (result-time, context-line) diffs need a new
result/event shape and are a follow-up.

RFC: docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md
This commit is contained in:
Tianyi Cui
2026-07-03 02:04:03 +08:00
parent 490fe002a1
commit 1a57d67058
35 changed files with 1015 additions and 450 deletions
+8 -8
View File
@@ -14,6 +14,7 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { DiffCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FsEditOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
@@ -83,16 +84,15 @@ export function applyEditTool(ctx: Context): void {
ctx.emit('fs/observed', target, outcome.version, exec)
return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }]
},
// Pure display: `edit` kind, a location for editor follow-along, and a short
// old→new summary as rawInput (truncated so a large replacement stays a
// readable card). The replacement COUNT is not available here — presentResult
// only sees `{ content, isError }`, not the outcome — so the title is static.
presentCall(args) {
const clip = (s: string): string => (s.length > 40 ? `${s.slice(0, 40)}…` : s)
// Pure display: a diff card of the literal replacement (old_string →
// new_string), derived from the call args. `oldText: old_string || null`
// matches claude-agent-acp's Edit arm; new_string is a required arg here, so
// it maps straight to newText. A follow-along location points at the file.
presentCall(args): DiffCallView {
return {
card: 'diff',
title: `Edit ${args.file_path}`,
kind: 'edit',
rawInput: `${JSON.stringify(clip(args.old_string))} → ${JSON.stringify(clip(args.new_string))}`,
diffs: [{ path: args.file_path, oldText: args.old_string || null, newText: args.new_string }],
locations: [{ path: args.file_path }],
}
},
+14 -11
View File
@@ -14,6 +14,7 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
@@ -101,19 +102,21 @@ export function applyReadTool(ctx: Context): void {
ctx.emit('fs/observed', target, info.version, exec)
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
},
// Pure display: a UI card titled by the file, `read` kind (icon), and a
// location so an editor can follow along to the file (and the read's offset
// line). `rawInput` surfaces offset/limit when the model narrowed the read.
presentCall(args) {
const detail = [
...args.offset !== undefined ? [`offset ${args.offset}`] : [],
...args.limit !== undefined ? [`limit ${args.limit}`] : [],
].join(', ')
// Pure display: a generic card titled by the file with the read window
// appended (`Read foo.txt (5 - 8)`), `read` kind (icon), and a follow-along
// location whose line is the read's offset (defaulting to 1). The window is
// derived from the RAW args (offset/limit as the model passed them), NOT the
// tool's defaulted 1/READ_LIMIT, so an unbounded read shows a bare title.
presentCall(args): GenericCallView {
const { offset, limit } = args
const window = limit !== undefined && limit > 0
? ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})`
: offset !== undefined ? ` (from line ${offset})` : ''
return {
title: `Read ${args.file_path}`,
card: 'generic',
title: `Read ${args.file_path}${window}`,
kind: 'read',
locations: [{ path: args.file_path, ...args.offset !== undefined ? { line: args.offset } : {} }],
...detail.length > 0 ? { rawInput: detail } : {},
locations: [{ path: args.file_path, line: offset ?? 1 }],
}
},
}))
+12 -6
View File
@@ -13,6 +13,7 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { DiffCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
@@ -62,12 +63,17 @@ export function applyWriteTool(ctx: Context): void {
ctx.emit('fs/observed', target, outcome.version, exec)
return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }]
},
// Pure display: `edit` kind (an editor treats create/replace as an edit) and
// a location so the UI can follow along to the written file. The create-vs-
// overwrite fact lives in the model-facing result text; `presentResult` only
// sees `{ content, isError }` (not the outcome), so the title stays static.
presentCall(args) {
return { title: `Write ${args.file_path}`, kind: 'edit', locations: [{ path: args.file_path }] }
// Pure display: a diff card (an editor renders write as a new-file / full-
// replace diff). `oldText: null` — a call-time presenter has no access to the
// file's prior content, so even an overwrite renders new-file style, matching
// claude-agent-acp. A follow-along location points at the written file.
presentCall(args): DiffCallView {
return {
card: 'diff',
title: `Write ${args.file_path}`,
diffs: [{ path: args.file_path, oldText: null, newText: args.content }],
locations: [{ path: args.file_path }],
}
},
}))
}