diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 854afc00b62..5d558ea14ca 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -4,6 +4,7 @@ import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
+import { Truncate } from "../tool/truncation"
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
@@ -46,7 +47,10 @@ export namespace Agent {
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
- external_directory: "ask",
+ external_directory: {
+ "*": "ask",
+ [Truncate.DIR]: "allow",
+ },
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
@@ -110,6 +114,9 @@ export namespace Agent {
websearch: "allow",
codesearch: "allow",
read: "allow",
+ external_directory: {
+ [Truncate.DIR]: "allow",
+ },
}),
user,
),
diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts
index ad6e22e1bee..7c81c5ed62d 100644
--- a/packages/opencode/src/id/id.ts
+++ b/packages/opencode/src/id/id.ts
@@ -9,6 +9,7 @@ export namespace Identifier {
user: "usr",
part: "prt",
pty: "pty",
+ tool: "tool",
} as const
export function schema(prefix: keyof typeof prefixes) {
@@ -70,4 +71,12 @@ export namespace Identifier {
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
+
+ /** Extract timestamp from an ascending ID. Does not work with descending IDs. */
+ export function timestamp(id: string): number {
+ const prefix = id.split("_")[0]
+ const hex = id.slice(prefix.length + 1, prefix.length + 13)
+ const encoded = BigInt("0x" + hex)
+ return Number(encoded / BigInt(0x1000))
+ }
}
diff --git a/packages/opencode/src/session/truncation.ts b/packages/opencode/src/session/truncation.ts
deleted file mode 100644
index 15177a55a65..00000000000
--- a/packages/opencode/src/session/truncation.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-export namespace Truncate {
- export const MAX_LINES = 2000
- export const MAX_BYTES = 50 * 1024
-
- export interface Result {
- content: string
- truncated: boolean
- }
-
- export interface Options {
- maxLines?: number
- maxBytes?: number
- direction?: "head" | "tail"
- }
-
- export function output(text: string, options: Options = {}): Result {
- const maxLines = options.maxLines ?? MAX_LINES
- const maxBytes = options.maxBytes ?? MAX_BYTES
- const direction = options.direction ?? "head"
- const lines = text.split("\n")
- const totalBytes = Buffer.byteLength(text, "utf-8")
-
- if (lines.length <= maxLines && totalBytes <= maxBytes) {
- return { content: text, truncated: false }
- }
-
- const out: string[] = []
- var i = 0
- var bytes = 0
- var hitBytes = false
-
- if (direction === "head") {
- for (i = 0; i < lines.length && i < maxLines; i++) {
- const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
- if (bytes + size > maxBytes) {
- hitBytes = true
- break
- }
- out.push(lines[i])
- bytes += size
- }
- const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
- const unit = hitBytes ? "chars" : "lines"
- return { content: `${out.join("\n")}\n\n...${removed} ${unit} truncated...`, truncated: true }
- }
-
- for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
- const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
- if (bytes + size > maxBytes) {
- hitBytes = true
- break
- }
- out.unshift(lines[i])
- bytes += size
- }
- const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
- const unit = hitBytes ? "chars" : "lines"
- return { content: `...${removed} ${unit} truncated...\n\n${out.join("\n")}`, truncated: true }
- }
-}
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index b9e0f8a1c3e..e06a3f157cb 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -15,8 +15,9 @@ import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
+import { Truncate } from "./truncation"
-const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
+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" })
@@ -55,7 +56,9 @@ export const BashTool = Tool.define("bash", async () => {
log.info("bash tool using shell", { shell })
return {
- description: DESCRIPTION.replaceAll("${directory}", Instance.directory),
+ description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
+ .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
+ .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
@@ -172,15 +175,14 @@ export const BashTool = Tool.define("bash", async () => {
})
const append = (chunk: Buffer) => {
- if (output.length <= MAX_OUTPUT_LENGTH) {
- output += chunk.toString()
- ctx.metadata({
- metadata: {
- output,
- description: params.description,
- },
- })
- }
+ output += chunk.toString()
+ 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,
+ description: params.description,
+ },
+ })
}
proc.stdout?.on("data", append)
@@ -228,12 +230,7 @@ export const BashTool = Tool.define("bash", async () => {
})
})
- let resultMetadata: String[] = [""]
-
- if (output.length > MAX_OUTPUT_LENGTH) {
- output = output.slice(0, MAX_OUTPUT_LENGTH)
- resultMetadata.push(`bash tool truncated output as it exceeded ${MAX_OUTPUT_LENGTH} char limit`)
- }
+ const resultMetadata: string[] = []
if (timedOut) {
resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
@@ -243,15 +240,14 @@ export const BashTool = Tool.define("bash", async () => {
resultMetadata.push("User aborted the command")
}
- if (resultMetadata.length > 1) {
- resultMetadata.push("")
- output += "\n\n" + resultMetadata.join("\n")
+ if (resultMetadata.length > 0) {
+ output += "\n\n\n" + resultMetadata.join("\n") + "\n"
}
return {
title: params.description,
metadata: {
- output,
+ output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
exit: proc.exitCode,
description: params.description,
},
diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt
index c31263c04eb..9fbc9fcf37e 100644
--- a/packages/opencode/src/tool/bash.txt
+++ b/packages/opencode/src/tool/bash.txt
@@ -22,10 +22,9 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will time out after 120000ms (2 minutes).
+ - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- - If the output exceeds 30000 characters, output will be truncated before being returned to you.
- - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
+ - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.
- Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use Glob (NOT find or ls)
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index a0f50129e50..56742517483 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -11,6 +11,7 @@ import { Identifier } from "../id/id"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
+const MAX_BYTES = 50 * 1024
export const ReadTool = Tool.define("read", {
description: DESCRIPTION,
@@ -77,6 +78,7 @@ export const ReadTool = Tool.define("read", {
output: msg,
metadata: {
preview: msg,
+ truncated: false,
},
attachments: [
{
@@ -97,9 +99,21 @@ export const ReadTool = Tool.define("read", {
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset || 0
const lines = await file.text().then((text) => text.split("\n"))
- const raw = lines.slice(offset, offset + limit).map((line) => {
- return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
- })
+
+ const raw: string[] = []
+ let bytes = 0
+ let truncatedByBytes = false
+ for (let i = offset; i < Math.min(lines.length, offset + limit); i++) {
+ const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i]
+ const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
+ if (bytes + size > MAX_BYTES) {
+ truncatedByBytes = true
+ break
+ }
+ raw.push(line)
+ bytes += size
+ }
+
const content = raw.map((line, index) => {
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
})
@@ -109,10 +123,13 @@ export const ReadTool = Tool.define("read", {
output += content.join("\n")
const totalLines = lines.length
- const lastReadLine = offset + content.length
+ const lastReadLine = offset + raw.length
const hasMoreLines = totalLines > lastReadLine
+ const truncated = hasMoreLines || truncatedByBytes
- if (hasMoreLines) {
+ if (truncatedByBytes) {
+ output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})`
+ } else if (hasMoreLines) {
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`
} else {
output += `\n\n(End of file - total ${totalLines} lines)`
@@ -128,6 +145,7 @@ export const ReadTool = Tool.define("read", {
output,
metadata: {
preview,
+ truncated,
},
}
},
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index bca6626db70..608edc65eb4 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -23,7 +23,7 @@ import { CodeSearchTool } from "./codesearch"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { LspTool } from "./lsp"
-import { Truncate } from "../session/truncation"
+import { Truncate } from "./truncation"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -60,16 +60,16 @@ export namespace ToolRegistry {
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
- init: async () => ({
+ init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, ctx) => {
const result = await def.execute(args as any, ctx)
- const out = Truncate.output(result)
+ const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
- metadata: { truncated: out.truncated },
+ metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}),
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index 060da0ae763..78ab325af41 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/packages/opencode/src/tool/tool.ts
@@ -2,7 +2,7 @@ import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { PermissionNext } from "../permission/next"
-import { Truncate } from "../session/truncation"
+import { Truncate } from "./truncation"
export namespace Tool {
interface Metadata {
@@ -50,8 +50,8 @@ export namespace Tool {
): Info {
return {
id,
- init: async (ctx) => {
- const toolInfo = init instanceof Function ? await init(ctx) : init
+ init: async (initCtx) => {
+ const toolInfo = init instanceof Function ? await init(initCtx) : init
const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => {
try {
@@ -66,13 +66,18 @@ export namespace Tool {
)
}
const result = await execute(args, ctx)
- const truncated = Truncate.output(result.output)
+ // skip truncation for tools that handle it themselves
+ if (result.metadata.truncated !== undefined) {
+ return result
+ }
+ const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
return {
...result,
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
+ ...(truncated.truncated && { outputPath: truncated.outputPath }),
},
}
}
diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts
new file mode 100644
index 00000000000..133c57d3d1f
--- /dev/null
+++ b/packages/opencode/src/tool/truncation.ts
@@ -0,0 +1,98 @@
+import fs from "fs/promises"
+import path from "path"
+import { Global } from "../global"
+import { Identifier } from "../id/id"
+import { lazy } from "../util/lazy"
+import { PermissionNext } from "../permission/next"
+import type { Agent } from "../agent/agent"
+
+export namespace Truncate {
+ export const MAX_LINES = 2000
+ export const MAX_BYTES = 50 * 1024
+ export const DIR = path.join(Global.Path.data, "tool-output")
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
+
+ export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
+
+ export interface Options {
+ maxLines?: number
+ maxBytes?: number
+ direction?: "head" | "tail"
+ }
+
+ export async function cleanup() {
+ const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
+ const glob = new Bun.Glob("tool_*")
+ const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[])
+ for (const entry of entries) {
+ if (Identifier.timestamp(entry) >= cutoff) continue
+ await fs.unlink(path.join(DIR, entry)).catch(() => {})
+ }
+ }
+
+ const init = lazy(cleanup)
+
+ function hasTaskTool(agent?: Agent.Info): boolean {
+ if (!agent?.permission) return false
+ const rule = PermissionNext.evaluate("task", "*", agent.permission)
+ return rule.action !== "deny"
+ }
+
+ export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise {
+ const maxLines = options.maxLines ?? MAX_LINES
+ const maxBytes = options.maxBytes ?? MAX_BYTES
+ const direction = options.direction ?? "head"
+ const lines = text.split("\n")
+ const totalBytes = Buffer.byteLength(text, "utf-8")
+
+ if (lines.length <= maxLines && totalBytes <= maxBytes) {
+ return { content: text, truncated: false }
+ }
+
+ const out: string[] = []
+ let i = 0
+ let bytes = 0
+ let hitBytes = false
+
+ if (direction === "head") {
+ for (i = 0; i < lines.length && i < maxLines; i++) {
+ const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
+ if (bytes + size > maxBytes) {
+ hitBytes = true
+ break
+ }
+ out.push(lines[i])
+ bytes += size
+ }
+ } else {
+ for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
+ const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
+ if (bytes + size > maxBytes) {
+ hitBytes = true
+ break
+ }
+ out.unshift(lines[i])
+ bytes += size
+ }
+ }
+
+ const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
+ const unit = hitBytes ? "bytes" : "lines"
+ const preview = out.join("\n")
+
+ await init()
+ const id = Identifier.ascending("tool")
+ const filepath = path.join(DIR, id)
+ await Bun.write(Bun.file(filepath), text)
+
+ const hint = hasTaskTool(agent)
+ ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have a subagent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
+ : `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
+ const message =
+ direction === "head"
+ ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
+ : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
+
+ return { content: message, truncated: true, outputPath: filepath }
+ }
+}
diff --git a/packages/opencode/test/session/truncation.test.ts b/packages/opencode/test/session/truncation.test.ts
deleted file mode 100644
index a3891ed450d..00000000000
--- a/packages/opencode/test/session/truncation.test.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { describe, test, expect } from "bun:test"
-import { Truncate } from "../../src/session/truncation"
-import path from "path"
-
-const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
-
-describe("Truncate", () => {
- describe("output", () => {
- test("truncates large json file by bytes", async () => {
- const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
- const result = Truncate.output(content)
-
- expect(result.truncated).toBe(true)
- expect(Buffer.byteLength(result.content, "utf-8")).toBeLessThanOrEqual(Truncate.MAX_BYTES + 100)
- expect(result.content).toContain("truncated...")
- })
-
- test("returns content unchanged when under limits", () => {
- const content = "line1\nline2\nline3"
- const result = Truncate.output(content)
-
- expect(result.truncated).toBe(false)
- expect(result.content).toBe(content)
- })
-
- test("truncates by line count", () => {
- const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
- const result = Truncate.output(lines, { maxLines: 10 })
-
- expect(result.truncated).toBe(true)
- expect(result.content.split("\n").length).toBeLessThanOrEqual(12)
- expect(result.content).toContain("...90 lines truncated...")
- })
-
- test("truncates by byte count", () => {
- const content = "a".repeat(1000)
- const result = Truncate.output(content, { maxBytes: 100 })
-
- expect(result.truncated).toBe(true)
- expect(result.content).toContain("truncated...")
- })
-
- test("truncates from head by default", () => {
- const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
- const result = Truncate.output(lines, { maxLines: 3 })
-
- expect(result.truncated).toBe(true)
- expect(result.content).toContain("line0")
- expect(result.content).toContain("line1")
- expect(result.content).toContain("line2")
- expect(result.content).not.toContain("line9")
- })
-
- test("truncates from tail when direction is tail", () => {
- const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
- const result = Truncate.output(lines, { maxLines: 3, direction: "tail" })
-
- expect(result.truncated).toBe(true)
- expect(result.content).toContain("line7")
- expect(result.content).toContain("line8")
- expect(result.content).toContain("line9")
- expect(result.content).not.toContain("line0")
- })
-
- test("uses default MAX_LINES and MAX_BYTES", () => {
- expect(Truncate.MAX_LINES).toBe(2000)
- expect(Truncate.MAX_BYTES).toBe(50 * 1024)
- })
-
- test("large single-line file truncates with byte message", async () => {
- const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
- const result = Truncate.output(content)
-
- expect(result.truncated).toBe(true)
- expect(result.content).toContain("chars truncated...")
- expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
- })
- })
-})
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index 2eb17a9fc94..750ff8193e9 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/packages/opencode/test/tool/bash.test.ts
@@ -4,6 +4,7 @@ import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import type { PermissionNext } from "../../src/permission/next"
+import { Truncate } from "../../src/tool/truncation"
const ctx = {
sessionID: "test",
@@ -230,3 +231,90 @@ describe("tool.bash permissions", () => {
})
})
})
+
+describe("tool.bash truncation", () => {
+ test("truncates output exceeding line limit", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const bash = await BashTool.init()
+ const lineCount = Truncate.MAX_LINES + 500
+ const result = await bash.execute(
+ {
+ command: `seq 1 ${lineCount}`,
+ description: "Generate lines exceeding limit",
+ },
+ ctx,
+ )
+ 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")
+ },
+ })
+ })
+
+ test("truncates output exceeding byte limit", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const bash = await BashTool.init()
+ const byteCount = Truncate.MAX_BYTES + 10000
+ const result = await bash.execute(
+ {
+ command: `head -c ${byteCount} /dev/zero | tr '\\0' 'a'`,
+ description: "Generate bytes exceeding limit",
+ },
+ ctx,
+ )
+ 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")
+ },
+ })
+ })
+
+ test("does not truncate small output", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const bash = await BashTool.init()
+ const result = await bash.execute(
+ {
+ command: "echo hello",
+ description: "Echo hello",
+ },
+ ctx,
+ )
+ expect((result.metadata as any).truncated).toBe(false)
+ expect(result.output).toBe("hello\n")
+ },
+ })
+ })
+
+ test("full output is saved to file when truncated", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const bash = await BashTool.init()
+ const lineCount = Truncate.MAX_LINES + 100
+ const result = await bash.execute(
+ {
+ command: `seq 1 ${lineCount}`,
+ description: "Generate lines for file check",
+ },
+ ctx,
+ )
+ expect((result.metadata as any).truncated).toBe(true)
+
+ const filepath = (result.metadata as any).outputPath
+ expect(filepath).toBeTruthy()
+
+ const saved = await Bun.file(filepath).text()
+ const lines = saved.trim().split("\n")
+ expect(lines.length).toBe(lineCount)
+ expect(lines[0]).toBe("1")
+ expect(lines[lineCount - 1]).toBe(String(lineCount))
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/session/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json
similarity index 100%
rename from packages/opencode/test/session/fixtures/models-api.json
rename to packages/opencode/test/tool/fixtures/models-api.json
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index 826fa03f6ca..a88d25f73ab 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -6,6 +6,8 @@ import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission/next"
import { Agent } from "../../src/agent/agent"
+const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
+
const ctx = {
sessionID: "test",
messageID: "",
@@ -165,3 +167,123 @@ describe("tool.read env file blocking", () => {
})
})
})
+
+describe("tool.read truncation", () => {
+ test("truncates large file by bytes and sets truncated metadata", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
+ await Bun.write(path.join(dir, "large.json"), content)
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
+ expect(result.metadata.truncated).toBe(true)
+ expect(result.output).toContain("Output truncated at")
+ expect(result.output).toContain("bytes")
+ },
+ })
+ })
+
+ test("truncates by line count when limit is specified", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
+ await Bun.write(path.join(dir, "many-lines.txt"), lines)
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
+ expect(result.metadata.truncated).toBe(true)
+ expect(result.output).toContain("File has more lines")
+ expect(result.output).toContain("line0")
+ expect(result.output).toContain("line9")
+ expect(result.output).not.toContain("line10")
+ },
+ })
+ })
+
+ test("does not truncate small file", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "small.txt"), "hello world")
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ const result = await read.execute({ filePath: path.join(tmp.path, "small.txt") }, ctx)
+ expect(result.metadata.truncated).toBe(false)
+ expect(result.output).toContain("End of file")
+ },
+ })
+ })
+
+ test("respects offset parameter", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n")
+ await Bun.write(path.join(dir, "offset.txt"), lines)
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
+ expect(result.output).toContain("line10")
+ expect(result.output).toContain("line14")
+ expect(result.output).not.toContain("line0")
+ expect(result.output).not.toContain("line15")
+ },
+ })
+ })
+
+ test("truncates long lines", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const longLine = "x".repeat(3000)
+ await Bun.write(path.join(dir, "long-line.txt"), longLine)
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
+ expect(result.output).toContain("...")
+ expect(result.output.length).toBeLessThan(3000)
+ },
+ })
+ })
+
+ test("image files set truncated to false", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ // 1x1 red PNG
+ const png = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
+ "base64",
+ )
+ await Bun.write(path.join(dir, "image.png"), png)
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ const result = await read.execute({ filePath: path.join(tmp.path, "image.png") }, ctx)
+ expect(result.metadata.truncated).toBe(false)
+ expect(result.attachments).toBeDefined()
+ expect(result.attachments?.length).toBe(1)
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts
new file mode 100644
index 00000000000..09222f279fa
--- /dev/null
+++ b/packages/opencode/test/tool/truncation.test.ts
@@ -0,0 +1,159 @@
+import { describe, test, expect, afterAll } from "bun:test"
+import { Truncate } from "../../src/tool/truncation"
+import { Identifier } from "../../src/id/id"
+import fs from "fs/promises"
+import path from "path"
+
+const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
+
+describe("Truncate", () => {
+ describe("output", () => {
+ test("truncates large json file by bytes", async () => {
+ const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
+ const result = await Truncate.output(content)
+
+ expect(result.truncated).toBe(true)
+ expect(result.content).toContain("truncated...")
+ if (result.truncated) expect(result.outputPath).toBeDefined()
+ })
+
+ test("returns content unchanged when under limits", async () => {
+ const content = "line1\nline2\nline3"
+ const result = await Truncate.output(content)
+
+ expect(result.truncated).toBe(false)
+ expect(result.content).toBe(content)
+ })
+
+ test("truncates by line count", async () => {
+ const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
+ const result = await Truncate.output(lines, { maxLines: 10 })
+
+ expect(result.truncated).toBe(true)
+ expect(result.content).toContain("...90 lines truncated...")
+ })
+
+ test("truncates by byte count", async () => {
+ const content = "a".repeat(1000)
+ const result = await Truncate.output(content, { maxBytes: 100 })
+
+ expect(result.truncated).toBe(true)
+ expect(result.content).toContain("truncated...")
+ })
+
+ test("truncates from head by default", async () => {
+ const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
+ const result = await Truncate.output(lines, { maxLines: 3 })
+
+ expect(result.truncated).toBe(true)
+ expect(result.content).toContain("line0")
+ expect(result.content).toContain("line1")
+ expect(result.content).toContain("line2")
+ expect(result.content).not.toContain("line9")
+ })
+
+ test("truncates from tail when direction is tail", async () => {
+ const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
+ const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
+
+ expect(result.truncated).toBe(true)
+ expect(result.content).toContain("line7")
+ expect(result.content).toContain("line8")
+ expect(result.content).toContain("line9")
+ expect(result.content).not.toContain("line0")
+ })
+
+ test("uses default MAX_LINES and MAX_BYTES", () => {
+ expect(Truncate.MAX_LINES).toBe(2000)
+ expect(Truncate.MAX_BYTES).toBe(50 * 1024)
+ })
+
+ test("large single-line file truncates with byte message", async () => {
+ const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
+ const result = await Truncate.output(content)
+
+ expect(result.truncated).toBe(true)
+ expect(result.content).toContain("bytes truncated...")
+ expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
+ })
+
+ test("writes full output to file when truncated", async () => {
+ const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
+ const result = await Truncate.output(lines, { maxLines: 10 })
+
+ expect(result.truncated).toBe(true)
+ expect(result.content).toContain("The tool call succeeded but the output was truncated")
+ expect(result.content).toContain("Grep")
+ if (!result.truncated) throw new Error("expected truncated")
+ expect(result.outputPath).toBeDefined()
+ expect(result.outputPath).toContain("tool_")
+
+ const written = await Bun.file(result.outputPath).text()
+ expect(written).toBe(lines)
+ })
+
+ test("suggests Task tool when agent has task permission", async () => {
+ const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
+ const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
+ const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
+
+ expect(result.truncated).toBe(true)
+ expect(result.content).toContain("Grep")
+ expect(result.content).toContain("Task tool")
+ })
+
+ test("omits Task tool hint when agent lacks task permission", async () => {
+ const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
+ const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
+ const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
+
+ expect(result.truncated).toBe(true)
+ expect(result.content).toContain("Grep")
+ expect(result.content).not.toContain("Task tool")
+ })
+
+ test("does not write file when not truncated", async () => {
+ const content = "short content"
+ const result = await Truncate.output(content)
+
+ expect(result.truncated).toBe(false)
+ if (result.truncated) throw new Error("expected not truncated")
+ expect("outputPath" in result).toBe(false)
+ })
+ })
+
+ describe("cleanup", () => {
+ const DAY_MS = 24 * 60 * 60 * 1000
+ let oldFile: string
+ let recentFile: string
+
+ afterAll(async () => {
+ await fs.unlink(oldFile).catch(() => {})
+ await fs.unlink(recentFile).catch(() => {})
+ })
+
+ test("deletes files older than 7 days and preserves recent files", async () => {
+ await fs.mkdir(Truncate.DIR, { recursive: true })
+
+ // Create an old file (10 days ago)
+ const oldTimestamp = Date.now() - 10 * DAY_MS
+ const oldId = Identifier.create("tool", false, oldTimestamp)
+ oldFile = path.join(Truncate.DIR, oldId)
+ await Bun.write(Bun.file(oldFile), "old content")
+
+ // Create a recent file (3 days ago)
+ const recentTimestamp = Date.now() - 3 * DAY_MS
+ const recentId = Identifier.create("tool", false, recentTimestamp)
+ recentFile = path.join(Truncate.DIR, recentId)
+ await Bun.write(Bun.file(recentFile), "recent content")
+
+ await Truncate.cleanup()
+
+ // Old file should be deleted
+ expect(await Bun.file(oldFile).exists()).toBe(false)
+
+ // Recent file should still exist
+ expect(await Bun.file(recentFile).exists()).toBe(true)
+ })
+ })
+})