From 0a2261a1c8a91329aa946eeebbb7bb07dc760080 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:02:40 +0100 Subject: [PATCH 1/3] fix(provider-acp): preserve UTF-8 across interleaved terminal streams Co-authored-by: GPT 6.1 Sol --- .../src/server/clientTerminals.test.ts | 63 +++++++++++++++++-- .../src/server/clientTerminals.ts | 4 ++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/provider-acp/src/server/clientTerminals.test.ts b/packages/provider-acp/src/server/clientTerminals.test.ts index 791bbc22a6a03..719f64f095383 100644 --- a/packages/provider-acp/src/server/clientTerminals.test.ts +++ b/packages/provider-acp/src/server/clientTerminals.test.ts @@ -51,6 +51,57 @@ describe("AcpClientTerminals", () => { ), ); + it.effect("preserves UTF-8 when stderr interleaves a split stdout character", () => + Effect.gen(function* () { + const stdoutStarted = yield* Deferred.make(); + const stderrWritten = yield* Deferred.make(); + const child = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.make(new Uint8Array([0xe2])).pipe( + Stream.concat( + Stream.fromEffect(Deferred.succeed(stdoutStarted, undefined)).pipe(Stream.drain), + ), + Stream.concat(Stream.fromEffect(Deferred.await(stderrWritten)).pipe(Stream.drain)), + Stream.concat(Stream.make(new Uint8Array([0x82, 0xac]))), + ), + stderr: Stream.fromEffect(Deferred.await(stdoutStarted)).pipe( + Stream.drain, + Stream.concat(Stream.make(new Uint8Array([0x58]))), + Stream.concat( + Stream.fromEffect(Deferred.succeed(stderrWritten, undefined)).pipe(Stream.drain), + ), + ), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + const terminals = yield* makeAcpClientTerminals({ defaultCwd: process.cwd() }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(child)), + ), + ); + yield* Effect.addFinalizer(() => terminals.disposeAll); + const terminal = yield* terminals.create({ sessionId: "session", command: "fixture" }); + const request = { sessionId: "session", ...terminal }; + yield* terminals.waitForExit(request); + + const output = yield* terminals.output(request); + expect(output.output).toContain("€"); + expect(output.output).toContain("X"); + expect(output.output).not.toContain("\uFFFD"); + expect(output.truncated).toBe(false); + expect(terminals.readOutputSnapshot("session", terminal.terminalId)?.output).toBe( + output.output, + ); + }).pipe(Effect.scoped), + ); + it.live("resolves terminal environment from the requesting ACP session", () => Effect.gen(function* () { const terminals = yield* makeAcpClientTerminals({ @@ -96,14 +147,17 @@ describe("AcpClientTerminals", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - it.live("truncates buffered output from the beginning at the byte limit", () => + it.live.each([ + { text: "a".repeat(64) + "TAIL", limit: 16 }, + { text: "€".repeat(16) + "TAIL", limit: 6 }, + ])("truncates buffered output from the beginning at the $limit byte limit", ({ text, limit }) => withTerminals((terminals) => Effect.gen(function* () { const created = yield* terminals.create({ sessionId: "session", command: process.execPath, - args: ["-e", "process.stdout.write('a'.repeat(64) + 'TAIL');"], - outputByteLimit: 16, + args: ["-e", `process.stdout.write(${JSON.stringify(text)});`], + outputByteLimit: limit, }); yield* terminals.waitForExit({ sessionId: "session", terminalId: created.terminalId }); @@ -113,7 +167,8 @@ describe("AcpClientTerminals", () => { }); expect(output.truncated).toBe(true); expect(output.output.endsWith("TAIL")).toBe(true); - expect(output.output.length).toBeLessThanOrEqual(16); + expect(Buffer.byteLength(output.output)).toBeLessThanOrEqual(limit); + expect(output.output).not.toContain("\uFFFD"); }), ), ); diff --git a/packages/provider-acp/src/server/clientTerminals.ts b/packages/provider-acp/src/server/clientTerminals.ts index 2c3278d3c144d..a62698f496351 100644 --- a/packages/provider-acp/src/server/clientTerminals.ts +++ b/packages/provider-acp/src/server/clientTerminals.ts @@ -1,3 +1,4 @@ +import * as Channel from "effect/Channel"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -277,6 +278,9 @@ export const makeAcpClientTerminals = Effect.fn("makeAcpClientTerminals")(functi const pump = (stream: typeof child.stdout) => stream.pipe( + // Keep each pipe's UTF-8 decoder independent and preserve literal BOMs. + Stream.pipeThroughChannel(Channel.decodeText("utf-8", { ignoreBOM: true })), + Stream.encodeText, Stream.runForEach((chunk) => Effect.sync(() => { appendOutput(record.buffer, chunk); From 39b7c3d627c749a9aec2a6bce16f6c22d350bf60 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:06:18 +0100 Subject: [PATCH 2/3] test(provider-acp): tighten interleaved UTF-8 terminal test Co-authored-by: Claude Opus 5.5 --- .../src/server/clientTerminals.test.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/provider-acp/src/server/clientTerminals.test.ts b/packages/provider-acp/src/server/clientTerminals.test.ts index 719f64f095383..c18f103a2df9d 100644 --- a/packages/provider-acp/src/server/clientTerminals.test.ts +++ b/packages/provider-acp/src/server/clientTerminals.test.ts @@ -62,18 +62,13 @@ describe("AcpClientTerminals", () => { kill: () => Effect.void, stdin: Sink.drain, stdout: Stream.make(new Uint8Array([0xe2])).pipe( - Stream.concat( - Stream.fromEffect(Deferred.succeed(stdoutStarted, undefined)).pipe(Stream.drain), - ), - Stream.concat(Stream.fromEffect(Deferred.await(stderrWritten)).pipe(Stream.drain)), + Stream.concat(Stream.fromEffectDrain(Deferred.succeed(stdoutStarted, undefined))), + Stream.concat(Stream.fromEffectDrain(Deferred.await(stderrWritten))), Stream.concat(Stream.make(new Uint8Array([0x82, 0xac]))), ), - stderr: Stream.fromEffect(Deferred.await(stdoutStarted)).pipe( - Stream.drain, + stderr: Stream.fromEffectDrain(Deferred.await(stdoutStarted)).pipe( Stream.concat(Stream.make(new Uint8Array([0x58]))), - Stream.concat( - Stream.fromEffect(Deferred.succeed(stderrWritten, undefined)).pipe(Stream.drain), - ), + Stream.concat(Stream.fromEffectDrain(Deferred.succeed(stderrWritten, undefined))), ), all: Stream.empty, getInputFd: () => Sink.drain, @@ -92,9 +87,7 @@ describe("AcpClientTerminals", () => { yield* terminals.waitForExit(request); const output = yield* terminals.output(request); - expect(output.output).toContain("€"); - expect(output.output).toContain("X"); - expect(output.output).not.toContain("\uFFFD"); + expect(output.output).toBe("X€"); expect(output.truncated).toBe(false); expect(terminals.readOutputSnapshot("session", terminal.terminalId)?.output).toBe( output.output, From 7e6492c4a6dd46e9dd7a6493ebf98ed229181330 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:07:11 +0100 Subject: [PATCH 3/3] test(provider-acp): avoid requiring cross-stream output order Co-authored-by: GPT 6.1 Sol --- packages/provider-acp/src/server/clientTerminals.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/provider-acp/src/server/clientTerminals.test.ts b/packages/provider-acp/src/server/clientTerminals.test.ts index c18f103a2df9d..5955690dc6b70 100644 --- a/packages/provider-acp/src/server/clientTerminals.test.ts +++ b/packages/provider-acp/src/server/clientTerminals.test.ts @@ -87,7 +87,9 @@ describe("AcpClientTerminals", () => { yield* terminals.waitForExit(request); const output = yield* terminals.output(request); - expect(output.output).toBe("X€"); + expect(output.output).toContain("€"); + expect(output.output).toContain("X"); + expect(output.output).not.toContain("\uFFFD"); expect(output.truncated).toBe(false); expect(terminals.readOutputSnapshot("session", terminal.terminalId)?.output).toBe( output.output,