From 224a9d5f0911f91c9dc1219a6cbdc92c8911a2a1 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 20:42:29 +0800 Subject: [PATCH] fix(client-web): reject unknown web kind, lock fixture to contract, observe web card in boot smoke - webCardModel returns null for an unknown web `kind` (wire from a newer host) instead of drawing it as a malformed fetch, matching the unknown-`card` and terminal-model wire-boundary default. - Fixture WEB_SEARCH_RESULT/WEB_FETCH_RESULT and the source type derive from the contract's ToolResultView via Extract, so a new contract field fails at the type level rather than drifting silently. - built-boot smoke asserts the web_search/web_fetch turns render their keyed WebRow cards, giving the registration and wire projection an assembled check. - DetailsPanel comment no longer claims the card omits content for search. - ui-primitives README inline-Chinese limitation now lists WebBlock's controls. --- apps/web/tests/built-boot.snapshot.ts | 11 +++++++++ .../client/connection/src/client/fixture.ts | 19 ++++----------- .../src/client/contract/web-card-model.ts | 24 +++++++++++++++---- .../src/client/skeleton/DetailsPanel.tsx | 10 ++++---- .../ui-conversation/tests/web-card.spec.tsx | 4 ++++ .../client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- 8 files changed, 48 insertions(+), 28 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 69d5d5cfae..8bf48fc761 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -108,6 +108,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() }, { timeout: 10_000 }) + // The web render intent reaches the assembled boot graph: the fixture's + // web_search / web_fetch turns render their keyed WebRow cards, proving the + // registration, wire projection, and card rendering survive the real bundle + // path (not just the per-package src benches). Without this the whole web + // card could silently fall back to the generic row and every new unit test + // would still pass. + await waitFor(() => { + expect(document.querySelector('[data-web="search"]')).not.toBeNull() + expect(document.querySelector('[data-web="fetch"]')).not.toBeNull() + }, { timeout: 10_000 }) + // Every bundle injected its plugin-owned style tag (the loader's CSS path). const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')] .map(style => style.getAttribute('data-plugin')) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7c81d702b7..cc8332d081 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -136,27 +136,16 @@ const TERMINAL_EXIT_STATUS: Record, 'card' | 'kind' | 'content'> = { answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.', sources: [ { @@ -179,7 +168,7 @@ const WEB_SEARCH_RESULT: { answer: string; sources: WebSourceFixture[]; truncate } /** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */ -const WEB_FETCH_RESULT: { url: string; statusCode: number; truncated: boolean } = { +const WEB_FETCH_RESULT: Omit, 'card' | 'kind' | 'content'> = { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200, truncated: false, diff --git a/packages/client/ui-conversation/src/client/contract/web-card-model.ts b/packages/client/ui-conversation/src/client/contract/web-card-model.ts index 28eb3de2d2..f2b15e023a 100644 --- a/packages/client/ui-conversation/src/client/contract/web-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/web-card-model.ts @@ -40,6 +40,9 @@ export const CHAT_WEB_MAX_SOURCES = 8 * cannot be trusted to be one of the compiled variants, and a generic result * view (a web tool's error path returns the generic card, whose text the * generic path preserves). + * - A web card whose `kind` this UI version does not know (a newer host's + * value): the wire cannot be trusted to be `search` or `fetch`, so it takes + * the generic path rather than rendering as a malformed fetch. * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @returns the web-card props, or null for the generic path. */ @@ -61,10 +64,21 @@ export function webCardModel(block: ToolCallBlock): WebBlockProps | null { truncated: result.truncated, } } - return { - kind: 'fetch', - url: result.url, - statusCode: result.statusCode, - truncated: result.truncated, + // Discriminate `fetch` explicitly rather than treating it as the else of + // `search`: a `kind` this UI version does not know arrives over the wire from + // a newer host, and reading it as a fetch would draw an empty URL and + // `HTTP undefined`. It takes the generic path, the same wire-boundary default + // an unknown `card` tag takes above. The static union narrows `kind` to + // `'fetch'` here, but the runtime value is off the wire, so the guard and its + // null fallthrough are load-bearing despite the type. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (result.kind === 'fetch') { + return { + kind: 'fetch', + url: result.url, + statusCode: result.statusCode, + truncated: result.truncated, + } } + return null } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 57c5d63460..cb4b9aa7b9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -152,10 +152,12 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u } const web = webCardModel(material.block) // Full source-list allowance here (the panel is the single-call reading - // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. The card is a - // summary — a web_fetch card shows only the URL and status — so the details - // panel also renders the flattened result content below it (the fetched body, - // the search answer + source markdown), which the card does not carry. + // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the + // panel also renders the flattened result content — the model-visible text + // the card does not carry verbatim (a web_fetch card shows only the URL and + // status, so its fetched body lives only here; a search card's answer and + // sources are structured, so the flattened form repeats them as the raw text + // the model saw). if (web !== null) { const settled = 'kind' in material.block ? material.block : null const body = settled === null ? '' : renderResult(settled) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index d6f04eb147..e92612c810 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -105,6 +105,10 @@ describe('webCardModel', () => { // documented generic-card default takes it, not a crash. const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView expect(webCardModel(settledSearch({ resultView: future }))).toBeNull() + // A web card whose kind this UI version does not know (a newer host's + // value) also takes the generic path, not a malformed fetch. + const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView + expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull() }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2a64292653..d7eae92129 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.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 packages/client/ui-primitives/README.md -README.md: b5b79f7f0d01afb2d06fb18bf30131cbdda75ca2 -README.zh.md: b10183496479249346da5da808f5ab3b6d2eef67 +README.md: 099da3ae3d4e2b45507fd18d279650ef0525f36a +README.zh.md: dc7fa5f78ea758cea75e86eefb0c06ebe92e61d2 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index b5b79f7f0d..099da3ae3d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -29,5 +29,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. -- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, and `CodeBlock`'s copy control are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction. +- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, `CodeBlock`'s copy control, and `WebBlock`'s source expand/collapse controls and its source-list and fetch truncation notes are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index b101834964..dc7fa5f78e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -28,5 +28,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 -- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale`;`TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件,以及 `CodeBlock` 的复制控件全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。 +- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale`;`TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件、`CodeBlock` 的复制控件,以及 `WebBlock` 的来源展开/收起控件与它的来源列表与 fetch 截断提示,全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。