From 7624ca62cb36fefb0e19311d1a6d8d51cf5d15d7 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Wed, 29 Jul 2026 09:06:00 +0800 Subject: [PATCH] fix(engine): keep renderRepoMap's output within maxOutputChars including the marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderRepoMap's pushLine budgets every content line against maxOutputChars, but the truncation marker was appended via lines.push unbudgeted — so a truncated result overshot by up to the marker's length plus a newline, and in the degenerate case returned a marker longer than the budget with no content. maxOutputChars is a token-budget guarantee the prompt builder relies on. Reserve the marker (plus its joining newline) from the budget: on truncation, drop trailing content lines until the marker fits, and when the budget can't hold even the marker alone, return the empty string rather than an over-budget marker. A complete (non-truncated) render is byte-for-byte unchanged. --- .../loopover-engine/src/miner/repo-map.ts | 19 ++++++- test/unit/repo-map.test.ts | 57 ++++++++++++------- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/packages/loopover-engine/src/miner/repo-map.ts b/packages/loopover-engine/src/miner/repo-map.ts index 40d229dc01..56fd64b080 100644 --- a/packages/loopover-engine/src/miner/repo-map.ts +++ b/packages/loopover-engine/src/miner/repo-map.ts @@ -341,6 +341,10 @@ export async function buildRepoMap( /** Render entries into a bounded plain-text outline: one line per symbol (`kind name (line N): signature`), * skipped/empty files noted with a one-line placeholder. Stops once `maxOutputChars` would be exceeded and * appends a truncation marker, so a caller/prompt-builder can tell the map is partial rather than complete. */ +/** The one-line marker appended when the map is truncated to fit `maxOutputChars`. Its length is reserved + * from the budget so the rendered string never exceeds `maxOutputChars` (#9617). */ +const TRUNCATION_MARKER = "… (repo map truncated to fit the output budget)"; + export function renderRepoMap( entries: readonly RepoMapFileEntry[], maxOutputChars = 20_000, @@ -378,6 +382,19 @@ export function renderRepoMap( } } - if (truncated) lines.push("… (repo map truncated to fit the output budget)"); + // A complete render is emitted at full budget, byte-for-byte as before — no marker, no reserved headroom. + if (!truncated) return lines.join("\n"); + + // Truncated: the marker itself must fit inside maxOutputChars (#9617) — pushLine budgets content but the + // marker used to be appended unbudgeted, overshooting by up to its own length + a newline. If the budget + // can't even hold the marker alone, an over-budget marker-only string is worse than nothing: return "". + if (maxOutputChars < TRUNCATION_MARKER.length) return ""; + // Reserve room for the marker (plus its joining newline while content lines remain) by dropping trailing + // content lines until it fits, then append it. + while (lines.length > 0 && length + TRUNCATION_MARKER.length + 1 > maxOutputChars) { + const removed = lines.pop()!; + length -= lines.length === 0 ? removed.length : removed.length + 1; + } + lines.push(TRUNCATION_MARKER); return lines.join("\n"); } diff --git a/test/unit/repo-map.test.ts b/test/unit/repo-map.test.ts index 90cadc6271..f0cf3bc1b2 100644 --- a/test/unit/repo-map.test.ts +++ b/test/unit/repo-map.test.ts @@ -466,9 +466,9 @@ describe("renderRepoMap (#4280)", () => { }), ); const output = renderRepoMap(manyEntries, 200); - expect(output.length).toBeLessThanOrEqual( - 200 + "\n… (repo map truncated to fit the output budget)".length, - ); + // #9617: the marker is reserved from the budget, so the WHOLE result stays within maxOutputChars — + // the marker no longer overshoots (previously up to its own length + a newline over budget). + expect(output.length).toBeLessThanOrEqual(200); expect( output.endsWith("… (repo map truncated to fit the output budget)"), ).toBe(true); @@ -479,39 +479,58 @@ describe("renderRepoMap (#4280)", () => { expect(output).not.toContain("truncated"); }); + it("#9617: result never exceeds maxOutputChars, and a budget too small for the marker yields the empty string", () => { + const marker = "… (repo map truncated to fit the output budget)"; + const manyEntries: RepoMapFileEntry[] = Array.from({ length: 40 }, (_, i) => ({ + path: `src/file${i}.ts`, + language: "typescript", + symbols: [{ kind: "function", name: `fn${i}`, signature: `export function fn${i}() {`, line: 1 }], + })); + + // Bound holds for a spread of budgets, including ones near and below the marker's own length. + for (const budget of [0, 5, marker.length - 1, marker.length, marker.length + 10, 100, 500]) { + const out = renderRepoMap(manyEntries, budget); + expect(out.length).toBeLessThanOrEqual(budget); + } + // A budget below the marker's length can't honestly signal truncation → empty string, never an over-budget marker. + expect(renderRepoMap(manyEntries, marker.length - 1)).toBe(""); + // A budget that holds the marker but no content still marks the truncation (marker only). + expect(renderRepoMap(manyEntries, marker.length)).toBe(marker); + }); + + // #9617: at a budget below the marker's own length, the target line still truncates at its own site + // (skipped / no-symbols / header), but there is no honest room for the marker — so the result is the + // empty string, never an over-budget marker. The three cases exercise the three non-symbol break sites. it("truncates on a skipped-entry line when the budget is exceeded there", () => { - const output = renderRepoMap([skippedEntry, normalEntry], 5); - expect(output).toBe("… (repo map truncated to fit the output budget)"); + expect(renderRepoMap([skippedEntry, normalEntry], 5)).toBe(""); }); it("truncates on a no-symbols-entry line when the budget is exceeded there", () => { - const output = renderRepoMap([emptyEntry, normalEntry], 5); - expect(output).toBe("… (repo map truncated to fit the output budget)"); + expect(renderRepoMap([emptyEntry, normalEntry], 5)).toBe(""); }); it("truncates on a file's header line (before any of its symbols) when the budget is exceeded there", () => { - const output = renderRepoMap([normalEntry], 5); - expect(output).toBe("… (repo map truncated to fit the output budget)"); + expect(renderRepoMap([normalEntry], 5)).toBe(""); }); it("truncates partway through a multi-symbol file's symbol list, keeping the symbols that already fit", () => { + const marker = "… (repo map truncated to fit the output budget)"; const multiSymbolEntry: RepoMapFileEntry = { path: "src/multi.ts", language: "typescript", symbols: [ { kind: "function", name: "a", signature: "function a() {", line: 1 }, - { kind: "function", name: "b", signature: "function b() {", line: 3 }, + // A large second symbol (longer than the marker) so the budget can hold the first symbol AND the + // reserved marker while still truncating this one — the content-retention path #9617 must preserve. + { kind: "function", name: "b", signature: "function bWithAVeryLongSignatureThatExceedsTheMarkerLength() {", line: 3 }, ], }; - const headerAndFirstSymbol = - "src/multi.ts:\n function a (line 1): function a() {"; - const output = renderRepoMap( - [multiSymbolEntry], - headerAndFirstSymbol.length, - ); - expect(output).toBe( - `${headerAndFirstSymbol}\n… (repo map truncated to fit the output budget)`, - ); + const headerAndFirstSymbol = "src/multi.ts:\n function a (line 1): function a() {"; + // Reserve exactly the header+first-symbol content plus the marker (and its joining newline). + const budget = headerAndFirstSymbol.length + 1 + marker.length; + const output = renderRepoMap([multiSymbolEntry], budget); + expect(output).toBe(`${headerAndFirstSymbol}\n${marker}`); + expect(output.length).toBeLessThanOrEqual(budget); }); });