From bb21535a3b2c0441dae35bfb681f51d1d78160db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 26 May 2026 09:36:40 +0800 Subject: [PATCH 1/2] fix(shell): truncate metadata preview by bytes --- packages/opencode/src/tool/shell.ts | 18 ++++++----- packages/opencode/test/tool/shell.test.ts | 37 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index b6a95b5c0970..5bba4e852dfb 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -221,8 +221,16 @@ function pathArgs(list: Part[], ps: boolean, cmd = false) { } function preview(text: string) { - if (text.length <= MAX_METADATA_LENGTH) return text - return "...\n\n" + text.slice(-MAX_METADATA_LENGTH) + if (Buffer.byteLength(text, "utf-8") <= MAX_METADATA_LENGTH) return text + return "...\n\n" + tailBytes(text, MAX_METADATA_LENGTH) +} + +function tailBytes(text: string, maxBytes: number) { + const buf = Buffer.from(text, "utf-8") + if (buf.length <= maxBytes) return text + let start = buf.length - maxBytes + while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++ + return buf.subarray(start).toString("utf-8") } function tail(text: string, maxLines: number, maxBytes: number) { @@ -240,11 +248,7 @@ function tail(text: string, maxLines: number, maxBytes: number) { const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) if (bytes + size > maxBytes) { if (out.length === 0) { - const buf = Buffer.from(lines[i], "utf-8") - let start = buf.length - maxBytes - if (start < 0) start = 0 - while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++ - out.unshift(buf.subarray(start).toString("utf-8")) + out.unshift(tailBytes(lines[i], maxBytes)) } break } diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index ddaa5c2ec7b1..32573fa1aaee 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1205,6 +1205,43 @@ describe("tool.shell truncation", () => { ), ) + it.live("truncates metadata output by utf8 byte length", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `${bin} -e ${evalarg("process.stdout.write(String.fromCodePoint(0x4e00).repeat(10001))")}`, + description: "Generate CJK output exceeding metadata byte limit", + }) + const output = result.metadata.output + if (typeof output !== "string") throw new Error("expected metadata output") + + expect(output.startsWith("...\n\n")).toBe(true) + expect(Buffer.byteLength(output.slice("...\n\n".length), "utf-8")).toBeLessThanOrEqual(30_000) + }), + ), + ) + + it.live("does not split surrogate pairs when truncating metadata output", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `${bin} -e ${evalarg( + "process.stdout.write(String.fromCharCode(97)+String.fromCodePoint(0x1f642).repeat(15000)+String.fromCharCode(98))", + )}`, + description: "Generate emoji output exceeding metadata byte limit", + }) + const output = result.metadata.output + if (typeof output !== "string") throw new Error("expected metadata output") + + const first = output.charCodeAt("...\n\n".length) + expect(output.startsWith("...\n\n")).toBe(true) + expect(first < 0xdc00 || first > 0xdfff).toBe(true) + }), + ), + ) + it.live("full output is saved to file when truncated", () => runIn( projectRoot, From c631021ec2ddc26987956b627d06249c349b43c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 26 May 2026 09:36:40 +0800 Subject: [PATCH 2/2] test(shell): stabilize metadata preview tests --- packages/opencode/src/tool/shell.ts | 6 +-- packages/opencode/test/tool/shell.test.ts | 50 +++++++---------------- 2 files changed, 18 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 5bba4e852dfb..63aa2dfb3d29 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -220,7 +220,7 @@ function pathArgs(list: Part[], ps: boolean, cmd = false) { return out } -function preview(text: string) { +export function previewMetadata(text: string) { if (Buffer.byteLength(text, "utf-8") <= MAX_METADATA_LENGTH) return text return "...\n\n" + tailBytes(text, MAX_METADATA_LENGTH) } @@ -497,7 +497,7 @@ export const ShellTool = Tool.define( cut = true } - last = preview(last + chunk) + last = previewMetadata(last + chunk) if (file) { sink?.write(chunk) @@ -589,7 +589,7 @@ export const ShellTool = Tool.define( return { title: input.description, metadata: { - output: last || preview(output), + output: last || previewMetadata(output), exit: code, description: input.description, truncated: cut, diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 32573fa1aaee..0ab4d13ff134 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1,11 +1,11 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { Cause, Effect, Exit, Layer } from "effect" import type * as Scope from "effect/Scope" import os from "os" import path from "path" import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" -import { ShellTool } from "../../src/tool/shell" +import { ShellTool, previewMetadata } from "../../src/tool/shell" import { Filesystem } from "@/util/filesystem" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import type { Permission } from "../../src/permission" @@ -1205,42 +1205,22 @@ describe("tool.shell truncation", () => { ), ) - it.live("truncates metadata output by utf8 byte length", () => - runIn( - projectRoot, - Effect.gen(function* () { - const result = yield* run({ - command: `${bin} -e ${evalarg("process.stdout.write(String.fromCodePoint(0x4e00).repeat(10001))")}`, - description: "Generate CJK output exceeding metadata byte limit", - }) - const output = result.metadata.output - if (typeof output !== "string") throw new Error("expected metadata output") + test("truncates metadata output by utf8 byte length", () => { + const output = previewMetadata(String.fromCodePoint(0x4e00).repeat(10001)) - expect(output.startsWith("...\n\n")).toBe(true) - expect(Buffer.byteLength(output.slice("...\n\n".length), "utf-8")).toBeLessThanOrEqual(30_000) - }), - ), - ) + expect(output.startsWith("...\n\n")).toBe(true) + expect(Buffer.byteLength(output.slice("...\n\n".length), "utf-8")).toBeLessThanOrEqual(30_000) + }) - it.live("does not split surrogate pairs when truncating metadata output", () => - runIn( - projectRoot, - Effect.gen(function* () { - const result = yield* run({ - command: `${bin} -e ${evalarg( - "process.stdout.write(String.fromCharCode(97)+String.fromCodePoint(0x1f642).repeat(15000)+String.fromCharCode(98))", - )}`, - description: "Generate emoji output exceeding metadata byte limit", - }) - const output = result.metadata.output - if (typeof output !== "string") throw new Error("expected metadata output") + test("does not split surrogate pairs when truncating metadata output", () => { + const output = previewMetadata( + String.fromCharCode(97) + String.fromCodePoint(0x1f642).repeat(15000) + String.fromCharCode(98), + ) + const first = output.charCodeAt("...\n\n".length) - const first = output.charCodeAt("...\n\n".length) - expect(output.startsWith("...\n\n")).toBe(true) - expect(first < 0xdc00 || first > 0xdfff).toBe(true) - }), - ), - ) + expect(output.startsWith("...\n\n")).toBe(true) + expect(first < 0xdc00 || first > 0xdfff).toBe(true) + }) it.live("full output is saved to file when truncated", () => runIn(