diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 4e299d9a79..05e1b41ae2 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -6,7 +6,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools' import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -154,6 +154,16 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.emit('fs/observed', target, info.version, exec) return outcome }, + presentResult(_args, result: ToolResult): GenericResultView | undefined { + if (result.isError) return undefined + const only = result.content.length === 1 ? result.content[0] : undefined + const text = only?.type === 'text' ? only.text : undefined + if (text === undefined) return undefined + // Group 1 always captures (possibly empty) when the envelope matches. + const body = /^[^\n]*<\/path>\nfile<\/type>\n\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1] + if (body === undefined) return undefined + return { card: 'generic', content: [{ type: 'text', text: body }] } + }, // 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 reflects raw args, so an omitted limit keeps diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 8f93b524a9..de844dcaf4 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -10,7 +10,7 @@ import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type ToolResult } from '@deepseek-ai/dsh-tools' import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -432,6 +432,11 @@ describe('tool-owned presentation (pure presentCall)', () => { return ctx.tools.get(name)?.presentCall?.(args) } + const presentResult = async (name: string, args: unknown, result: ToolResult) => { + const { ctx } = await setup() + return ctx.tools.get(name)?.presentResult?.(args, result) + } + 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({ card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read', @@ -445,6 +450,36 @@ describe('tool-owned presentation (pure presentCall)', () => { }) }) + it('read: completed presentation removes the model-facing XML envelope', async () => { + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: '/tmp/a.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }], + isError: false, + })).toEqual({ + card: 'generic', + content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }], + }) + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: 'malformed replay' }], + isError: false, + })).toBeUndefined() + }) + + it('read: completed presentation declines errors and non-single-text content', async () => { + const envelope = '/tmp/a.txt\nfile\n\nbody\n' + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: envelope }], + isError: true, + })).toBeUndefined() + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }], + isError: false, + })).toBeUndefined() + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'reasoning', text: envelope }], + isError: false, + })).toBeUndefined() + }) + 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 }],