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:
@@ -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,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 }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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 }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -352,34 +352,46 @@ describe('tool-owned presentation (pure presentCall)', () => {
|
||||
return ctx.tools.get(name)?.presentCall?.(args)
|
||||
}
|
||||
|
||||
it('read: titles by file, read kind, location with the offset line', async () => {
|
||||
it('read: generic card titled by file with the read window, read kind, location with the offset line', async () => {
|
||||
expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({
|
||||
title: 'Read src/a.ts', kind: 'read', rawInput: 'offset 12, limit 40',
|
||||
card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read',
|
||||
locations: [{ path: 'src/a.ts', line: 12 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('read: omits rawInput and the location line when offset/limit are unset', async () => {
|
||||
it('read: bare title and line-1 location when offset/limit are unset', async () => {
|
||||
expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({
|
||||
title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt' }],
|
||||
card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('write: titles by file, edit kind, location', async () => {
|
||||
expect(await presentCall('write', { file_path: 'out.txt', content: 'x' })).toEqual({
|
||||
title: 'Write out.txt', kind: 'edit', locations: [{ path: 'out.txt' }],
|
||||
it('read: "from line N" window when only offset is set', async () => {
|
||||
expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({
|
||||
card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('edit: titles by file, edit kind, an old→new rawInput summary, location', async () => {
|
||||
expect(await presentCall('edit', { file_path: 'a.txt', old_string: 'foo', new_string: 'bar' })).toEqual({
|
||||
title: 'Edit a.txt', kind: 'edit', rawInput: '"foo" → "bar"', locations: [{ path: 'a.txt' }],
|
||||
it('write: diff card (new-file style, oldText null), location', async () => {
|
||||
expect(await presentCall('write', { file_path: 'out.txt', content: 'hello' })).toEqual({
|
||||
card: 'diff', title: 'Write out.txt',
|
||||
diffs: [{ path: 'out.txt', oldText: null, newText: 'hello' }],
|
||||
locations: [{ path: 'out.txt' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('edit: clips a long old/new string in the rawInput summary', async () => {
|
||||
const long = 'a'.repeat(60)
|
||||
const p = await presentCall('edit', { file_path: 'a.txt', old_string: long, new_string: 'b' })
|
||||
expect((p as { rawInput: string }).rawInput).toBe(`${JSON.stringify(`${'a'.repeat(40)}…`)} → ${JSON.stringify('b')}`)
|
||||
it('read: a limit with no offset windows from line 1', async () => {
|
||||
expect(await presentCall('read', { file_path: 'a.txt', limit: 10 })).toEqual({
|
||||
card: 'generic', title: 'Read a.txt (1 - 10)', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('edit: an empty old_string maps to oldText null (a whole-file replace diff)', async () => {
|
||||
// presentCall runs on replay of raw logged args, which parseEditArgs does not
|
||||
// gate — an empty old_string must still produce a valid diff (oldText null).
|
||||
expect(await presentCall('edit', { file_path: 'a.txt', old_string: '', new_string: 'seed' })).toEqual({
|
||||
card: 'diff', title: 'Edit a.txt',
|
||||
diffs: [{ path: 'a.txt', oldText: null, newText: 'seed' }],
|
||||
locations: [{ path: 'a.txt' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user