feat: write truncated tool outputs to files (#7239)
This commit is contained in:
@@ -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))
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
33453
packages/opencode/test/tool/fixtures/models-api.json
Normal file
33453
packages/opencode/test/tool/fixtures/models-api.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
159
packages/opencode/test/tool/truncation.test.ts
Normal file
159
packages/opencode/test/tool/truncation.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user