From 2092350cfa1059699f4d861c950086dee5f63708 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:18:16 -0500 Subject: [PATCH] fix(core): align shell output limits (#41007) --- packages/core/src/tool/plugin/shell.ts | 18 +++++++++---- packages/core/test/tool-shell.test.ts | 35 ++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index d027c5a45f6..85e342aafba 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -6,6 +6,7 @@ import type { Content } from "@opencode-ai/schema/tool" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import { Deferred, Effect, Schema, Scope } from "effect" import { FSUtil } from "@opencode-ai/util/fs-util" +import { Config } from "../../config" import { LocationMutation } from "../../location-mutation" import { Permission } from "../../permission" import { PluginRuntime } from "../../plugin/runtime" @@ -13,10 +14,10 @@ import { NonNegativeInt } from "../../schema" import { SessionSchema } from "../../session/schema" import { Shell } from "../../shell" import { ShellParse } from "../../shell/parse" +import { ToolOutput } from "../../tool-output" export const name = "shell" export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 -export const MAX_CAPTURE_BYTES = 1024 * 1024 const BACKGROUND_STARTED = "The command was moved to the background." const BACKGROUND_INSTRUCTION = @@ -86,6 +87,7 @@ export const Plugin = { const mutation = yield* LocationMutation.Service const shell = yield* Shell.Service const permission = yield* Permission.Service + const config = yield* Config.Service const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* ( sessionID: SessionSchema.ID, @@ -191,15 +193,21 @@ export const Plugin = { yield* context.progress({ shellID: info.id }) const captureShell = Effect.fn("ShellTool.captureShell")(function* () { + const configured = Config.latest(yield* config.entries(), "tool_output") + const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES + const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER }) - const truncated = latest.size > MAX_CAPTURE_BYTES const page = yield* shell.output(info.id, { - cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES), - limit: MAX_CAPTURE_BYTES, + cursor: Math.max(0, latest.size - maxBytes), + limit: maxBytes, }) + const lines = page.output.split("\n") + if (page.output.endsWith("\n")) lines.pop() + const truncated = latest.size > maxBytes || lines.length > maxLines + const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : "" return { - output: `${page.output || "(no output)"}${notice}`, + output: `${output || "(no output)"}${notice}`, truncated, } }) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index dfee4e12448..ccc628ad214 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -30,6 +30,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { Shell } from "@opencode-ai/core/shell" import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { ShellTool } from "@opencode-ai/core/tool/plugin/shell" +import { ToolOutput } from "@opencode-ai/core/tool-output" import { Tool } from "@opencode-ai/core/tool" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -171,6 +172,9 @@ const overflowCommand = (bytes: number) => isWindows ? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100` : `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end` +const lineOverflowCommand = isWindows + ? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')" + : "printf 'one\\ntwo\\nthree'" const progressOverflowCommand = (bytes: number, release: string) => isWindows ? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }` @@ -477,7 +481,7 @@ describe("ShellTool", () => { Effect.promise(() => tmpdir()), (tmp) => { reset() - const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 + const bytes = ToolOutput.MAX_BYTES + 1024 return withSession(tmp.path, (registry) => executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")), ).pipe( @@ -501,6 +505,33 @@ describe("ShellTool", () => { { timeout: 15_000 }, ) + it.live("uses configured line limits", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 1_000 } }), + ), + ) + const settled = yield* withSession(tmp.path, (registry) => + executeTool(registry, call({ command: lineOverflowCommand }, "call-line-overflow")), + ) + expect(settled.metadata).toMatchObject({ exit: 0, truncated: true }) + const content = settled.content?.[0] + if (!content || content.type !== "text") throw new Error("Expected text content") + expect(content.text).not.toContain("one") + expect(content.text).toStartWith("two\nthree") + expect(content.text).toContain("output truncated; full output saved to:") + }) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) + it.live( "reports the shell ID for a running command", () => @@ -515,7 +546,7 @@ describe("ShellTool", () => { const observed = yield* Deferred.make() yield* executeTool(registry, { ...call( - { command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) }, + { command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) }, "call-progress", ), progress: (update) =>