diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index b6a95b5c0970..63aa2dfb3d29 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -220,9 +220,17 @@ function pathArgs(list: Part[], ps: boolean, cmd = false) { return out } -function preview(text: string) { - if (text.length <= MAX_METADATA_LENGTH) return text - return "...\n\n" + text.slice(-MAX_METADATA_LENGTH) +export function previewMetadata(text: string) { + 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 } @@ -493,7 +497,7 @@ export const ShellTool = Tool.define( cut = true } - last = preview(last + chunk) + last = previewMetadata(last + chunk) if (file) { sink?.write(chunk) @@ -585,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 ddaa5c2ec7b1..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,6 +1205,23 @@ describe("tool.shell truncation", () => { ), ) + 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) + }) + + 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) + + 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,