diff --git a/src/domain/process/processCaptureValidation.ts b/src/domain/process/processCaptureValidation.ts index 02d0b8157a..a222a47a69 100644 --- a/src/domain/process/processCaptureValidation.ts +++ b/src/domain/process/processCaptureValidation.ts @@ -140,17 +140,39 @@ const validateCoverage = ( hasCaptureTruncation( details, ), "truncation_details", "aggregate truncation must match producer coverage"); - for (const [name, retention, count] of [ - ["raw_terminal", details.raw_terminal, capture.frames.length], + for (const [name, retention, count, bytes] of [ + [ + "raw_terminal", + details.raw_terminal, + capture.frames.length, + capture.frames.reduce( + (total, frame) => + total + Buffer.byteLength(frame.raw_data ?? frame.data), + 0, + ), + ], [ "rendered_terminal", details.rendered_terminal, capture.rendered_frames.length, + capture.rendered_frames.reduce( + (total, frame) => + frame.lines.reduce( + (sum, line) => sum + Buffer.byteLength(line), + total + Buffer.byteLength(frame.serialized_state), + ), + 0, + ), ], ] as const) { + // Whole frames are retained or omitted, so retained bytes are exactly the + // retained frames' bytes, and nothing is unretained when no frame was omitted. require(retention.retained_frames === count && retention.observed_frames >= count && + retention.retained_bytes === bytes && retention.observed_bytes >= retention.retained_bytes && + (retention.observed_frames > count || + retention.observed_bytes === retention.retained_bytes) && retention.retained_bytes <= retention.budget_bytes, `truncation_details.${name}`, "retained observations and budget accounting do not agree"); } diff --git a/tests/boundary/process/processTruncationAdapters.test.ts b/tests/boundary/process/processTruncationAdapters.test.ts index 7a46e5aab3..00e2c7f5c2 100644 --- a/tests/boundary/process/processTruncationAdapters.test.ts +++ b/tests/boundary/process/processTruncationAdapters.test.ts @@ -133,6 +133,30 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)( expect( processCaptureSchema.safeParse({ ...result, truncated: false }).success, ).toBe(false); + const withTerminal = ( + name: "raw_terminal" | "rendered_terminal", + change: Partial, + ) => + processCaptureSchema.safeParse({ + ...result, + truncation_details: { + ...details, + [name]: { ...details[name], ...change }, + }, + }).success; + // Byte totals must match the retained frames, and with no omitted frame + // nothing observed can be missing from retention. + expect(withTerminal("raw_terminal", { retained_bytes: 0 })).toBe(false); + expect( + withTerminal("raw_terminal", { + observed_bytes: details.raw_terminal.observed_bytes + 1, + }), + ).toBe(false); + expect( + withTerminal("rendered_terminal", { + retained_bytes: details.rendered_terminal.retained_bytes - 1, + }), + ).toBe(false); }, 20_000, );