Summary
Two truncation helpers in packages/harness write the shared PAYLOAD_TRUNCATION_MARKER format and have the same bug: they slice content to exactly the configured budget, then append the truncation marker AFTER the slice — so the actual output is budget + marker.length characters, not budget.
truncateForPayload (core/collector/normalizer.ts), used for toolInput/toolResponseSummary in the collector's hook normalization.
clip (core/record-archive.ts), used by compactSessionRecord for prompt/assistantText/tool input/responseSummary. This one also documents an idempotency guarantee ("a second pass finds a body already at the cap and returns it untouched"); that guarantee itself holds (verified with a 56-case brute-force sweep — 0 idempotency violations in the current code), but it converges on a value that's already over budget, since the bug is in the first pass's arithmetic, not the unwrap/fold logic.
Reproduction
truncateForPayload("y".repeat(200), 50).length
// -> more than 50
Impact
Low in practice today: the aggregate RECORD_MAX_BYTES cap in compactSessionRecord is enforced separately, via a real Buffer.byteLength() measurement taken after clipping, and drops whole turns oldest-first until the total fits — so it doesn't trust clip()'s per-field promise, and the real storage/transport cap is safe regardless. But both functions directly contradict their own documented "bounded" behavior, and any future caller relying on the documented per-field bound (or a tightened per-field budget) would be silently wrong.
Suggested fix
Since the marker's own length depends on the dropped-char count, which depends on where the text is sliced, which depends on the marker's length, this needs a small converging loop (shrink the slice point until slice + marker fits within budget) in both functions, plus a hard backstop for degenerate tiny budgets. I have a fix ready, verified with a 56-case brute-force sweep checking both the bound and the idempotency property together. Happy to open a PR.
Summary
Two truncation helpers in
packages/harnesswrite the sharedPAYLOAD_TRUNCATION_MARKERformat and have the same bug: they slice content to exactly the configured budget, then append the truncation marker AFTER the slice — so the actual output isbudget + marker.lengthcharacters, notbudget.truncateForPayload(core/collector/normalizer.ts), used fortoolInput/toolResponseSummaryin the collector's hook normalization.clip(core/record-archive.ts), used bycompactSessionRecordforprompt/assistantText/toolinput/responseSummary. This one also documents an idempotency guarantee ("a second pass finds a body already at the cap and returns it untouched"); that guarantee itself holds (verified with a 56-case brute-force sweep — 0 idempotency violations in the current code), but it converges on a value that's already over budget, since the bug is in the first pass's arithmetic, not the unwrap/fold logic.Reproduction
Impact
Low in practice today: the aggregate
RECORD_MAX_BYTEScap incompactSessionRecordis enforced separately, via a realBuffer.byteLength()measurement taken after clipping, and drops whole turns oldest-first until the total fits — so it doesn't trustclip()'s per-field promise, and the real storage/transport cap is safe regardless. But both functions directly contradict their own documented "bounded" behavior, and any future caller relying on the documented per-field bound (or a tightened per-field budget) would be silently wrong.Suggested fix
Since the marker's own length depends on the dropped-char count, which depends on where the text is sliced, which depends on the marker's length, this needs a small converging loop (shrink the slice point until slice + marker fits within budget) in both functions, plus a hard backstop for degenerate tiny budgets. I have a fix ready, verified with a 56-case brute-force sweep checking both the bound and the idempotency property together. Happy to open a PR.