fix(ui): read every footer of a search answer

The server writes next_offset and truncation_reason under a full page,
candidate_window_saturated for very many candidates, and the lines of a
too small output budget. The search parser knew none of them, so every
search with more than one page fell back to "Index search unavailable".

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-04 18:43:52 +02:00
1 parent edbf36ef20
commit 652332ec73
2 files changed
+129 -1

No files matched your search

@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { parseSearchResults } from './compact-rows';
/*
* Handtest 04.10.: In cbm meldete die Galaxy-Suche bei vielen Begriffen
* "Index search unavailable". Der Server schreibt unter eine Suchantwort mehr
* Fusszeilen, als der Parser kannte (bm25_emit_metadata_tree in src/mcp/mcp.c):
* next_offset und truncation_reason schon bei einer vollen Seite,
* candidate_window_saturated bei sehr vielen Kandidaten, und bei einem zu
* kleinen Ausgabebudget max_output_bytes, continuation_requires_higher_budget,
* output_budget_floor_exceeded und hint. Jede davon liess die Suche scheitern.
*/
/** Wie der Server auf search_graph {project: cbm, query: working, limit: 3} antwortet. */
const PAGED = [
'results: 3 (cols: qn label file lines rank)',
' cbm.graph-ui.tools.freshclone-check.workingTreeChanges Function graph-ui/tools/freshclone-check.mjs 112-138 -15.29',
' cbm.__branch__.working-tree Branch {} - -14.24',
' cbm.tests.test_cypher.cypher_with_alias_stays_in_scope_issue1919 Function tests/test_cypher.c 2151-2164 -12.2',
'total: 14',
'total_relation: eq',
'search_mode: bm25',
'returned: 3',
'has_more: true',
'next_offset: 3',
'truncated: true',
'truncation_reason: page_limit',
'',
].join('\n');
describe('parseSearchResults reads every footer the server writes', () => {
it('reads a full page with next_offset and truncation_reason', () => {
const parsed = parseSearchResults(PAGED);
expect(parsed.rows).toHaveLength(3);
expect(parsed.total).toBe(14);
expect(parsed.hasMore).toBe(true);
expect(parsed.nextOffset).toBe(3);
expect(parsed.truncated).toBe(true);
expect(parsed.truncationReason).toBe('page_limit');
});
it('reads a saturated candidate window', () => {
const saturated = [
'results: 1 (cols: qn label)',
' a.b.c Function',
'total: 1',
'total_relation: gte',
'candidate_window_saturated: true',
'search_mode: bm25',
'returned: 1',
'has_more: false',
'truncated: true',
'truncation_reason: candidate_window',
'',
].join('\n');
const parsed = parseSearchResults(saturated);
expect(parsed.totalRelation).toBe('gte');
expect(parsed.candidateWindowSaturated).toBe(true);
expect(parsed.truncationReason).toBe('candidate_window');
expect(parsed.rows).toHaveLength(1);
});
it('reads the lines of a too small output budget', () => {
const budget = [
'results: 0 (cols: qn label)',
'total: 5',
'total_relation: eq',
'search_mode: bm25',
'returned: 0',
'has_more: true',
'continuation_requires_higher_budget: true',
'truncated: true',
'truncation_reason: output_budget',
'max_output_bytes: 200',
'output_budget_floor_exceeded: true',
'hint: "first identity row exceeds max_output_bytes; raise max_output_bytes"',
'',
].join('\n');
const parsed = parseSearchResults(budget);
expect(parsed.rows).toHaveLength(0);
expect(parsed.truncationReason).toBe('output_budget');
expect(parsed.maxOutputBytes).toBe(200);
expect(parsed.continuationRequiresHigherBudget).toBe(true);
expect(parsed.outputBudgetFloorExceeded).toBe(true);
expect(parsed.hint).toContain('max_output_bytes');
});
it('still rejects a line it does not know', () => {
expect(() => parseSearchResults(PAGED.replace('truncation_reason: page_limit', 'surprise: 1'))).toThrow(/unbekannte Zeile/);
});
});
+37 -1
View File
@@ -63,8 +63,23 @@ export interface SearchResults {
returned?: number;
totalRelation?: string;
truncated?: boolean;
/** Was bm25_emit_metadata_tree (src/mcp/mcp.c) ausserdem schreibt: die naechste Seite,
* warum gekappt wurde, ein gesaettigtes Kandidatenfenster und ein zu kleines Budget. */
nextOffset?: number;
truncationReason?: string;
candidateWindowSaturated?: boolean;
continuationRequiresHigherBudget?: boolean;
maxOutputBytes?: number;
outputBudgetFloorExceeded?: boolean;
hint?: string;
}
/** Fusszeilen der Suchantwort mit Zahl, Wahrheitswert oder Text (Handtest 04.10.: jede
* unbekannte liess die Suche mit "Index search unavailable" scheitern). */
const SEARCH_NUMBER_LINE = /^(next_offset|max_output_bytes):\s*(\d+)\s*$/;
const SEARCH_FLAG_LINE = /^(candidate_window_saturated|continuation_requires_higher_budget|output_budget_floor_exceeded):\s*(true|false)\s*$/;
const SEARCH_TEXT_LINE = /^(truncation_reason|hint):\s*(.*)$/;
const ROWS_HEAD = /^rows:\s*(\d+)\s*\(cols:\s*([^)]*)\)\s*$/;
const RESULTS_HEAD = /^results:\s*(\d+)\s*\(cols:\s*([^)]*)\)\s*$/;
const TOTAL_LINE = /^total:\s*(\d+)\s*$/;
@@ -341,6 +356,7 @@ export function parseSearchResults(text: string): SearchResults {
let totalRelation: string | undefined;
let truncated: boolean | undefined;
let refs: ReadonlyMap<number, string> | undefined;
const footers: Pick<SearchResults, 'nextOffset' | 'truncationReason' | 'candidateWindowSaturated' | 'continuationRequiresHigherBudget' | 'maxOutputBytes' | 'outputBudgetFloorExceeded' | 'hint'> = {};
let i = 0;
while (i < lines.length) {
@@ -394,6 +410,26 @@ export function parseSearchResults(text: string): SearchResults {
i += 1;
continue;
}
const numberMatch = SEARCH_NUMBER_LINE.exec(line);
if (numberMatch !== null) {
footers[numberMatch[1] === 'next_offset' ? 'nextOffset' : 'maxOutputBytes'] = Number.parseInt(numberMatch[2], 10);
i += 1;
continue;
}
const flagMatch = SEARCH_FLAG_LINE.exec(line);
if (flagMatch !== null) {
const key = flagMatch[1] === 'candidate_window_saturated' ? 'candidateWindowSaturated'
: flagMatch[1] === 'continuation_requires_higher_budget' ? 'continuationRequiresHigherBudget' : 'outputBudgetFloorExceeded';
footers[key] = flagMatch[2] === 'true';
i += 1;
continue;
}
const textMatch = SEARCH_TEXT_LINE.exec(line);
if (textMatch !== null) {
footers[textMatch[1] === 'truncation_reason' ? 'truncationReason' : 'hint'] = unquote(textMatch[2].trim());
i += 1;
continue;
}
const head = RESULTS_HEAD.exec(line);
if (head !== null) {
declared = Number.parseInt(head[1], 10);
@@ -426,7 +462,7 @@ export function parseSearchResults(text: string): SearchResults {
throw new Error('Suchantwort ohne has_more-Zeile');
}
const out: SearchResults = { total, mode, columns, rows, hasMore };
const out: SearchResults = { total, mode, columns, rows, hasMore, ...footers };
if (returned !== undefined) {
out.returned = returned;
}