fix(web-search-card): follow base rename kind->shape and view-drops-content

The base (feat/search-presenter) renamed the search result view's discriminant
from `kind` to `shape` and removed the view's `content` field (a UI without a
card now falls back to the raw tool/result content). Adapt the web consumer:

- searchCardModel switches on `result.shape`; SearchBlock's own `kind` prop is
  mapped from it.
- The truncation recovery footer reads the block's raw `content` (where the
  `Full … stored at …` locator now lives) instead of the removed view content.
- Fixture grep/glob views use `shape` and drop `content`; the recovery footer
  rides the raw tool/result text.
- Tests and the bilingual Agent Note follow the rename and the recovery source.
This commit is contained in:
Chinesezjc
2026-07-30 22:49:41 +08:00
parent 53b054f539
commit 4a4ec6fd4d
6 changed files with 83 additions and 81 deletions

View File

@@ -435,20 +435,16 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR
const call = presentCall(name, argsRaw)
if (call === undefined) return undefined
// Search is result-time only: the call stays a generic search card, and the
// result view carries the structured shape the card renders, with the
// model-facing text as `content` for a UI without a search card. `total`
// exceeds the retained count so the card shows its capped indicator.
// result view carries the structured shape the card renders. The view holds no
// result text — a UI without a search card falls back to the raw tool/result
// content — so the truncation recovery footer rides that raw content (the
// `toolTurn` message text), not the view. `total` exceeds the retained count so
// the card shows its capped indicator.
if (name === 'grep') {
return {
card: 'search', kind: 'matches', files: SEARCH_MATCHES_FIXTURE,
truncated: true, total: 42, content: text(resultText),
}
return { card: 'search', shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 }
}
if (name === 'glob') {
return {
card: 'search', kind: 'paths', paths: SEARCH_PATHS_FIXTURE,
truncated: true, total: 23, content: text(resultText),
}
return { card: 'search', shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 }
}
switch (call.card) {
case 'terminal':

View File

@@ -14,10 +14,11 @@
* execution.
*
* A capped result also carries a recovery locator (grep/glob's `Full … stored
* at …` footer) that lives only in the view's `content` text, not in the
* structured matches/paths. Since both render sites replace the raw result with
* the card, this derivation surfaces that text as {@link SearchCardModel.recovery}
* so the one path to the dropped rows is not lost.
* at …` footer) in the raw `tool/result` content, not in the structured
* matches/paths the view carries. Since both render sites replace that raw
* result with the card, this derivation surfaces the block's own result text as
* {@link SearchCardModel.recovery} so the one path to the dropped rows is not
* lost.
* @module
*/
import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -62,22 +63,22 @@ export interface SearchCardModel {
*/
title: string | undefined
/**
* The model-facing result text (the view's `content`, flattened), surfaced
* only when the search was capped. The card renders the retained matches or
* paths, but the recovery locator a capped result carries — grep/glob's
* `Full … stored at: <locator>` footer, the one way to reach the rows the cap
* dropped — lives only in this text. A UI that replaces the raw result with
* the card would otherwise lose it. Absent when the result was not capped
* (the card holds every result) or the presenter supplied no content.
* The raw `tool/result` text, flattened, surfaced only when the search was
* capped. The card renders the retained matches or paths, but the recovery
* locator a capped result carries — grep/glob's `Full … stored at: <locator>`
* footer, the one way to reach the rows the cap dropped — lives only in the raw
* result text, which the card replaces. A UI that shows the card would
* otherwise lose it. Absent when the result was not capped (the card holds
* every result) or the block carries no text.
*/
recovery: string | undefined
}
/**
* Whether every file group in a matches view is structurally valid: the wire
* frame carries `kind` and `card` as strings the host schema checks, but not the
* frame carries `shape` and `card` as strings the host schema checks, but not the
* grouped shape, so a version mismatch or loose producer could deliver
* `kind: 'matches'` with a missing or malformed `files`. Rendering that would
* `shape: 'matches'` with a missing or malformed `files`. Rendering that would
* crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the
* generic path instead.
* @param files - the candidate `files` field off the untrusted result view.
@@ -95,15 +96,15 @@ function isValidFiles(files: unknown): files is SearchFileGroup[] {
}
/**
* Flatten a result view's `content` blocks to their text, joined by newlines.
* The search views carry `content` (the model-facing result text) so a UI
* without a search card can show it; here it is the source of the truncation
* recovery footer. Non-text blocks (a search result carries none) are skipped.
* @param content - the result view's optional content blocks.
* @returns the joined text, or undefined when absent or empty.
* Flatten a settled tool result's content blocks to their text, joined by
* newlines. The search view carries no result text a UI without a card falls
* back to the raw `tool/result` content — so the truncation recovery footer is
* read from the block's own content here. Non-text blocks (a search result
* carries none) are skipped.
* @param content - the result node's content blocks.
* @returns the joined text, or undefined when empty.
*/
function flattenContent(content: readonly { type: string; text?: string }[] | undefined): string | undefined {
if (content === undefined) return undefined
function flattenContent(content: readonly { type: string; text?: string }[]): string | undefined {
const text = content
.filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string')
.map(block => block.text)
@@ -119,7 +120,7 @@ function flattenContent(content: readonly { type: string; text?: string }[] | un
* a still-running call (no result view) is null, as is a settled call whose
* result view is not a search card — including a `card` value this UI version
* does not know, which arrives over the wire and cannot be trusted to be one of
* the compiled variants, a `card: 'search'` view whose `kind` is neither
* the compiled variants, a `card: 'search'` view whose `shape` is neither
* `matches` nor `paths` (equally untrusted wire data), and a generic result a
* `grep`/`glob` failure or nested `run_code` dispatch produces (its text keeps
* the generic path).
@@ -133,25 +134,25 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
if (result === null) return null
const common = { truncated: result.truncated, total: result.total }
// The recovery footer only matters when the tool capped the result: an
// uncapped card holds every match/path, so its content adds nothing the card
// does not already show. When capped, the content's `Full … stored at …`
// uncapped card holds every match/path, so the raw text adds nothing the card
// does not already show. When capped, the raw result's `Full … stored at …`
// locator is the only path to the dropped rows, so surface it.
const recovery = result.truncated ? flattenContent(result.content) : undefined
if (result.kind === 'matches') {
// `files` rides the untrusted wire frame: the host schema checks `card`/`kind`
const recovery = result.truncated ? flattenContent(block.content) : undefined
if (result.shape === 'matches') {
// `files` rides the untrusted wire frame: the host schema checks `card`/`shape`
// strings but not the grouped shape, so validate it before SearchBlock, which
// would crash on a missing/malformed `files`. An invalid shape falls to generic.
if (!isValidFiles(result.files)) return null
return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } }
}
// `kind` rides the same untrusted wire frame as `card`, so a version mismatch
// `shape` rides the same untrusted wire frame as `card`, so a version mismatch
// or a loose protocol producer could deliver a `card: 'search'` subtype this
// client does not compile. Guard the paths shape explicitly: an unknown kind
// client does not compile. Guard the paths shape explicitly: an unknown shape
// falls to the generic path rather than being rendered as a paths card, which
// would leave SearchBlock calling `.length`/`.map` on an absent `paths`.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- kind is wire data; the compiled union cannot prove this exhaustive.
if (result.kind !== 'paths') return null
// `paths` is likewise unchecked by the wire schema; a known kind with a
// oxlint-disable-next-line typescript/no-unnecessary-condition -- shape is wire data; the compiled union cannot prove this exhaustive.
if (result.shape !== 'paths') return null
// `paths` is likewise unchecked by the wire schema; a known shape with a
// missing/malformed array would crash the paths card at `.map`.
if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null
return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } }

View File

@@ -38,8 +38,8 @@ const GREP_ARGS = '{"pattern":"foo","path":"src"}'
const GLOB_ARGS = '{"pattern":"**/*.ts","path":"src"}'
/** A grep result view: matches grouped by file. */
const resultMatches = (over?: Partial<Extract<ToolResultView, { card: 'search'; kind: 'matches' }>>): ToolResultView => ({
card: 'search', kind: 'matches',
const resultMatches = (over?: Partial<Extract<ToolResultView, { card: 'search'; shape: 'matches' }>>): ToolResultView => ({
card: 'search', shape: 'matches',
files: [
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] },
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] },
@@ -48,8 +48,8 @@ const resultMatches = (over?: Partial<Extract<ToolResultView, { card: 'search';
})
/** A glob result view: a flat path list. */
const resultPaths = (over?: Partial<Extract<ToolResultView, { card: 'search'; kind: 'paths' }>>): ToolResultView => ({
card: 'search', kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
const resultPaths = (over?: Partial<Extract<ToolResultView, { card: 'search'; shape: 'paths' }>>): ToolResultView => ({
card: 'search', shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
})
const runningGrep = (over?: Partial<RunningToolCall>): RunningToolCall => ({
@@ -90,7 +90,8 @@ describe('searchCardModel', () => {
})
it('derives a paths card from the glob result view, carrying the truncation signal', () => {
expect(searchCardModel(settledGlob({ resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({
// Empty block content isolates the truncation signal from the recovery arm.
expect(searchCardModel(settledGlob({ content: [], resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({
title: undefined,
recovery: undefined,
card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 },
@@ -118,53 +119,55 @@ describe('searchCardModel', () => {
expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull()
})
it('returns null for a card:search view whose kind this version does not compile', () => {
// `kind` rides the same untrusted wire frame as `card`; a subtype this client
it('returns null for a card:search view whose shape this version does not compile', () => {
// `shape` rides the same untrusted wire frame as `card`; a subtype this client
// does not know must fall to the generic path, never render as a paths card
// that would crash SearchBlock on an absent `paths`.
const futureKind = {
card: 'search', kind: 'future', truncated: false, total: 0,
const futureShape = {
card: 'search', shape: 'future', truncated: false, total: 0,
} as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: futureKind }))).toBeNull()
expect(searchCardModel(settledGrep({ resultView: futureShape }))).toBeNull()
})
it('returns null for a known kind whose structured shape is missing or malformed', () => {
// The host wire schema checks the `card`/`kind` strings but not the grouped
// shape, so a version mismatch could deliver kind:'matches' with no `files`
// (or kind:'paths' with no `paths`). Rendering that crashes SearchBlock at
it('returns null for a known shape whose structured shape is missing or malformed', () => {
// The host wire schema checks the `card`/`shape` strings but not the grouped
// shape, so a version mismatch could deliver shape:'matches' with no `files`
// (or shape:'paths' with no `paths`). Rendering that crashes SearchBlock at
// `.reduce`/`.map`; the derivation drops to the generic path instead.
const noFiles = { card: 'search', kind: 'matches', truncated: false, total: 0 } as unknown as ToolResultView
const noFiles = { card: 'search', shape: 'matches', truncated: false, total: 0 } as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull()
const badFile = {
card: 'search', kind: 'matches', truncated: false, total: 1,
card: 'search', shape: 'matches', truncated: false, total: 1,
files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }],
} as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull()
const noPaths = { card: 'search', kind: 'paths', truncated: false, total: 0 } as unknown as ToolResultView
const noPaths = { card: 'search', shape: 'paths', truncated: false, total: 0 } as unknown as ToolResultView
expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull()
const badPaths = {
card: 'search', kind: 'paths', truncated: false, total: 1, paths: [42],
card: 'search', shape: 'paths', truncated: false, total: 1, paths: [42],
} as unknown as ToolResultView
expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull()
})
it('surfaces the recovery text only when the result was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
// Capped: the content (its `Full … stored at …` locator) rides through so the
// dropped rows stay reachable.
// The recovery locator lives in the raw tool/result content (the view carries
// no text), surfaced only when the card capped the result.
const capped = searchCardModel(settledGrep({
resultView: resultMatches({ truncated: true, total: 42, content: [{ type: 'text', text: recovery }] }),
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}))
expect(capped?.recovery).toBe(recovery)
// Not capped: the card holds every match, so the content adds nothing and is
// dropped.
// Not capped: the card holds every match, so the raw content adds nothing and
// is dropped.
const whole = searchCardModel(settledGrep({
resultView: resultMatches({ truncated: false, content: [{ type: 'text', text: recovery }] }),
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: false }),
}))
expect(whole?.recovery).toBeUndefined()
// Capped but the presenter attached no content: nothing to surface.
const noContent = searchCardModel(settledGrep({ resultView: resultMatches({ truncated: true, total: 42 }) }))
expect(noContent?.recovery).toBeUndefined()
// Capped but the block carries no text: nothing to surface.
const noText = searchCardModel(settledGrep({ content: [], resultView: resultMatches({ truncated: true, total: 42 }) }))
expect(noText?.recovery).toBeUndefined()
})
})
@@ -205,7 +208,8 @@ describe('chat row search body (GenericToolCard fallback)', () => {
it('the expanded body shows the recovery footer below a capped card', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render(<GenericToolCard {...ownerProps(settledGrep({
resultView: resultMatches({ truncated: true, total: 42, content: [{ type: 'text', text: recovery }] }),
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}), 'grep')} />)
fireEvent.click(view.container.querySelector('button')!)
expect(searchKindOf(view.container)).toBe('matches')
@@ -273,7 +277,8 @@ describe('SearchRow keyed card', () => {
it('renders the recovery footer below the card when the search was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render(<SearchRow {...rowProps(settledGrep({
resultView: resultMatches({ truncated: true, total: 42, content: [{ type: 'text', text: recovery }] }),
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}), 'grep')} />)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
@@ -376,7 +381,7 @@ describe('DetailsPanel Output section (search)', () => {
it('renders the recovery footer below the card for a capped search', () => {
const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)'
const view = mount(snapshot({
nodes: [settledGlob({ resultView: resultPaths({ truncated: true, total: 23, content: [{ type: 'text', text: recovery }] }) })],
nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], resultView: resultPaths({ truncated: true, total: 23 }) })],
}), globTarget)
expect(searchKindOf(view.container)).toBe('paths')
expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy()