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({