diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml index ea105fa9a0..b792018613 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card.md -2026-07-30-web-result-card.md: da8fc8162e4e52b76162c50c751d31ef6a9c3b1d -2026-07-30-web-result-card.zh.md: 286d9ed659dbea20858798723d9c040f551df0b3 +2026-07-30-web-result-card.md: 3c1f3e69e612d76b959af3304203dcc4ad125019 +2026-07-30-web-result-card.zh.md: e8a3f38d51f4dc81362f5090ff553cfaff21823c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md index da8fc8162e..3c1f3e69e6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md @@ -42,5 +42,3 @@ A future web tool that wants this card declares `presentResult` returning a `car - [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `web` arm. - [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent that carried the bash `terminal` render intent to the browser; the web frontend consumer of this arm is its analogue, deferred to a later PR. - - diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md index 286d9ed659..e8a3f38d51 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md @@ -42,4 +42,3 @@ web 前端消费者是一个独立的后续 PR:本 PR 新增契约分支并让 - [标签化的工具调用渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md) —— 本卡片以 `web` 分支扩展的 `card` 标签词汇表。 - [Web terminal card](2026-07-28-web-terminal-card.md) —— 把 bash `terminal` 渲染意图带到浏览器的先例;本分支的 web 前端消费者是它的对应物,推迟到后续 PR。 - diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 20ac234799..04f35f78c4 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -389,10 +389,17 @@ export class ToolCardComponent implements Component { const glyph = this.result === undefined ? '○' : '●' const rawBody = this.renderBody() const view = this.resultView ?? this.callView - const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined - const unknownXml = this.definition === undefined && genericContent !== undefined + // A generic card's own content, or a web card's fallback to the raw result + // content (the `web` view carries no `content` copy), both render as one dim + // Markdown block below, so links/lists/headings keep the unified dim styling + // rather than reading as bare text. Terminal and diff cards own their body + // styling, so they are excluded (mirrors renderBody's fallback at line 511). + const markdownContent = view.card === 'generic' + ? view.content ?? this.result?.content + : view.card === 'web' ? this.result?.content : undefined + const unknownXml = this.definition === undefined && markdownContent !== undefined ? renderUnknownXml( - displayText(contentText(genericContent)), + displayText(contentText(markdownContent)), this.maxOutputLines, this.visibility === 'expanded', displayText, @@ -405,7 +412,7 @@ export class ToolCardComponent implements Component { // A generic card renders title and result as one Markdown document, so the // document's own block spacing is preserved, then dims every row — the whole // card body reads as one dim block under the status-colored header. - const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0 + const body = unknownXml ?? (markdownContent !== undefined && rawBody.lines.length > 0 ? this.dimBody(rawBody, width) : [...rawBody.prelude, ...rawBody.lines]) const visibleBody = unknownXml !== undefined || this.visibility === 'expanded' @@ -503,8 +510,9 @@ export class ToolCardComponent implements Component { return { prelude: [...hunks, footer], lines: [] } } // The web card carries no `content` copy, so a `web` result view falls back - // to the raw result content here (`view.card === 'generic'` narrows the union, - // mirroring line 392). + // to the raw result content here (`view.card === 'generic'` narrows the + // generic union arm; a `web` card takes the same fallback, mirroring the + // `markdownContent` selection in render()). const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content const prelude: string[] = [] const lines: string[] = [] diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index d99780dedb..3ad808fd74 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4375,6 +4375,14 @@ describe('tool cards and surface replay', () => { name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Known XML' }), }, + // A web card carries no `content` copy, so it falls back to the raw result + // content, which must still render through the dim Markdown path (bold + // markers stripped) rather than as bare text. + webCard: { + name: 'webCard', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Fetch page', kind: 'fetch' }), + presentResult: () => ({ card: 'web', kind: 'fetch', title: 'https://a.test', url: 'https://a.test', statusCode: 200, truncated: false }), + }, } it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => { @@ -4395,6 +4403,7 @@ describe('tool cards and surface replay', () => { ['c11', 'terminalResult', '{}'], ['c12', 'symbolic', '{}'], ['c13', 'knownXml', '{}'], + ['c16', 'webCard', '{}'], ] as const appendAssistant(result.session, [ { type: 'text', text: 'Calling tools' }, @@ -4488,6 +4497,14 @@ describe('tool cards and surface replay', () => { isError: false, }), }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'c16' as never, + content: [{ type: 'text', text: 'Fetched **body** text' }], + isError: false, + }), + }, { surfaceOp: 'append' }) result.session.append('tool/result', { turn: 1, step: 1, @@ -4537,6 +4554,11 @@ describe('tool cards and surface replay', () => { expect(output).toContain('Empty card') expect(output).toContain('converted terminal') expect(output).toContain('literal') + // A web card carries no `content` copy, so it falls back to the raw result + // content, which still renders through the dim Markdown path: the bold + // markers are stripped rather than shown literally. + expect(output).toContain('Fetched body text') + expect(output).not.toContain('Fetched **body** text') expect(output).toContain('path: /tmp/a.txt') expect(output).toContain('line (number="1"): hello') expect(output).not.toContain('') diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 293ce1db76..924f878d8a 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -266,6 +266,11 @@ interface RenderedFetch { * limits the source prefix processed synchronously, then applies again where the * complete output — header, rendered body, and footer — is known. * + * The tool registry calls this once through `output.render` and again through + * `output.presentationMeta`, both with the same frozen result value; the + * conversion is memoized per `(result, maxOutputChars)` so the synchronous DOM + * parse and turndown walk run once, not twice, on the same body. + * * @param result - the seam's fetch outcome. * @param maxOutputChars - cap on the complete returned string; a cut body gets * the same fetch-something-narrower notice as provider-side truncation. @@ -273,6 +278,32 @@ interface RenderedFetch { * the provider, a source cut, or the cap trimmed the content. */ export function renderFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch { + const byCap = renderCache.get(result) ?? new Map() + const cached = byCap.get(maxOutputChars) + if (cached !== undefined) return cached + const computed = computeFetchOutput(result, maxOutputChars) + byCap.set(maxOutputChars, computed) + renderCache.set(result, byCap) + return computed +} + +/** + * Per-result memo for {@link renderFetchOutput}, keyed first on the frozen + * result value so a garbage-collected result drops its entry, then on the output + * cap (a deployment constant per registration). Collapses the registry's twin + * `render`/`presentationMeta` calls into one HTML→markdown conversion. + */ +const renderCache = new WeakMap>() + +/** + * The uncached conversion behind {@link renderFetchOutput}. Separated so the + * memo wraps exactly one call site and the conversion logic stays pure. + * + * @param result - the seam's fetch outcome. + * @param maxOutputChars - cap on the complete returned string. + * @returns the bounded text and effective truncation. + */ +function computeFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch { const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n` const rendered = renderBody(result.body, maxOutputChars) const prefix = `${header}${rendered.text}` diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index c28fa65352..fa07c4ba56 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -388,6 +388,27 @@ describe('web_fetch presentation meta and result view', () => { expect(formatFetchOutput(value, NO_CAP)).not.toContain('Content truncated') }) + it('converts one HTML body once across the render and meta projections of the same result', () => { + // The registry calls output.render and output.presentationMeta with the same + // frozen result value; the memo must collapse them into one turndown walk so + // a large or deeply nested page is not parsed and converted twice. A second + // cap on the same result is a distinct entry, so it converts again. + const spy = vi.spyOn(TurndownService.prototype, 'turndown') + const value = { + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html' as const, content: '

hello

' }, + } + try { + formatFetchOutput(value, NO_CAP) + fetchMetaFromValue(value, NO_CAP) + expect(spy).toHaveBeenCalledTimes(1) + formatFetchOutput(value, NO_CAP - 1) + expect(spy).toHaveBeenCalledTimes(2) + } finally { + spy.mockRestore() + } + }) + it('presents a completed fetch as a web/fetch card carrying the summary, titled by the url, without content', () => { const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: '# Title' } }, NO_CAP) expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, '# Title'))).toEqual({