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

View File

@@ -52,8 +52,8 @@ describe('ToolRegistry', () => {
description: 'has presenters',
parameters: { x: { type: 'string', required: true } },
async execute() { return [] },
presentCall: args => ({ title: args.x }),
presentResult: (args, result) => ({ title: args.x, content: result.content }),
presentCall: args => ({ card: 'generic', title: args.x }),
presentResult: (args, result) => ({ card: 'generic', title: args.x, content: result.content }),
}))
const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
@@ -906,15 +906,15 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
presentCall(args) {
// args is typed { path: string; n?: number } — zero casts.
expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
return { title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
return { card: 'generic', title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
},
presentResult(args, result) {
return { title: `Opened ${args.path}`, content: result.content }
return { card: 'generic', title: `Opened ${args.path}`, content: result.content }
},
})
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' })
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ card: 'generic', title: 'Open /a', kind: 'read', rawInput: '/a' })
expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
.toEqual({ title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
.toEqual({ card: 'generic', title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
})
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
@@ -934,8 +934,8 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
description: 'demo',
parameters: { path: { type: 'string', required: true } },
async execute() { return [] },
presentCall: args => ({ title: args.path }),
presentResult: (args, result) => ({ title: args.path, content: result.content }),
presentCall: args => ({ card: 'generic', title: args.path }),
presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }),
})
// Unlike execute (which throws ToolArgsError on a mismatch), the display
// methods soft-validate and fall back to undefined so a UI never crashes