feat(fs): add a search render-intent card for grep and glob results

grep and glob returned only model-facing text; the structured matches/paths
never reached the client. Add a card:'search' result view with a kind
discriminant ('matches' grouped by file for grep, 'paths' for glob), projected
through each tool's output.presentationMeta and read back in presentResult. The
projections re-apply the same inline cap and per-line budget as the render text
and report total + truncated, so a UI never presents a capped page as complete.
A UI without the search card falls back to content; the TUI is unchanged. The
web consumer is a follow-up.
This commit is contained in:
Chinesezjc
2026-07-30 17:03:54 +08:00
parent 69214b4708
commit 3e22adab28
14 changed files with 628 additions and 12 deletions

View File

@@ -2155,6 +2155,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ScopeKey',
declaration: 'export type ScopeKey = object;',
},
{
name: 'SearchFileMatches',
declaration: 'export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n}',
},
{
name: 'SearchLineMatch',
declaration: 'export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n}',
},
{
name: 'SearchMatchesResultView',
declaration: 'export interface SearchMatchesResultView {\n card: \'search\';\n kind: \'matches\';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n content?: ContentBlock[];\n}',
},
{
name: 'SearchPathsResultView',
declaration: 'export interface SearchPathsResultView {\n card: \'search\';\n kind: \'paths\';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n content?: ContentBlock[];\n}',
},
{
name: 'SearchResultView',
declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;',
},
{
name: 'SendOptions',
declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}',
@@ -2697,7 +2717,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolResultView',
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;',
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView;',
},
{
name: 'ToolRunContext',

View File

@@ -82,6 +82,11 @@ export type {
GenericResultView,
TerminalResultView,
DiffResultView,
SearchResultView,
SearchMatchesResultView,
SearchPathsResultView,
SearchFileMatches,
SearchLineMatch,
} from './presentation.ts'
declare module 'cordis' {

View File

@@ -125,7 +125,7 @@ export interface DiffCallView {
* `ToolDefinition.presentResult`; omitting the method keeps the pending
* title and renders the raw result content.
*/
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView
/**
* The default completed card: an optional replacement title and reformatted
@@ -176,3 +176,90 @@ export interface DiffResultView {
/** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */
diffs: FileDiff[]
}
/** One matched line inside a {@link SearchFileMatches} group: its 1-based line number and text. */
export interface SearchLineMatch {
/** 1-based line number of the match within its file. */
lineNumber: number
/** The matched line text, as the tool surfaced it (the per-line preview budget already applied). */
line: string
}
/** One file's grouped content matches for a {@link SearchMatchesResultView}, in first-seen file order. */
export interface SearchFileMatches {
/** The file the matches belong to (the model-facing display path). */
path: string
/** The file's matched lines, in output order. */
matches: SearchLineMatch[]
}
/**
* A completed content search (`grep`) rendered as a search card whose matches are
* grouped by file, so a capable UI can list each file as an expandable group of
* its matched lines. `kind: 'matches'` discriminates this shape from the path
* shape ({@link SearchPathsResultView}) within {@link SearchResultView}.
*/
export interface SearchMatchesResultView {
card: 'search'
kind: 'matches'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** Matched lines grouped by file, in first-seen file order. */
files: SearchFileMatches[]
/**
* Whether the tool capped the inline result: `files` carries only the retained
* matches, not every match the search found. A UI shows a capped indicator so it
* never presents a partial group as complete.
*/
truncated: boolean
/** Total matches the search found before capping (equals the retained count when not `truncated`). */
total: number
/**
* UI-facing content blocks reproducing the model-facing result text, so a UI
* without a dedicated search card renders it as text. Omit to let the UI render
* the raw result content.
*/
content?: ContentBlock[]
}
/**
* A completed path search (`glob`) rendered as a search card whose result is a flat
* path list. `kind: 'paths'` discriminates this shape from the grouped-matches
* shape ({@link SearchMatchesResultView}) within {@link SearchResultView}.
*/
export interface SearchPathsResultView {
card: 'search'
kind: 'paths'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** The discovered paths, in the tool's result order (the retained page when `truncated`). */
paths: string[]
/**
* Whether the tool capped the inline result: `paths` carries only the retained
* page, not every path the search found. A UI shows a capped indicator so it
* never presents a partial list as complete.
*/
truncated: boolean
/** Total paths the search found before capping (equals `paths.length` when not `truncated`). */
total: number
/**
* UI-facing content blocks reproducing the model-facing result text, so a UI
* without a dedicated search card renders it as text. Omit to let the UI render
* the raw result content.
*/
content?: ContentBlock[]
}
/**
* A completed search rendered as a search card, the result-time view a discovery
* tool (`grep`, `glob`) returns from `presentResult`. One `card: 'search'` view
* with two `kind`-discriminated shapes: grouped-by-file content matches
* ({@link SearchMatchesResultView}) and a flat path list
* ({@link SearchPathsResultView}). Both carry a `truncated`/`total` signal so a UI
* never presents a capped result as complete, and an optional `content` a UI
* without a search card renders as text. There is no call-time analogue: a search
* call stays a {@link GenericCallView} (`kind: 'search'`) because the pending
* state has no matches or paths to show — the structured shape exists only after
* `execute`.
*/
export type SearchResultView = SearchMatchesResultView | SearchPathsResultView

View File

@@ -11,13 +11,14 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { globSearchMeta, searchViewFromMeta } from './presentation.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
@@ -136,6 +137,24 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener
return { card: 'generic', title: `Glob ${args.pattern}${where}`, kind: 'search', rawInput: args.pattern }
}
/**
* Completed-call presentation: the search card projected from the result's
* `presentationMeta` (the discovered path list, with the truncation signal), with
* the model-facing result text attached as `content` for a UI without a search
* card. Malformed or absent metadata (an obsolete or hand-edited replayed log)
* falls back to the generic card.
*
* @param _args - the raw tool arguments; unused, the view derives from the result.
* @param result - the final model-facing tool result carrying the projected metadata.
* @returns the search card view, or `undefined` for the generic fallback.
*/
export function presentGlobResult(_args: { pattern: string; path?: string }, result: ToolResult): SearchResultView | undefined {
if (result.isError) return undefined
const view = searchViewFromMeta(result.meta)
if (view === undefined || view.kind !== 'paths') return undefined
return { ...view, content: result.content }
}
/**
* Register the `glob` tool and its system-prompt guidance.
*
@@ -169,6 +188,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
},
},
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }],
presentationMeta: (_args, value) => globSearchMeta(value.paths, caps.maxResults),
},
async execute(args, exec) {
const input = parseGlobArgs(args)
@@ -184,6 +204,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
return { paths: all }
},
presentCall: presentGlobCall,
presentResult: presentGlobResult,
})
ctx.tools.register(tool)

View File

@@ -12,13 +12,14 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { grepSearchMeta, searchViewFromMeta } from './presentation.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
@@ -268,6 +269,27 @@ export function presentGrepCall(args: { pattern: string; path?: string; include?
return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern }
}
/**
* Completed-call presentation: the search card projected from the result's
* `presentationMeta` (matches grouped by file, with the truncation signal), with
* the model-facing result text attached as `content` for a UI without a search
* card. Malformed or absent metadata (an obsolete or hand-edited replayed log)
* falls back to the generic card.
*
* @param _args - the raw tool arguments; unused, the view derives from the result.
* @param result - the final model-facing tool result carrying the projected metadata.
* @returns the search card view, or `undefined` for the generic fallback.
*/
export function presentGrepResult(
_args: { pattern: string; path?: string; include?: string },
result: ToolResult,
): SearchResultView | undefined {
if (result.isError) return undefined
const view = searchViewFromMeta(result.meta)
if (view === undefined || view.kind !== 'matches') return undefined
return { ...view, content: result.content }
}
/**
* Register the `grep` tool and its system-prompt guidance.
*
@@ -317,6 +339,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
type: 'text',
text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes),
}],
presentationMeta: (_args, value) => grepSearchMeta(value.matches, caps.maxMatches, caps.maxLineBytes),
},
async execute(args, exec) {
const input = parseGrepArgs(args)
@@ -335,6 +358,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
return { matches: all }
},
presentCall: presentGrepCall,
presentResult: presentGrepResult,
})
ctx.tools.register(tool)

View File

@@ -33,7 +33,7 @@ import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall } from './glob.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult } from './glob.ts'
export type { GlobInput, GlobToolCaps } from './glob.ts'
export {
GREP_MAX_LINE_BYTES,
@@ -45,9 +45,12 @@ export {
parseGrepArgs,
parseGrepMatches,
presentGrepCall,
presentGrepResult,
previewLine,
} from './grep.ts'
export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts'
export { globSearchMeta, grepSearchMeta, groupMatchesByFile, searchViewFromMeta } from './presentation.ts'
export type { SearchMeta } from './presentation.ts'
export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
export type { RipgrepRun, SearchErrorCode } from './search-core.ts'
export { singleQuote } from './shell-quote.ts'

View File

@@ -0,0 +1,149 @@
/**
* Result-time search-card presentation for `grep` and `glob`. Both tools land on
* one `card: 'search'` render intent ({@link SearchResultView}) with two
* `kind`-discriminated shapes: `grep` projects its matches grouped by file
* ({@link SearchMatchesResultView}), `glob` projects a flat path list
* ({@link SearchPathsResultView}). This module owns the value→`presentationMeta`
* projection each tool declares and the defensive `meta`→view narrowing each
* tool's `presentResult` reads back on replay.
*
* The canonical value never crosses the wire — only the model-facing render text
* and this JSON `meta` do — so the structured shape a UI renders MUST ride in
* `meta`. Each projection applies the SAME inline cap the model-facing render
* applies ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `grepMaxMatches`,
* {@link module:@deepseek-ai/dsh-tool-fs-search/glob} `globMaxResults`) and reports
* `total` (every result found) and `truncated`, so a UI never presents a capped
* result as complete.
*
* @module @deepseek-ai/dsh-tool-fs-search/presentation
*/
import type {
SearchFileMatches,
SearchLineMatch,
SearchResultView,
} from '@deepseek-ai/dsh-tools'
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
import type { GrepMatch } from './grep.ts'
import { previewLine } from './grep.ts'
/**
* The `grep`/`glob` tools' private `tool/result` `meta` payload: the capped,
* structured search result. Attached opaquely (as `JsonValue`) on the tool result
* and persisted with the session log, so `presentResult` reproduces the search
* card on replay. The `matches` shape carries the by-file groups; the `paths`
* shape carries the flat list. Both carry the pre-cap `total` and the `truncated`
* flag. The producing tool owns and narrows this opaque shape.
*
* The member shapes use object-literal `type` aliases rather than the
* {@link SearchFileMatches}/{@link SearchLineMatch} interfaces because only a type
* alias is assignable to the `JsonValue` index signature `presentationMeta`
* returns; the two are structurally identical, so the projected value still reads
* back as a {@link SearchResultView}.
*/
export type SearchMeta =
| { kind: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number }
| { kind: 'paths'; paths: string[]; truncated: boolean; total: number }
/** One matched line in {@link SearchMeta} (the JSON-assignable form of {@link SearchLineMatch}). */
type MetaLineMatch = { lineNumber: number; line: string }
/** One file's grouped matches in {@link SearchMeta} (the JSON-assignable form of {@link SearchFileMatches}). */
type MetaFileMatches = { path: string; matches: MetaLineMatch[] }
/**
* Group flat matches by file (first-seen order) into the structured by-file shape
* a UI renders as expandable per-file groups. The grouping matches the
* model-facing text grouping
* ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `formatGrepMatches`), so
* card and text agree about file order and membership.
*
* @param matches - the retained matches to group, in output order.
* @returns one entry per file, in first-seen order.
*/
export function groupMatchesByFile(matches: GrepMatch[]): MetaFileMatches[] {
const byFile = new Map<string, MetaLineMatch[]>()
for (const match of matches) {
const entry: MetaLineMatch = { lineNumber: match.lineNumber, line: match.line }
const group = byFile.get(match.path)
if (group !== undefined) group.push(entry)
else byFile.set(match.path, [entry])
}
return Array.from(byFile, ([path, fileMatches]) => ({ path, matches: fileMatches }))
}
/**
* Project the canonical `grep` matches into {@link SearchMeta} for the search
* card. Applies the per-line preview budget and the inline match cap exactly as
* the model-facing render does, groups the retained matches by file, and reports
* `total` (every parsed match) and `truncated`.
*
* @param matches - every match the search parsed (the canonical value's matches).
* @param maxMatches - the inline match cap (the `grepMaxMatches` config).
* @param maxLineBytes - the per-matched-line preview budget in bytes.
* @returns the `matches`-shaped search metadata.
*/
export function grepSearchMeta(matches: GrepMatch[], maxMatches: number, maxLineBytes: number): SearchMeta {
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
for (const match of matches) retainer.push({ ...match, line: previewLine(match.line, maxLineBytes) })
const retained = retainer.finish()
return { kind: 'matches', files: groupMatchesByFile(retained.items), truncated: retained.truncated, total: retained.seen }
}
/**
* Project the canonical `glob` paths into {@link SearchMeta} for the search card.
* Applies the inline path cap exactly as the model-facing render does and reports
* `total` (every discovered path) and `truncated`.
*
* @param paths - every path the search discovered (the canonical value's paths).
* @param maxResults - the inline path cap (the `globMaxResults` config).
* @returns the `paths`-shaped search metadata.
*/
export function globSearchMeta(paths: string[], maxResults: number): SearchMeta {
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
for (const path of paths) retainer.push(path)
const retained = retainer.finish()
return { kind: 'paths', paths: retained.items, truncated: retained.truncated, total: retained.seen }
}
/** Whether `value` is a valid {@link SearchLineMatch} (defensive narrowing from opaque `meta`). */
function isSearchLineMatch(value: unknown): value is SearchLineMatch {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const { lineNumber, line } = value as Record<string, unknown>
return typeof lineNumber === 'number' && typeof line === 'string'
}
/** Whether `value` is a valid {@link SearchFileMatches} (defensive narrowing from opaque `meta`). */
function isSearchFileMatches(value: unknown): value is SearchFileMatches {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const { path, matches } = value as Record<string, unknown>
return typeof path === 'string' && Array.isArray(matches) && matches.every(isSearchLineMatch)
}
/**
* Narrow opaque live or replayed result metadata to a {@link SearchResultView}.
* Malformed metadata returns `undefined` so `presentResult` can fall back to the
* generic card instead of throwing during replay of an older or hand-edited log.
* The returned view carries no `content`; the caller attaches the model-facing
* result text so a UI without a search card renders it as text.
*
* @param meta - result metadata (the {@link SearchMeta} the tool projected).
* @returns the search view, or `undefined` for absent or malformed metadata.
*/
export function searchViewFromMeta(meta: unknown): SearchResultView | undefined {
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
const record = meta as Record<string, unknown>
const { truncated, total } = record
if (typeof truncated !== 'boolean' || typeof total !== 'number') return undefined
if (record.kind === 'matches') {
const { files } = record
if (!Array.isArray(files) || !files.every(isSearchFileMatches)) return undefined
return { card: 'search', kind: 'matches', files: files, truncated, total }
}
if (record.kind === 'paths') {
const { paths } = record
if (!Array.isArray(paths) || !paths.every((path): path is string => typeof path === 'string')) return undefined
return { card: 'search', kind: 'paths', paths, truncated, total }
}
return undefined
}

View File

@@ -0,0 +1,129 @@
/**
* Unit tests for the search-card presentation layer (`src/presentation.ts`): the
* canonical value → `presentationMeta` projections (`grepSearchMeta`,
* `globSearchMeta`, `groupMatchesByFile`) and the defensive `meta` → view
* narrowing (`searchViewFromMeta`). These pin the by-file grouping, the inline
* cap and `truncated`/`total` honesty, and the malformed-metadata fallback a
* replayed or hand-edited log can deliver.
*/
import { describe, expect, it } from 'vitest'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import {
globSearchMeta,
grepSearchMeta,
groupMatchesByFile,
searchViewFromMeta,
} from '../src/presentation.ts'
import type { GrepMatch } from '../src/grep.ts'
const match = (path: string, lineNumber: number, line: string): GrepMatch => ({ path, lineNumber, line })
describe('groupMatchesByFile', () => {
it('groups matches by first-seen file order, keeping line/lineNumber only', () => {
expect(groupMatchesByFile([
match('b.ts', 2, 'x'),
match('a.ts', 1, 'y'),
match('b.ts', 5, 'z'),
])).toEqual([
{ path: 'b.ts', matches: [{ lineNumber: 2, line: 'x' }, { lineNumber: 5, line: 'z' }] },
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'y' }] },
])
})
it('returns an empty list for no matches', () => {
expect(groupMatchesByFile([])).toEqual([])
})
})
describe('grepSearchMeta', () => {
it('projects grouped matches with total and a false truncation flag within the cap', () => {
const meta = grepSearchMeta([match('a.ts', 1, 'one'), match('a.ts', 2, 'two')], 10, 2000)
expect(meta).toEqual({
kind: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
truncated: false,
total: 2,
})
})
it('caps the retained matches and reports the pre-cap total when truncated', () => {
const meta = grepSearchMeta([match('a.ts', 1, 'one'), match('a.ts', 2, 'two'), match('b.ts', 3, 'three')], 2, 2000)
expect(meta).toEqual({
kind: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
truncated: true,
total: 3,
})
})
it('applies the per-line preview budget (UTF-8 boundary) to the projected line', () => {
const meta = grepSearchMeta([match('a.txt', 1, 'aéaéaéaé')], 10, 7)
expect(meta).toMatchObject({ kind: 'matches', files: [{ path: 'a.txt', matches: [{ lineNumber: 1, line: 'aéaéa (line truncated)' }] }] })
})
})
describe('globSearchMeta', () => {
it('projects the path list with total and a false truncation flag within the cap', () => {
expect(globSearchMeta(['a.ts', 'b.ts'], 10)).toEqual({ kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 })
})
it('caps the retained paths and reports the pre-cap total when truncated', () => {
expect(globSearchMeta(['a.ts', 'b.ts', 'c.ts'], 2)).toEqual({ kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
})
})
describe('searchViewFromMeta (defensive narrowing)', () => {
// The narrowing accepts an opaque JsonValue; a malformed payload is not a
// statically-valid JsonValue, so route every case through one cast helper that
// mirrors how a hand-edited/older session log delivers arbitrary shapes.
const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined
it('narrows a well-formed matches payload into a matches view', () => {
const meta = { kind: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] }], truncated: true, total: 5 }
expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta })
})
it('narrows a well-formed paths payload into a paths view', () => {
const meta = { kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 }
expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta })
})
it('rejects undefined / non-object / array meta', () => {
expect(searchViewFromMeta(undefined)).toBeUndefined()
expect(searchViewFromMeta(null)).toBeUndefined()
expect(searchViewFromMeta(m('nope'))).toBeUndefined()
expect(searchViewFromMeta(m([]))).toBeUndefined()
})
it('rejects a payload with a missing / mistyped truncated or total field', () => {
expect(searchViewFromMeta(m({ kind: 'paths', paths: [], total: 0 }))).toBeUndefined()
expect(searchViewFromMeta(m({ kind: 'paths', paths: [], truncated: 'no', total: 0 }))).toBeUndefined()
expect(searchViewFromMeta(m({ kind: 'paths', paths: [], truncated: false }))).toBeUndefined()
expect(searchViewFromMeta(m({ kind: 'paths', paths: [], truncated: false, total: '0' }))).toBeUndefined()
})
it('rejects an unknown or missing kind discriminant', () => {
expect(searchViewFromMeta(m({ kind: 'other', truncated: false, total: 0 }))).toBeUndefined()
expect(searchViewFromMeta(m({ truncated: false, total: 0 }))).toBeUndefined()
})
it('rejects a matches payload with a malformed files array', () => {
const base = { kind: 'matches', truncated: false, total: 1 }
expect(searchViewFromMeta(m({ ...base, files: 'x' }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, files: [null] }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, files: ['x'] }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, files: [[]] }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, files: [{ path: 1, matches: [] }] }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: 'x' }] }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [null] }] }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [{ lineNumber: '1', line: 'x' }] }] }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [{ lineNumber: 1, line: 2 }] }] }))).toBeUndefined()
})
it('rejects a paths payload with a non-array or non-string-element paths field', () => {
const base = { kind: 'paths', truncated: false, total: 1 }
expect(searchViewFromMeta(m({ ...base, paths: 'x' }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, paths: [1] }))).toBeUndefined()
})
})

View File

@@ -27,7 +27,9 @@ import {
formatGrepMatches,
parseGrepMatches,
presentGlobCall,
presentGlobResult,
presentGrepCall,
presentGrepResult,
previewLine,
toWorkdirRelative,
} from '@deepseek-ai/dsh-tool-fs-search'
@@ -802,6 +804,74 @@ describe('presentation', () => {
expect(presentGrepCall({ pattern: 'todo' })).toMatchObject({ card: 'generic', title: 'Grep todo', kind: 'search' })
expect(presentGrepCall({ pattern: 'todo', path: 'src', include: '*.ts' }).title).toBe('Grep todo in src (*.ts)')
})
it('grep projects a search card from a real execute, grouped by file with total and truncation', async () => {
const { ctx, bash } = await setup({ config: { grepMaxMatches: 2 } })
bash.handler = () => runResult([
matchLine('a.ts', 1, 'one'),
matchLine('a.ts', 2, 'two'),
matchLine('b.ts', 3, 'three'),
'',
].join('\n'))
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected grep success')
// The presentationMeta projection rides the result meta (a surface call).
expect(result.meta).toEqual({
kind: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
truncated: true,
total: 3,
})
const view = presentGrepResult({ pattern: 'e' }, result)
expect(view).toEqual({
card: 'search',
kind: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
truncated: true,
total: 3,
content: result.content,
})
})
it('glob projects a search card from a real execute, a flat path list with total and truncation', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 2 } })
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected glob success')
expect(result.meta).toEqual({ kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
const view = presentGlobResult({ pattern: '*.ts' }, result)
expect(view).toEqual({ card: 'search', kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3, content: result.content })
})
it('nested Code dispatch computes no meta, so presentResult falls back to the generic card', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n`)
const result = await call(ctx, 'grep', { pattern: 'o' }, {
agent: agent('/w'),
parent: Symbol('run_code') as ToolExecutionToken,
})
if (result.isError) throw new Error('expected grep success')
expect(result.meta).toBeUndefined()
expect(presentGrepResult({ pattern: 'o' }, result)).toBeUndefined()
})
it('presentResult returns undefined for a failed result and for the other tool’s meta shape', () => {
const errorResult = { content: [{ type: 'text' as const, text: 'boom' }], isError: true }
expect(presentGrepResult({ pattern: 'x' }, errorResult)).toBeUndefined()
expect(presentGlobResult({ pattern: '*' }, errorResult)).toBeUndefined()
// A grep result carrying a paths-shaped meta (and vice versa) is not this
// tool's shape: each presenter narrows to its own kind and otherwise falls back.
const pathsResult = { content: [], isError: false, meta: { kind: 'paths', paths: ['a.ts'], truncated: false, total: 1 } }
const matchesResult = { content: [], isError: false, meta: { kind: 'matches', files: [], truncated: false, total: 0 } }
expect(presentGrepResult({ pattern: 'x' }, pathsResult)).toBeUndefined()
expect(presentGlobResult({ pattern: '*' }, matchesResult)).toBeUndefined()
})
it('presentResult falls back to the generic card on malformed replayed meta', () => {
const malformed = { content: [], isError: false, meta: { kind: 'matches', files: 'nope', truncated: false, total: 0 } }
expect(presentGrepResult({ pattern: 'x' }, malformed)).toBeUndefined()
expect(presentGlobResult({ pattern: '*' }, { content: [], isError: false, meta: 42 })).toBeUndefined()
})
})
describe('helpers', () => {