From 0b1b21a1f8994be6f7a56891105d831a221886b8 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 22 Jan 2026 11:35:16 -0500 Subject: [PATCH] refactor: extract shared Output utility for command streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both bash.ts (BashTool) and prompt.ts (shell command execution) had duplicated logic for accumulating command output that caused O(n²) memory usage from repeated string concatenation. Extract a shared Output utility that: - Accumulates output in memory up to a threshold (50KB) - Streams to a temp file when threshold is exceeded - Provides preview for UI display while streaming - Handles cleanup on abort This prep commit deduplicates the streaming logic so both code paths benefit from the fix and future improvements only need to be made once. Assisted-by: OpenCode (Claude claude-sonnet-4-20250514) --- packages/opencode/src/session/prompt.ts | 38 +++--- packages/opencode/src/tool/bash.ts | 28 ++-- packages/opencode/src/util/output.ts | 159 +++++++++++++++++++++++ packages/opencode/test/tool/bash.test.ts | 4 +- 4 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 packages/opencode/src/util/output.ts diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index de62788200b..74f6565e413 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,6 +45,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { Output } from "@/util/output" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -1485,29 +1486,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, }) - let output = "" + const state = Output.create() - proc.stdout?.on("data", (chunk) => { - output += chunk.toString() + const handleChunk = (chunk: Buffer) => { + Output.append(state, chunk) if (part.state.status === "running") { part.state.metadata = { - output: output, + output: Output.preview(state), description: "", } Session.updatePart(part) } - }) + } - proc.stderr?.on("data", (chunk) => { - output += chunk.toString() - if (part.state.status === "running") { - part.state.metadata = { - output: output, - description: "", - } - Session.updatePart(part) - } - }) + proc.stdout?.on("data", handleChunk) + proc.stderr?.on("data", handleChunk) let aborted = false let exited = false @@ -1517,11 +1510,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (abort.aborted) { aborted = true await kill() + Output.cleanup(state) } const abortHandler = () => { aborted = true void kill() + Output.cleanup(state) } abort.addEventListener("abort", abortHandler, { once: true }) @@ -1534,11 +1529,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) }) + Output.close(state) + if (aborted) { - output += "\n\n" + ["", "User aborted the command", ""].join("\n") + Output.appendMetadata(state, "\n\n" + ["", "User aborted the command", ""].join("\n")) } + msg.time.completed = Date.now() await Session.updateMessage(msg) + + const result = Output.finalize(state) + if (part.state.status === "running") { part.state = { status: "completed", @@ -1549,10 +1550,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the input: part.state.input, title: "", metadata: { - output, + output: result.preview, description: "", + ...(result.truncated && { outputPath: result.path }), }, - output, + output: result.output, } await Session.updatePart(part) } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index bf7c524941f..4b211e36c07 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -9,15 +9,14 @@ import { lazy } from "@/util/lazy" import { Language } from "web-tree-sitter" import { $ } from "bun" -import { Filesystem } from "@/util/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" +import { Output } from "@/util/output" -const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 export const log = Log.create({ service: "bash-tool" }) @@ -164,7 +163,7 @@ export const BashTool = Tool.define("bash", async () => { detached: process.platform !== "win32", }) - let output = "" + const state = Output.create() // Initialize metadata with empty output ctx.metadata({ @@ -175,11 +174,10 @@ export const BashTool = Tool.define("bash", async () => { }) const append = (chunk: Buffer) => { - output += chunk.toString() + Output.append(state, chunk) ctx.metadata({ metadata: { - // truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access) - output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, + output: Output.preview(state), description: params.description, }, }) @@ -197,11 +195,13 @@ export const BashTool = Tool.define("bash", async () => { if (ctx.abort.aborted) { aborted = true await kill() + Output.cleanup(state) } const abortHandler = () => { aborted = true void kill() + Output.cleanup(state) } ctx.abort.addEventListener("abort", abortHandler, { once: true }) @@ -240,18 +240,28 @@ export const BashTool = Tool.define("bash", async () => { resultMetadata.push("User aborted the command") } + Output.close(state) + if (resultMetadata.length > 0) { - output += "\n\n\n" + resultMetadata.join("\n") + "\n" + Output.appendMetadata(state, "\n\n\n" + resultMetadata.join("\n") + "\n") } + const result = Output.finalize(state, { + hint: state.file + ? `The command output was ${state.written} bytes and was truncated (inline limit: ${Output.THRESHOLD} bytes).\nFull output saved to: ${state.file.path}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + : undefined, + }) + return { title: params.description, metadata: { - output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, + output: result.preview, exit: proc.exitCode, description: params.description, + truncated: result.truncated, + outputPath: result.path, }, - output, + output: result.output, } }, } diff --git a/packages/opencode/src/util/output.ts b/packages/opencode/src/util/output.ts new file mode 100644 index 00000000000..b2e19670bb0 --- /dev/null +++ b/packages/opencode/src/util/output.ts @@ -0,0 +1,159 @@ +import fs from "fs" +import path from "path" +import { Identifier } from "../id/id" +import { Truncate } from "../tool/truncation" +import { Log } from "./log" + +const log = Log.create({ service: "output" }) + +/** + * Handles streaming command output with automatic file spillover when threshold is exceeded. + * Avoids O(n²) memory usage from repeated string concatenation by: + * 1. Accumulating in memory up to a threshold + * 2. Streaming to a temp file once threshold is exceeded + */ +export namespace Output { + export const THRESHOLD = Truncate.MAX_BYTES + export const MAX_PREVIEW = 30_000 + + export interface StreamFile { + fd: number + path: string + } + + export interface State { + /** In-memory buffer (only used when not streaming to file) */ + buffer: string + /** Total bytes received */ + bytes: number + /** Total lines received */ + lines: number + /** File handle when streaming to disk */ + file?: StreamFile + /** Bytes written to file */ + written: number + } + + export interface Result { + /** The output content (full if not truncated, preview + hint if truncated) */ + output: string + /** Preview for UI display (always fits in memory) */ + preview: string + /** Whether output was truncated/streamed to file */ + truncated: boolean + /** Path to file if output was streamed */ + path?: string + /** Total bytes of output */ + bytes: number + /** Total lines of output */ + lines: number + } + + export function create(): State { + return { + buffer: "", + bytes: 0, + lines: 0, + written: 0, + } + } + + function createFile(state: State): StreamFile | undefined { + let fd: number | undefined + try { + const dir = Truncate.DIR + fs.mkdirSync(dir, { recursive: true }) + Truncate.cleanup().catch(() => {}) + const filepath = path.join(dir, Identifier.ascending("tool")) + fd = fs.openSync(filepath, "w") + if (state.buffer) { + fs.writeSync(fd, state.buffer) + state.written += Buffer.byteLength(state.buffer, "utf-8") + } + state.buffer = "" + return { fd, path: filepath } + } catch (e) { + if (fd !== undefined) fs.closeSync(fd) + log.warn("failed to create stream file, continuing in memory", { error: e }) + return undefined + } + } + + export function append(state: State, chunk: Buffer | string): void { + const text = typeof chunk === "string" ? chunk : chunk.toString() + const size = typeof chunk === "string" ? Buffer.byteLength(chunk, "utf-8") : chunk.length + + state.bytes += size + state.lines += (text.match(/\n/g) || []).length + + if (!state.file && (state.bytes > THRESHOLD || state.lines > Truncate.MAX_LINES)) { + state.file = createFile(state) + } + + if (state.file) { + fs.writeSync(state.file.fd, text) + state.written += Buffer.byteLength(text, "utf-8") + } else { + state.buffer += text + } + } + + export function preview(state: State): string { + if (state.file) { + return `[streaming to file: ${state.written} bytes written...]\n` + } + if (state.buffer.length > MAX_PREVIEW) { + return state.buffer.slice(0, MAX_PREVIEW) + "\n\n..." + } + return state.buffer + } + + export function close(state: State): void { + if (state.file) { + fs.closeSync(state.file.fd) + } + } + + export function cleanup(state: State): void { + if (state.file) { + try { + fs.unlinkSync(state.file.path) + } catch {} + } + } + + export function appendMetadata(state: State, metadata: string): void { + if (state.file) { + fs.appendFileSync(state.file.path, metadata) + } else { + state.buffer += metadata + } + } + + export function finalize(state: State, options?: { hint?: string }): Result { + const truncated = !!state.file + const filepath = state.file?.path + + if (truncated && filepath) { + const hint = + options?.hint ?? + `The command output was ${state.written} bytes and was truncated (inline limit: ${THRESHOLD} bytes).\nFull output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + return { + output: hint, + preview: `[output streamed to file: ${state.written} bytes]`, + truncated: true, + path: filepath, + bytes: state.bytes, + lines: state.lines, + } + } + + return { + output: state.buffer, + preview: state.buffer.length > MAX_PREVIEW ? state.buffer.slice(0, MAX_PREVIEW) + "\n\n..." : state.buffer, + truncated: false, + bytes: state.bytes, + lines: state.lines, + } + } +} diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 750ff8193e9..6c4f3ef4abc 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -248,7 +248,7 @@ describe("tool.bash truncation", () => { ) expect((result.metadata as any).truncated).toBe(true) expect(result.output).toContain("truncated") - expect(result.output).toContain("The tool call succeeded but the output was truncated") + expect(result.output).toContain("Full output saved to:") }, }) }) @@ -268,7 +268,7 @@ describe("tool.bash truncation", () => { ) expect((result.metadata as any).truncated).toBe(true) expect(result.output).toContain("truncated") - expect(result.output).toContain("The tool call succeeded but the output was truncated") + expect(result.output).toContain("Full output saved to:") }, }) })