diff --git a/plugins/opencode/src/command.ts b/plugins/opencode/src/command.ts index e9668fb..f6f1da3 100644 --- a/plugins/opencode/src/command.ts +++ b/plugins/opencode/src/command.ts @@ -71,7 +71,10 @@ export function rpc(executable: string, request: object, signal: AbortSignal): P // Hook output only reaches the host through these messages, and hook // failures arrive as structured errors from a zero exit. const describe = (message: string) => { - const detail = tail.toString().trim() + // Skip UTF-8 continuation bytes left by cutting the tail mid-character. + let start = 0 + while ((tail[start] & 0xc0) === 0x80) start++ + const detail = tail.subarray(start).toString().trim() return detail ? `${message}: ${detail}` : message } child.on("error", (error) => finish(() => reject(error))) diff --git a/plugins/opencode/test/command.test.ts b/plugins/opencode/test/command.test.ts index 67c3313..640ab68 100644 --- a/plugins/opencode/test/command.test.ts +++ b/plugins/opencode/test/command.test.ts @@ -39,7 +39,7 @@ const timer = setInterval(() => { await writeFile(join(temp, "runner.ts"), ` import { rpc } from ${JSON.stringify(fileURLToPath(new URL("../src/command.ts", import.meta.url)))} for (let index = 0; index < 3; index++) { - console.log(await rpc(${JSON.stringify(executable)}, {}, AbortSignal.timeout(1500))) + console.log(await rpc(${JSON.stringify(executable)}, {}, AbortSignal.timeout(3000))) } `) try { @@ -58,7 +58,7 @@ for (let index = 0; index < 3; index++) { } finally { await rm(temp, { recursive: true, force: true }) } -}) +}, 15_000) test("failed RPCs include a bounded stderr tail", async () => { const temp = await mkdtemp(join(tmpdir(), "opencode-rift-rpc-")) @@ -69,7 +69,7 @@ test("failed RPCs include a bounded stderr tail", async () => { ) await chmod(executable, 0o755) try { - await expect(rpc(executable, {}, AbortSignal.timeout(1500))).rejects.toThrow( + await expect(rpc(executable, {}, AbortSignal.timeout(3000))).rejects.toThrow( `Rift exited with status 1: ${"x".repeat(8 * 1024 - "hook failed\n".length)}hook failed`, ) } finally { @@ -96,7 +96,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify({ status: "error", error: f ) await chmod(executable, 0o755) try { - const error = await rpc(executable, {}, AbortSignal.timeout(1500)).catch((error: unknown) => error) + const error = await rpc(executable, {}, AbortSignal.timeout(3000)).catch((error: unknown) => error) expect(error).toBeInstanceOf(RpcError) expect({ ...(error as RpcError), message: (error as RpcError).message }).toEqual({ name: "RiftRpcError", @@ -154,3 +154,19 @@ test("signal deaths name the signal", async () => { await rm(temp, { recursive: true, force: true }) } }) + +test("stderr tails do not start inside a multi-byte character", async () => { + const temp = await mkdtemp(join(tmpdir(), "opencode-rift-rpc-")) + const executable = join(temp, "rift") + await writeFile( + executable, + '#!/usr/bin/env node\nprocess.stderr.write("é".repeat(5000) + "\\n", () => process.exit(1))\n', + ) + await chmod(executable, 0o755) + try { + const error = await rpc(executable, {}, AbortSignal.timeout(3000)).catch((error: Error) => error) + expect((error as Error).message).toBe(`Rift exited with status 1: ${"é".repeat(4095)}`) + } finally { + await rm(temp, { recursive: true, force: true }) + } +})