From a338c1cae1af63b7d50edcaeb26aee49fa91e82a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 6 Sep 2026 18:10:49 +0200 Subject: [PATCH] fix(ui): the reader asks for full source and pages through long files Second drift of the lean output contract (#1597), found on a real project: get_code_snippet now outlines any container of 200 lines or more unless source_mode is "full", and full source arrives in pages of at most 500 lines with next_start_line and original_end_line. The reader loaded a file through its module node with the old request, so on main every file longer than 200 lines came back as an outline with no source ("get_code_snippet returned no source for ..."), and even with "full" only the first 500 lines would have shown. - rpc-client: getCodeSnippet sends source_mode "full" and takes an optional page window (start_line, max_lines); the provider's symbol snippets benefit the same way. - rpc-schemas: the page fields are read (source_mode, source_truncated, next_start_line, original_end_line), optional so recorded answers from before the contract keep parsing. - file-source: pages are fetched one after the other and joined; a page that does not arrive ends the loading and the note under the source names the missing lines. The single-window behaviour of servers before the contract is unchanged. The server's "(source not available)" placeholder, sent when the indexed file is no longer on disk where the index expects it, is refused as source and reported as what it is. Tests: two pages joined with the calls the server needs, a missing page named, the placeholder refused; the client's request pinned. Reader and provider suites 149 passed; full suite green with both gates. Signed-off-by: Martin Vogel --- graph-ui/src/provider/rpc-client.test.ts | 12 +++ graph-ui/src/provider/rpc-client.ts | 27 ++++++- graph-ui/src/provider/rpc-schemas.ts | 25 +++++++ graph-ui/src/reader/file-source.test.ts | 82 +++++++++++++++++++++ graph-ui/src/reader/file-source.ts | 93 ++++++++++++++++++++---- 5 files changed, 221 insertions(+), 18 deletions(-) diff --git a/graph-ui/src/provider/rpc-client.test.ts b/graph-ui/src/provider/rpc-client.test.ts index 653982b7e..8185fa432 100644 --- a/graph-ui/src/provider/rpc-client.test.ts +++ b/graph-ui/src/provider/rpc-client.test.ts @@ -94,6 +94,18 @@ describe('die Werkzeuge, die von sich aus JSON liefern', () => { expect(snippet.source).toContain('export function createUser'); }); + it('bittet um vollen Quelltext, seit der Server Container sonst nur umreisst', async () => { + const { client: c, rpc } = client([{ + tool: 'get_code_snippet', + json: { start_line: 1, end_line: 500, source: 'x\n', next_start_line: 501, original_end_line: 900, source_mode: 'full' }, + }]); + const page = await c.getCodeSnippet(RECORDED_PROJECT, 'x', { startLine: 1, maxLines: 500 }); + expect(page.next_start_line).toBe(501); + expect(page.original_end_line).toBe(900); + expect(page.source_mode).toBe('full'); + expect(rpc.callsTo('get_code_snippet')[0]?.args).toMatchObject({ source_mode: 'full', start_line: 1, max_lines: 500 }); + }); + it('liest den Indexzustand', async () => { const { client: c } = client([{ tool: 'index_status', diff --git a/graph-ui/src/provider/rpc-client.ts b/graph-ui/src/provider/rpc-client.ts index bd534b362..e5ff11aaf 100644 --- a/graph-ui/src/provider/rpc-client.ts +++ b/graph-ui/src/provider/rpc-client.ts @@ -337,11 +337,32 @@ export class RpcIntelligenceClient { return readSearchRows(rows, parsed.total); } - async getCodeSnippet(project: string, qualifiedName: string): Promise { - return readCodeSnippet(await this.json('get_code_snippet', { + /** + * A symbol's or a module's source. + * + * `source_mode: 'full'` is part of the request since the lean output + * contract (#1597): without it the server outlines every container of + * 200 lines or more and sends members instead of source. Full source + * comes in pages of at most 500 lines; `window` names the page, and the + * answer's `next_start_line` says whether another one follows. + */ + async getCodeSnippet( + project: string, + qualifiedName: string, + window?: { startLine?: number; maxLines?: number }, + ): Promise { + const args: Record = { project, qualified_name: qualifiedName, - })); + source_mode: 'full', + }; + if (window?.startLine !== undefined) { + args['start_line'] = window.startLine; + } + if (window?.maxLines !== undefined) { + args['max_lines'] = window.maxLines; + } + return readCodeSnippet(await this.json('get_code_snippet', args)); } async getArchitecture(project: string, aspects: string[] = ['all']): Promise { diff --git a/graph-ui/src/provider/rpc-schemas.ts b/graph-ui/src/provider/rpc-schemas.ts index 2ec7586a7..6ded6948d 100644 --- a/graph-ui/src/provider/rpc-schemas.ts +++ b/graph-ui/src/provider/rpc-schemas.ts @@ -306,6 +306,14 @@ export interface CodeSnippetResult { start_line?: number; end_line?: number; source: string; + /** `full` or `outline` since the lean output contract; absent before it. */ + source_mode?: string; + /** The server cut this page and says so (lean contract). */ + source_truncated?: boolean; + /** The first line of the next page, when there is one (lean contract). */ + next_start_line?: number; + /** The file's real last line when the answer is a page of it (lean contract). */ + original_end_line?: number; /** * Der Server hat den Schnipsel gekappt und sagt es selbst. * @@ -399,6 +407,23 @@ export function readCodeSnippet(value: unknown): CodeSnippetResult { if (clippedAt !== undefined) { result.clipped_at_lines = clippedAt; } + /* The lean output contract (#1597) pages source: `next_start_line` names + * the next page, `original_end_line` the file's real last line, and + * `source_mode` says whether source or an outline came back. */ + if (typeof raw['source_mode'] === 'string') { + result.source_mode = raw['source_mode'] as string; + } + if (raw['source_truncated'] !== undefined) { + result.source_truncated = toBoolean(raw['source_truncated']); + } + const nextStart = toOptionalNumber(raw['next_start_line']); + if (nextStart !== undefined) { + result.next_start_line = nextStart; + } + const originalEnd = toOptionalNumber(raw['original_end_line']); + if (originalEnd !== undefined) { + result.original_end_line = originalEnd; + } return result; } diff --git a/graph-ui/src/reader/file-source.test.ts b/graph-ui/src/reader/file-source.test.ts index d64cd4855..edc5281f7 100644 --- a/graph-ui/src/reader/file-source.test.ts +++ b/graph-ui/src/reader/file-source.test.ts @@ -150,6 +150,88 @@ describe('loadFileDocument', () => { }); }); +describe('loadFileDocument, seit dem schlanken Vertrag (#1597)', () => { + + /* + * Auf main liefert get_code_snippet Quelltext in Seiten zu 500 Zeilen und + * nennt die naechste in next_start_line; die wirkliche Dateilaenge steht + * in original_end_line. Die Formen unten sind die, die der Server am + * 2026-09-06 fuer src/ui/http_server.c (2244 Zeilen) geantwortet hat. + */ + it('fordert vollen Quelltext an und fuegt die Seiten zusammen', async () => { + const { client, rpc } = clientFor([ + moduleRoute('probe-small.src.ui.http_server', 2244), + { + tool: 'get_code_snippet', + when: (args) => args['start_line'] === undefined, + json: { + qualified_name: 'probe-small.src.ui.http_server', label: 'Module', + start_line: 1, end_line: 500, source_truncated: true, source_clipped: true, + next_start_line: 501, original_end_line: 1000, source_mode: 'full', + source: 'page one\n', + }, + }, + { + tool: 'get_code_snippet', + when: (args) => args['start_line'] === 501, + json: { + qualified_name: 'probe-small.src.ui.http_server', label: 'Module', + start_line: 501, end_line: 1000, source_mode: 'full', source: 'page two\n', + }, + }, + ]); + + const doc = await loadFileDocument(client, PROJECT, 'src/ui/http_server.c'); + + expect(doc.source).toBe('page one\npage two\n'); + expect(doc.firstLine).toBe(1); + expect(doc.lastLine).toBe(1000); + expect(doc.fileLastLine).toBe(1000); + expect(doc.truncated).toBe(false); + expect(doc.truncationNote).toBe(''); + const calls = rpc.callsTo('get_code_snippet'); + expect(calls).toHaveLength(2); + expect(calls[0]?.args['source_mode']).toBe('full'); + expect(calls[1]?.args['start_line']).toBe(501); + expect(calls[1]?.args['max_lines']).toBe(500); + }); + + it('benennt die fehlende Seite, wenn eine nicht ankommt', async () => { + const { client } = clientFor([ + moduleRoute('probe-small.src.ui.http_server', 2244), + { + tool: 'get_code_snippet', + when: (args) => args['start_line'] === undefined, + json: { + start_line: 1, end_line: 500, source_clipped: true, next_start_line: 501, + original_end_line: 1000, source_mode: 'full', source: 'page one\n', + }, + }, + { tool: 'get_code_snippet', when: (args) => args['start_line'] === 501, toolError: 'boom' }, + ]); + + const doc = await loadFileDocument(client, PROJECT, 'src/ui/http_server.c'); + + expect(doc.source).toBe('page one\n'); + expect(doc.lastLine).toBe(500); + expect(doc.truncated).toBe(true); + expect(doc.truncationNote).toContain('501-1000'); + expect(doc.truncationNote).toContain('did not arrive'); + }); + + it('nimmt den Platzhalter des Servers nicht als Quelltext', async () => { + const { client } = clientFor([ + moduleRoute('probe-small.src.ui.http_server', 2244), + snippetRoute({ start_line: 1, end_line: 500, source_mode: 'full', source: '(source not available)' }), + ]); + + await expect(loadFileDocument(client, PROJECT, 'src/ui/http_server.c')) + .rejects.toBeInstanceOf(FileNotReadableError); + await expect(loadFileDocument(client, PROJECT, 'src/ui/http_server.c')) + .rejects.toThrow(/could not read/); + }); +}); + describe('truncationNoteFor', () => { it('nennt Zeilen, Deckel und den Grund, warum nicht nachgeladen wird', () => { diff --git a/graph-ui/src/reader/file-source.ts b/graph-ui/src/reader/file-source.ts index a4ce3b0b2..3a82e2128 100644 --- a/graph-ui/src/reader/file-source.ts +++ b/graph-ui/src/reader/file-source.ts @@ -13,19 +13,22 @@ * Schnipsel (cbm/src/mcp/mcp.c). Der Server sagt es selbst mit * `source_clipped`. Eine laengere Datei kommt also unvollstaendig an, und * der Reader schreibt darunter, welche Zeilen fehlen und warum. - * 2. **Nachladen geht nicht.** `get_code_snippet` nimmt `qualified_name`, - * `project` und `include_neighbors`, sonst nichts. `start_line` und - * `end_line` werden angenommen und ignoriert: dieselbe Anfrage mit - * start_line 501 liefert wieder die Zeilen 1 bis 500. Gemessen am - * laufenden Server, nachzulesen in verification/w2/reader.json unter - * `windowSemantics`. Es gibt darum kein "load more": eine Schaltflaeche, - * die dasselbe noch einmal holt, waere eine Luege in Knopfform. + * 2. **Nachladen gibt es seit dem schlanken Ausgabevertrag (#1597).** Bis + * dahin wurden `start_line` und `end_line` angenommen und ignoriert + * (gemessen, verification/w2/reader.json unter `windowSemantics`). Seit + * dem Vertrag liefert `source_mode: full` Seiten zu 500 Zeilen, und die + * Antwort nennt in `next_start_line` die naechste; loadFileDocument holt + * sie nacheinander und fuegt sie zusammen. Ohne `source_mode: full` + * umreisst der Server jeden Container ab 200 Zeilen und schickt gar + * keinen Quelltext. Einen "load more"-Knopf gibt es weiterhin nicht: die + * Seiten kommen von selbst, und was nicht kommt, steht als Satz darunter. * 3. **Nur, was indiziert ist.** Eine Datei ohne Modul-Knoten ist ueber diesen * Weg nicht lesbar. Der Reader sagt das, statt eine leere Flaeche zu zeigen. */ import { fileNodeForPath, moduleForFile, COLUMNS } from '../provider/cypher'; import type { RpcIntelligenceClient } from '../provider/rpc-client'; +import type { CodeSnippetResult } from '../provider/rpc-schemas'; import { moduleQualifiedName, moduleQnFromFileQn, normalizeWorkspacePath } from '../app/module-qn'; /** Das Werkzeug, aus dem der Inhalt des Readers kommt. Der Beweislauf schreibt es mit. */ @@ -113,6 +116,23 @@ async function lookupModuleNode( return derived === undefined ? undefined : { qualifiedName: derived, source: 'graph-file' }; } +/** What the server writes in place of source when it cannot read the file. */ +export const SOURCE_UNAVAILABLE = '(source not available)'; + +/** The server's page size for full source (MCP_SNIPPET_MAX_LINES). */ +export const SOURCE_PAGE_LINES = 500; + +/** More pages than this is not a source file this reader should be loading. */ +export const MAX_SOURCE_PAGES = 100; + +/** The note when the server pages source and a page did not arrive. */ +export function pagingNoteFor(lastLine: number, fileLastLine: number | undefined): string { + if (fileLastLine === undefined) { + return `lines after ${lastLine} not loaded: a further page of source did not arrive from the server (get_code_snippet).`; + } + return `lines ${lastLine + 1}-${fileLastLine} not loaded: a further page of source did not arrive from the server (get_code_snippet).`; +} + /** * Der Satz unter dem Quelltext, wenn nicht die ganze Datei angekommen ist. * @@ -152,31 +172,74 @@ export async function loadFileDocument( const qualifiedName = node.qualifiedName; const snippet = await client.getCodeSnippet(project, qualifiedName); - if (snippet.source.length === 0) { + if (snippet.source.length === 0 || snippet.source === SOURCE_UNAVAILABLE) { throw new FileNotReadableError( path, - `get_code_snippet returned no source for ${qualifiedName}.`, + snippet.source === SOURCE_UNAVAILABLE + ? `The server could not read ${path} from the repository (${qualifiedName}): ` + + 'the index knows the file, the disk does not have it where the index expects it.' + : `get_code_snippet returned no source for ${qualifiedName}.`, ); } + /* + * Since the lean output contract the server pages source 500 lines at a + * time and names the next page in `next_start_line`. The pages are + * fetched one after the other and joined; a page that does not arrive + * ends the loading, and the note below names what is missing rather + * than a guess. Before the contract there was one window and no + * `next_start_line`, and that path is unchanged. + */ const firstLine = snippet.start_line ?? 1; - const lastLine = snippet.end_line ?? firstLine; - const fileLastLine = node.endLine; - const clipped = snippet.source_clipped === true; + let source = snippet.source; + let lastLine = snippet.end_line ?? firstLine; + let next = snippet.next_start_line; + let pages = 1; + let pageFailed = false; + while (next !== undefined && pages < MAX_SOURCE_PAGES) { + let page: CodeSnippetResult; + try { + page = await client.getCodeSnippet(project, qualifiedName, { + startLine: next, + maxLines: SOURCE_PAGE_LINES, + }); + } catch { + pageFailed = true; + break; + } + if (page.source.length === 0 || page.source === SOURCE_UNAVAILABLE + || (page.start_line !== undefined && page.start_line !== next)) { + pageFailed = true; + break; + } + if (!source.endsWith('\n')) { + source += '\n'; + } + source += page.source; + lastLine = page.end_line ?? lastLine; + next = page.next_start_line; + pages += 1; + } + const fileLastLine = snippet.original_end_line ?? node.endLine; + const pagesMissing = pageFailed || next !== undefined; + const singleWindowClipped = pages === 1 && snippet.next_start_line === undefined + && snippet.source_clipped === true; const shortOfFile = fileLastLine !== undefined && fileLastLine > lastLine; - const truncated = clipped || shortOfFile; + const truncated = pagesMissing || singleWindowClipped || shortOfFile; const document: ReaderDocument = { path, qualifiedName, qnSource: qualifiedName === derivedQualifiedName ? 'derived' : node.source, derivedQualifiedName, - source: snippet.source, + source, firstLine, lastLine, truncated, truncationNote: truncated - ? truncationNoteFor(lastLine, fileLastLine, snippet.clipped_at_lines) + ? (pagesMissing + ? pagingNoteFor(lastLine, fileLastLine) + : truncationNoteFor(lastLine, fileLastLine, snippet.clipped_at_lines)) : '', }; if (fileLastLine !== undefined) {