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.
This commit is contained in:
@@ -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'))
|
||||
|
||||
@@ -136,27 +136,16 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
|
||||
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
|
||||
}
|
||||
|
||||
/**
|
||||
* One inline-authored source for the `web_search` fixture. Structurally the
|
||||
* contract's `WebSource`; authored locally because the fixture cannot import the
|
||||
* web tool, and kept minimal so a missing optional field renders its fallback.
|
||||
*/
|
||||
interface WebSourceFixture {
|
||||
url: string
|
||||
title?: string
|
||||
snippet?: string
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The structured `web_search` result view for fixture turn 66, authored inline
|
||||
* because this client-side fixture cannot import the web tool that projects it.
|
||||
* The sources exercise the citation list's features: a titled source with a
|
||||
* snippet and a date, a source with no title (its hostname labels the link) and
|
||||
* a snippet but no date, and a source with a title and a date but no snippet.
|
||||
* `truncated` marks the capped indicator.
|
||||
* `truncated` marks the capped indicator. The shape is the contract's own
|
||||
* search view minus its wire discriminants and fallback content.
|
||||
*/
|
||||
const WEB_SEARCH_RESULT: { answer: string; sources: WebSourceFixture[]; truncated: boolean } = {
|
||||
const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'search' }>, '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<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind' | 'content'> = {
|
||||
url: 'https://www.deepseek.com/blog/harness-architecture',
|
||||
statusCode: 200,
|
||||
truncated: false,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user