mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user