From 23c710a5f11a8d7127de375f262722066f873492 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 22:45:28 +0000 Subject: [PATCH] fix(server): truncated text stays the same when projected again truncateDetail cut text to the byte cap and then appended its marker, so truncated text was always over the cap. Each further projection pass cut again into the old marker and appended a new one, so multibyte text gained a newline and a byte with every pass. Live thread updates were projected twice (the event sink's bounded stream, then the ws live tail), and stored file diffs are bounded and then projected, so clients saw text that differed from the snapshot. The marker now counts toward the cap, so a second pass returns truncated text unchanged. The ws live tail also stops re-projecting events the event sink already projected, saving ~14-16 us per dynamic tool event per subscriber. Replay reads stored events directly and still projects once. Refs #25157 --- .../src/orchestration-v2/WireProjection.test.ts | 12 +++++++++--- apps/server/src/orchestration-v2/WireProjection.ts | 10 +++++++--- apps/server/src/ws.ts | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index 6379cad45dbd0..95d3fcde7f401 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -175,7 +175,9 @@ describe("orchestration V2 wire projection", () => { }); it("truncates detail at a UTF-8 boundary without changing the source", () => { - const progress = "a".repeat(32_767) + "😀" + "x".repeat(100_000); + const marker = "\n… output truncated for transport"; + const prefixBytes = 32_768 - Buffer.byteLength(marker); + const progress = "a".repeat(prefixBytes - 1) + "😀" + "x".repeat(100_000); const item = { ...base, type: "subagent" as const, @@ -184,13 +186,17 @@ describe("orchestration V2 wire projection", () => { driver: ProviderDriverKind.make("codex"), providerInstanceId: ProviderInstanceId.make("codex"), childThreadId: null, - prompt: "Inspect code", + prompt: "漢".repeat(12_000), result: null, progress, }; const projected = projectTurnItemForWire(item); + expect(projectTurnItemForWire(projected)).toEqual(projected); + expect( + Buffer.byteLength(projected.type === "subagent" ? projected.prompt : ""), + ).toBeLessThanOrEqual(32_768); expect(projected.type === "subagent" ? projected.progress : null).toBe( - "a".repeat(32_767) + "\n… output truncated for transport", + "a".repeat(prefixBytes - 1) + marker, ); expect(item.progress).toBe(progress); }); diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 1495a3effa677..c4916546cf8e1 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -26,6 +26,8 @@ const MAX_WIRE_CHECKPOINT_FILES = 200; const MAX_DETAIL_STRING_BYTES = 32_768; const MAX_DYNAMIC_VALUE_BYTES = 16_384; const MAX_ON_DEMAND_BYTES = 256 * 1024; +const TRUNCATION_MARKER = "\n… output truncated for transport"; +const TRUNCATION_MARKER_BYTES = Buffer.byteLength(TRUNCATION_MARKER, "utf8"); function truncateDetail( value: string | undefined, @@ -37,12 +39,14 @@ function truncateDetail( ) { return value; } + // The marker counts toward the cap, so projecting truncated text again leaves it unchanged. + const budget = maxBytes - TRUNCATION_MARKER_BYTES; // UTF-8 needs at least one byte per UTF-16 unit; encode only a potentially fitting prefix. - const prefix = Buffer.from(value.slice(0, maxBytes), "utf8") - .subarray(0, maxBytes) + const prefix = Buffer.from(value.slice(0, budget), "utf8") + .subarray(0, budget) .toString("utf8") .replace(/\uFFFD$/u, ""); - return `${prefix}\n… output truncated for transport`; + return `${prefix}${TRUNCATION_MARKER}`; } /** Bound stored diffs to the client wire limit to avoid retaining invisible data in SQLite and heap. */ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4781fcf78dde6..9b97c61260580 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -813,7 +813,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs Stream.map((stored) => ({ kind: "event" as const, sequence: stored.sequence, - event: projectDomainEventForWire(stored.event), + event: stored.event, })), coalesceThreadLiveStream, Stream.mapError(