feat(tool-fs): editor-facing presentation for read/write/edit
The fs tools rendered as generic cards (title = tool name, raw file content) in
an ACP editor. Give them tool-owned presentation like bash/subagent have:
- read → title "Read <path>", kind read, offset/limit as rawInput
- write → title "Write <path>", kind edit
- edit → title "Edit <path>", kind edit, a clipped old→new rawInput summary
Add a provider-neutral `locations: { path, line? }[]` to ToolCallPresentation —
the files a call reads/modifies — so a capable editor can follow along / jump to
the file (read carries its offset as the line). The ACP bridge forwards it onto
the wire `tool_call` (ResolvedCallPresentation + call() + the tool_call build in
streamSessionEventUpdate). This flips the `locations` cell in the ACP feature
matrix to supported. The SDK already carries `tool_call.locations`
(ToolCallLocation `{ path, line? }`), so no ACP types leak into dsh-tools.
presentResult is intentionally omitted: it only receives `{ content, isError }`,
not the write/edit outcome, so titling by create-vs-overwrite or replacement
count would mean parsing the model-facing text — the static title stays.
Tests: pure presentCall assertions for all three tools incl. locations and the
edit rawInput clip; a bridge test drives the REAL fs tools through ToolPresenter
and asserts locations reaches the wire tool_call (proven to fail without the
forwarding line). New withFs harness option + dsh-fs devDeps on dsh-acp.
This commit is contained in:
@@ -320,3 +320,43 @@ describe('edit tool', () => {
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-owned presentation (pure presentCall)', () => {
|
||||
// presentCall is a pure display function of args (no I/O); it drives the ACP
|
||||
// card's title/kind and the `locations` an editor follows along to.
|
||||
const presentCall = async (name: string, args: unknown) => {
|
||||
const { ctx } = await setup()
|
||||
return ctx.tools.get(name)?.presentCall?.(args)
|
||||
}
|
||||
|
||||
it('read: titles by file, 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',
|
||||
locations: [{ path: 'src/a.ts', line: 12 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('read: omits rawInput and the location line 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' }],
|
||||
})
|
||||
})
|
||||
|
||||
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('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('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')}`)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user