feat(fs): add a read render-intent card for the read tool result

The read tool's result carries structured numbered lines, but only the
model-facing envelope text reached the client. Add a card:'read' result view
(ReadResultView) projecting {path, lines, totalLines, lang} through the tool's
output.presentationMeta so presentResult reproduces it on live and replay
paths; the pending call stays a generic read card. A UI without the read
capability falls back to the envelope-stripped content, so the TUI is
unchanged. The web consumer that renders the line-numbered view is a follow-up.
This commit is contained in:
Chinesezjc
2026-07-30 17:03:05 +08:00
parent 69214b4708
commit eb4cc8efc5
9 changed files with 389 additions and 11 deletions

View File

@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest'
import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
import type { ReadWindow } from '../src/read-render.ts'
const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
@@ -116,3 +116,54 @@ describe('buildWindow', () => {
})
})
})
describe('langFromPath', () => {
it('maps a known extension to its language hint, case-insensitively', () => {
expect(langFromPath('src/a.ts')).toBe('ts')
expect(langFromPath('src/a.TSX')).toBe('tsx')
expect(langFromPath('/abs/module.mjs')).toBe('js')
expect(langFromPath('conf.yml')).toBe('yaml')
expect(langFromPath('README.md')).toBe('md')
})
it('reads the extension after the last path segment and last dot', () => {
expect(langFromPath('a.py.bak')).toBeUndefined()
expect(langFromPath('archive.tar.gz')).toBeUndefined()
expect(langFromPath('/dir.py/plain')).toBeUndefined()
expect(langFromPath('C:\\src\\main.rs')).toBe('rs')
})
it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => {
expect(langFromPath('.gitignore')).toBeUndefined()
expect(langFromPath('/etc/hosts')).toBeUndefined()
expect(langFromPath('data.unknownext')).toBeUndefined()
expect(langFromPath('trailingdot.')).toBeUndefined()
})
})
describe('readMetaFromMeta', () => {
const good = { path: '/abs/a.ts', lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' }
it('narrows a well-formed read meta, with and without a lang hint', () => {
expect(readMetaFromMeta(good)).toEqual(good)
const noLang = { path: '/abs/a', lines: [], totalLines: 0 }
expect(readMetaFromMeta(noLang)).toEqual(noLang)
})
it('returns undefined for absent, non-object, or array meta', () => {
expect(readMetaFromMeta(undefined)).toBeUndefined()
expect(readMetaFromMeta(null)).toBeUndefined()
expect(readMetaFromMeta('nope')).toBeUndefined()
expect(readMetaFromMeta([good])).toBeUndefined()
})
it('returns undefined when a field is missing or the wrong type (defensive narrowing)', () => {
expect(readMetaFromMeta({ ...good, path: 5 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, totalLines: '1' })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: 'nope' })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: [{ number: '1', text: 'x' }] })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: [{ number: 1 }] })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined()
})
})

View File

@@ -320,6 +320,38 @@ describe('read tool', () => {
expect(text(result)).toContain('Output capped.')
})
it('attaches the structured window as presentation meta, and presentResult narrows it into a read card', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:a.ts', 'const x = 1\nconst y = 2')
const result = await call(ctx, 'read', { file_path: 'a.ts' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected read success')
// The extension drives the lang hint; the window rides on persisted meta.
expect(result.meta).toEqual({
path: '/abs/a.ts',
lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
totalLines: 2,
lang: 'ts',
})
const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result)
expect(view).toEqual({
card: 'read',
path: '/abs/a.ts',
lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
totalLines: 2,
lang: 'ts',
content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }],
})
})
it('omits the lang hint in meta for an extension that maps to no language', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:notes', 'plain')
const result = await call(ctx, 'read', { file_path: 'notes' })
if (result.isError) throw new Error('expected read success')
expect(result.meta).toEqual({ path: '/abs/notes', lines: [{ number: 1, text: 'plain' }], totalLines: 1 })
})
})
describe('formatReadOutput footer variants', () => {
@@ -450,33 +482,70 @@ 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: '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
it('read: completed presentation is a read card carrying the structured window with the envelope stripped', async () => {
// The structured line data rides on persisted meta (the raw output object is
// not on the wire); presentResult narrows it and appends the stripped text as
// the no-capability `content` fallback.
const meta = { path: '/tmp/a.ts', lines: [{ number: 1, text: 'hello' }], totalLines: 1, lang: 'ts' }
expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
isError: false,
meta,
})).toEqual({
card: 'generic',
card: 'read',
path: '/tmp/a.ts',
lines: [{ number: 1, text: 'hello' }],
totalLines: 1,
lang: 'ts',
content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }],
})
expect(await presentResult('read', { file_path: 'a.txt' }, {
// A window whose extension maps to no language omits `lang` from the card.
expect(await presentResult('read', { file_path: 'notes' }, {
content: [{ type: 'text', text: '<path>/tmp/notes</path>\n<type>file</type>\n<content>\nbody\n</content>' }],
isError: false,
meta: { path: '/tmp/notes', lines: [{ number: 1, text: 'body' }], totalLines: 1 },
})).toEqual({
card: 'read',
path: '/tmp/notes',
lines: [{ number: 1, text: 'body' }],
totalLines: 1,
content: [{ type: 'text', text: 'body' }],
})
// Malformed envelope text with valid meta still declines (the fallback text is unavailable).
expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: 'malformed replay' }],
isError: false,
meta,
})).toBeUndefined()
// Valid envelope but absent/malformed meta declines to the generic fallback.
expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n</content>' }],
isError: false,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n</content>' }],
isError: false,
meta: { path: '/tmp/a.ts', lines: 'nope', totalLines: 1 },
})).toBeUndefined()
})
it('read: completed presentation declines errors and non-single-text content', async () => {
const envelope = '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\nbody\n</content>'
const meta = { path: '/tmp/a.txt', lines: [{ number: 1, text: 'body' }], totalLines: 1 }
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }],
isError: true,
meta,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }],
isError: false,
meta,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'reasoning', text: envelope }],
isError: false,
meta,
})).toBeUndefined()
})