From f6d1bc0e4d13eff6a8cf2466756700fd96c4a31d Mon Sep 17 00:00:00 2001 From: teyrebaz33 Date: Fri, 21 Aug 2026 20:36:32 +0300 Subject: [PATCH] fix(harness): formatValue's truncation marker could push output past VALUE_CAP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth instance of the same bug class found in the repo-wide sweep that also turned up the three fixed in #656 (normalizer.ts, record-archive.ts, resume-brief.ts) -- this one lives in the web frontend (web/src/lib/extract-step-context.ts), a different subsystem entirely (the run-inspector's Debug/Explain macro context builder), so it gets its own PR rather than joining that one. formatValue's doc comment says values are "capped at VALUE_CAP characters," but it sliced to exactly VALUE_CAP then appended a marker after the slice, so the total could exceed VALUE_CAP by the marker's length. Unlike the three fixed in #656, this marker (`\n… (truncated, N chars total)`) reports the value's original total length, which doesn't change with where the slice lands -- so unlike those three, the fix here is a single subtraction, not a converging loop: the marker's length is fixed once `text.length` is known, so there's nothing to iterate on. Updated the one existing test whose exact-match assertion had encoded the old (over-budget) output as the expected value, with the new expected value computed independently. vitest run web/src/lib/extract-step-context.test.ts: 51/51 passing. tsc --noEmit clean on both the server and web tsconfigs. `web/` is excluded from this package's eslint config (see .eslintrc.cjs's ignorePatterns) -- pre-existing, unrelated to this change. No dependency or lockfile changes. --- .changeset/fix-formatvalue-budget-overrun.md | 5 +++++ .../web/src/lib/extract-step-context.test.ts | 8 ++++++-- .../harness/web/src/lib/extract-step-context.ts | 16 +++++++++++----- 3 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-formatvalue-budget-overrun.md diff --git a/.changeset/fix-formatvalue-budget-overrun.md b/.changeset/fix-formatvalue-budget-overrun.md new file mode 100644 index 00000000..daaf8054 --- /dev/null +++ b/.changeset/fix-formatvalue-budget-overrun.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Fix a debug-context helper that could return output slightly longer than its configured character budget. diff --git a/packages/harness/web/src/lib/extract-step-context.test.ts b/packages/harness/web/src/lib/extract-step-context.test.ts index 247ee194..2aa97fed 100644 --- a/packages/harness/web/src/lib/extract-step-context.test.ts +++ b/packages/harness/web/src/lib/extract-step-context.test.ts @@ -154,14 +154,18 @@ describe("extractStepContext — input/output", () => { expect(ctx).toContain("[object Object]"); }); - it("truncates a very large value and marks the truncation", () => { + it("truncates a very large value and marks the truncation, bounding the total output", () => { const step: StepView = { id: "s1", name: "big", status: "passed" }; const huge = "z".repeat(5000); const ctx = extractStepContext(step, { output: huge }); expect(ctx).toContain("… (truncated, 5000 chars total)"); // The head is kept; the value is not pasted whole. expect(ctx).not.toContain(huge); - expect(ctx).toContain("z".repeat(2000)); + // The marker's own length has to come out of VALUE_CAP too, so slightly + // less than 2000 chars of content survive -- the total (content + + // marker) is what's bounded, not the content alone. + expect(ctx).toContain("z".repeat(1968)); + expect(ctx).not.toContain("z".repeat(1969)); }); }); diff --git a/packages/harness/web/src/lib/extract-step-context.ts b/packages/harness/web/src/lib/extract-step-context.ts index c8a7c556..4e6eb01c 100644 --- a/packages/harness/web/src/lib/extract-step-context.ts +++ b/packages/harness/web/src/lib/extract-step-context.ts @@ -103,7 +103,9 @@ export function formatLatency(ms: number): string { * strings pass through verbatim; anything JSON can't represent (a cycle, a * bigint) falls back to `String(value)` so the builder never throws. When the * result exceeds the cap it is head-kept (the shape and leading content matter - * most for a value) with an explicit truncation marker. + * most for a value) with an explicit truncation marker, and the TOTAL output + * (content + marker) is what respects the cap — not the content alone, or the + * marker itself would push the result past VALUE_CAP. */ function formatValue(value: unknown): string { let text: string; @@ -117,10 +119,14 @@ function formatValue(value: unknown): string { text = String(value); } } - if (text.length > VALUE_CAP) { - return `${text.slice(0, VALUE_CAP)}\n… (truncated, ${text.length} chars total)`; - } - return text; + if (text.length <= VALUE_CAP) return text; + // Unlike a "chars dropped" marker, this one reports the original total + // length, which doesn't change with where we slice -- so the marker's + // length is fixed and a single subtraction (no converging loop needed) + // correctly reserves room for it within VALUE_CAP. + const marker = `\n… (truncated, ${text.length} chars total)`; + const sliceLen = Math.max(0, VALUE_CAP - marker.length); + return `${text.slice(0, sliceLen)}${marker}`.slice(0, VALUE_CAP); } /**