diff --git a/packages/provider-acp/src/server/clientTerminals.test.ts b/packages/provider-acp/src/server/clientTerminals.test.ts index 791bbc22a6a03..5955690dc6b70 100644 --- a/packages/provider-acp/src/server/clientTerminals.test.ts +++ b/packages/provider-acp/src/server/clientTerminals.test.ts @@ -51,6 +51,52 @@ 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.fromEffectDrain(Deferred.succeed(stdoutStarted, undefined))), + Stream.concat(Stream.fromEffectDrain(Deferred.await(stderrWritten))), + Stream.concat(Stream.make(new Uint8Array([0x82, 0xac]))), + ), + stderr: Stream.fromEffectDrain(Deferred.await(stdoutStarted)).pipe( + Stream.concat(Stream.make(new Uint8Array([0x58]))), + Stream.concat(Stream.fromEffectDrain(Deferred.succeed(stderrWritten, undefined))), + ), + 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 +142,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 +162,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);