diff --git a/src/engine/redaction.ts b/src/engine/redaction.ts index 5d3daa2..f4750f2 100644 --- a/src/engine/redaction.ts +++ b/src/engine/redaction.ts @@ -79,12 +79,39 @@ export function redact(input: string): string { return out; } -/** Redact and cap length so a runaway log can't blow up a critic call. */ +/** + * Redact and cap length so a runaway log can't blow up a critic call. + * + * Structure-aware (inspired by pi's harness `truncate.ts`): when the content + * has line structure — diffs, build/test output — the head is snapped back to + * the last line boundary within budget and the tail forward to the next one, so + * the critic never reasons over a line cut mid-token. The elision marker + * reports how much (chars and lines) was dropped. A single very long line with + * no boundaries falls back to a hard head/tail char slice. + */ export function redactAndTruncate(input: string, maxChars = 8_000): string { const redacted = redact(input); if (redacted.length <= maxChars) return redacted; - const head = redacted.slice(0, Math.floor(maxChars * 0.7)); - const tail = redacted.slice(-Math.floor(maxChars * 0.2)); - const omitted = redacted.length - head.length - tail.length; - return `${head}\n...[${omitted} chars truncated]...\n${tail}`; + + const headBudget = Math.floor(maxChars * 0.7); + const tailBudget = Math.floor(maxChars * 0.2); + + // Head: take the budget, then trim back to the last complete line. + let head = redacted.slice(0, headBudget); + const headNl = head.lastIndexOf("\n"); + if (headNl >= 0) head = head.slice(0, headNl); + + // Tail: take the budget from the end, then trim forward to start of a line. + let tail = redacted.slice(redacted.length - tailBudget); + const tailNl = tail.indexOf("\n"); + if (tailNl >= 0) tail = tail.slice(tailNl + 1); + + const omittedChars = redacted.length - head.length - tail.length; + if (omittedChars <= 0) return redacted; // budgets overlapped; nothing to drop + + const omittedLines = + redacted.slice(head.length, redacted.length - tail.length).split("\n").length - 1; + const linesPart = omittedLines > 0 ? ` / ${omittedLines} lines` : ""; + + return `${head}\n...[${omittedChars} chars${linesPart} truncated]...\n${tail}`; } diff --git a/test/redaction.test.ts b/test/redaction.test.ts index b2bcd21..1de5998 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -40,6 +40,43 @@ describe("redaction", () => { expect(out).toContain("END"); expect(out).toContain("truncated"); }); + + it("truncates multi-line output on line boundaries, reporting omitted lines", () => { + const lines = Array.from({ length: 300 }, (_, i) => `line-${i}: ${"y".repeat(40)}`); + const out = redactAndTruncate(lines.join("\n"), 600); + + expect(out).toContain("line-0:"); // first line kept + expect(out).toContain("line-299:"); // last line kept + expect(out).toContain("lines truncated"); + expect(out).not.toContain("line-150:"); // a middle line dropped + + // Every retained line in the head/tail segments is complete (no mid-line cut). + const segments = out.split(/\n\.\.\.\[[^\]]*\]\.\.\.\n/); + expect(segments).toHaveLength(2); + for (const seg of segments) { + for (const ln of seg.split("\n").filter(Boolean)) { + expect(ln).toMatch(/^line-\d+: y{40}$/); + } + } + }); + + it("falls back to a hard char slice for a single very long line", () => { + const big = "START" + "x".repeat(5_000) + "END"; + const out = redactAndTruncate(big, 500); + expect(out).toContain("START"); + expect(out).toContain("END"); + expect(out).toContain("truncated"); + expect(out.length).toBeLessThan(1_000); + }); + + it("still redacts secrets in the retained head/tail after truncation", () => { + const text = + "OPENAI_API_KEY=sk-abcdef0123456789abcdef\n" + "log line\n".repeat(2_000) + "trailing FOO=bar"; + const out = redactAndTruncate(text, 400); + expect(out).toContain("OPENAI_API_KEY=[REDACTED]"); + expect(out).not.toContain("sk-abcdef"); + expect(out).toContain("trailing FOO=bar"); + }); });