From f614805ae28923cc640d20e8357c577c449e8c8e Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:34:01 +1100 Subject: [PATCH] fix(server): Claude live command output no longer starts with replacement characters after skipping ahead Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeTaskOutputTail.test.ts | 7 ++++--- .../orchestration-v2/Adapters/ClaudeTaskOutputTail.ts | 11 ++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index b70b7322d5e63..a1a4212326868 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -91,11 +91,12 @@ describe("tailClaudeTaskOutput", () => { }).pipe(Effect.forkScoped); assert.equal(yield* Queue.take(chunks), "start\n"); - yield* fileSystem.writeFileString(file, `start\n${"y\n".repeat(1_000_000)}end\n`); + // The skip lands on the second byte of a two-byte é. + yield* fileSystem.writeFileString(file, `start\n${"é\n".repeat(1_000_000)}end.\n`); const flooded = yield* Queue.take(chunks); assert.isAtMost(flooded.length, 256 * 1024 + 1); - assert.isTrue(flooded.startsWith("\n")); - assert.isTrue(flooded.endsWith("y\nend\n")); + assert.equal(flooded.slice(0, 3), "\n\né"); + assert.isTrue(flooded.endsWith("é\nend.\n")); }), ).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts index 77505d24a5bbf..7eac250d7afde 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts @@ -104,10 +104,15 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct if (Option.isSome(bytes)) { // Skipped output must not splice two unrelated lines together. const skipped = start > offset; - // Reset partial UTF-8 bytes when skipping output. - if (skipped) decoder = new TextDecoder("utf-8", { fatal: false }); + let first = 0; + if (skipped) { + // Reset partial UTF-8 bytes, and skip the rest of a code point the cut landed inside. + decoder = new TextDecoder("utf-8", { fatal: false }); + while (((bytes.value[first] ?? 0) & 0xc0) === 0x80) first += 1; + } offset = start + bytes.value.byteLength; - const text = (skipped ? "\n" : "") + decoder.decode(bytes.value, { stream: true }); + const text = + (skipped ? "\n" : "") + decoder.decode(bytes.value.subarray(first), { stream: true }); if (text.length > 0) yield* input.onChunk(text); } }