feat(acp): tool-owned tool-call UI presentation (title/command/output)

In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.

dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).

dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.

dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.

Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
This commit is contained in:
Tianyi Cui
2026-06-18 09:01:36 +08:00
parent 49bec650b8
commit 7803c38824
16 changed files with 626 additions and 26 deletions

View File

@@ -814,3 +814,54 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
expect(result.isError).toBe(false)
})
})
describe('defineTool presentation (presentCall / presentResult)', () => {
it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
const tool = defineTool({
name: 'demo',
description: 'demo',
parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
async execute() { return [{ type: 'text', text: 'ok' }] },
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 }
},
presentResult(args, result) {
return { title: `Opened ${args.path}`, content: result.content }
},
})
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ 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' }] })
})
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
const tool = defineTool({
name: 'plain',
description: 'plain',
parameters: { x: { type: 'string', required: true } },
async execute() { return [] },
})
expect(typeof tool.presentCall).toBe('undefined')
expect(typeof tool.presentResult).toBe('undefined')
})
it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
const tool = defineTool({
name: 'demo',
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 }),
})
// Unlike execute (which throws ToolArgsError on a mismatch), the display
// methods soft-validate and fall back to undefined so a UI never crashes
// replaying an old/foreign log entry. The ToolDefinition methods take
// `unknown`, so malformed shapes pass without a cast.
expect(tool.presentCall?.({})).toBeUndefined()
expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
})
})