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) {