Compare commits

...
4 changed files with 89 additions and 28 deletions
+4 -6
View File
@@ -48,6 +48,7 @@ import { Skill } from "./skill.js"
import { Job } from "./job.js"
import { Command } from "./command.js"
import { Shell } from "./shell.js"
import { ShellOutput } from "./shell/output.js"
import { Global } from "@opencode-ai/util/global"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex.js"
@@ -694,9 +695,9 @@ const layer = Layer.effect(
),
)
const output = terminal.retained
? yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
? yield* ShellOutput.preview(started, shell).pipe(
Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())),
)
: missingShellOutput()
return { shell: terminal.info, output }
}).pipe(Effect.provide(locations.get(session.location)))
@@ -1120,9 +1121,6 @@ function positiveInt(value: string | null) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
// Mirrors the shell tool's in-memory preview safety limit.
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
export const node = makeGlobalNode({
service: Service,
layer: layer.pipe(Layer.orDie),
+25
View File
@@ -0,0 +1,25 @@
export * as ShellOutput from "./output.js"
import { Effect } from "effect"
import type { Info } from "@opencode-ai/schema/shell"
import { Config } from "../config.js"
import { Shell } from "../shell.js"
import { ToolOutput } from "../tool-output.js"
export const preview = Effect.fn("ShellOutput.preview")(function* (info: Info, shell: Shell.Interface) {
const config = yield* Config.Service
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 page = yield* shell.output(info.id, {
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 { ...page, output: `${output || "(no output)"}${notice}`, truncated }
})
+4 -22
View File
@@ -12,9 +12,9 @@ import { PluginRuntime } from "../../plugin/runtime.js"
import { NonNegativeInt } from "../../schema.js"
import { SessionSchema } from "../../session/schema.js"
import { Shell } from "../../shell.js"
import { ShellOutput } from "../../shell/output.js"
import { ShellParse } from "../../shell/parse.js"
import { ShellSelect } from "../../shell/select.js"
import { ToolOutput } from "../../tool-output.js"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@@ -239,29 +239,11 @@ export const Plugin = {
)
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fnUntraced(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 page = yield* shell.output(info.id, {
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: `${output || "(no output)"}${notice}`,
truncated,
}
})
const settleShell = Effect.fnUntraced(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
const capture = yield* ShellOutput.preview(info, shell).pipe(
Effect.provideService(Config.Service, config),
)
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
+56
View File
@@ -30,6 +30,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
@@ -888,6 +889,61 @@ describe("Session.create", () => {
),
)
it.live("truncates direct shell output using the shared shell preview policy", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
const bytes = ToolOutput.MAX_BYTES + 1024
const command =
process.platform === "win32"
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end')`
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
yield* session.shell({ sessionID: created.id, command })
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell?.output?.truncated).toBe(true)
expect(shell?.output?.output).not.toContain("output-start")
expect(shell?.output?.output).toContain("output-end")
expect(shell?.output?.output).toContain("output truncated; full output saved to:")
}),
),
)
it.live("applies configured output limits to direct shell commands", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(directory, "opencode.json"),
JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 1_000 } }),
),
)
const session = yield* Session.Service
const created = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
const command =
process.platform === "win32"
? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')"
: "printf 'one\\ntwo\\nthree'"
yield* session.shell({ sessionID: created.id, command })
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell?.output?.truncated).toBe(true)
expect(shell?.output?.output).not.toContain("one")
expect(shell?.output?.output.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
expect(shell?.output?.output).toContain("output truncated; full output saved to:")
}),
),
)
it.live("still emits shell ended for a failing command", () =>
withTmp((directory) =>
Effect.gen(function* () {