Browse Source

fix(web-presenter): dim-Markdown web fallback, memoize fetch conversion, strip note residue

Route a `web` result card's raw-content fallback through the TUI's dim
Markdown path (render() only recognized `card: 'generic'` as markdown
content, so web fallback rendered as bare undimmed text).

Memoize renderFetchOutput per (result, maxOutputChars) so the registry's
twin output.render / output.presentationMeta calls on the same frozen
result run one HTML->markdown conversion instead of two.

Remove the trailing `</content>`/`</invoke>` protocol residue from both
sides of the web-result-card Agent Note and re-record the pairing.
Chinesezjc 2 months ago
parent
commit
689dcfe7d5

+ 2 - 2
.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

+ 0 - 2
.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.
-</content>
-</invoke>

+ 0 - 1
.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。
-</content>

+ 14 - 6
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[] = []

+ 22 - 0
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('<known><value>literal</value></known>')
+    // 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('<result>')

+ 31 - 0
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<number, RenderedFetch>()
+  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<WebFetchResult, Map<number, RenderedFetch>>()
+
+/**
+ * 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}`

+ 21 - 0
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: '<p>hello</p>' },
+    }
+    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({