diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 84e799c1310e..1f3c4c716e98 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -19,7 +19,7 @@ export namespace Truncate { export interface Options { maxLines?: number maxBytes?: number - direction?: "head" | "tail" + direction?: "head" | "tail" | "middle" } export function init() { @@ -50,7 +50,7 @@ export namespace Truncate { 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 direction = options.direction ?? "middle" const lines = text.split("\n") const totalBytes = Buffer.byteLength(text, "utf-8") @@ -58,6 +58,57 @@ export namespace Truncate { return { content: text, truncated: false } } + 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 explore agent 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.` + + if (direction === "middle") { + const HEAD_SHARE = 0.3 + const headMaxLines = Math.max(1, Math.floor(maxLines * HEAD_SHARE)) + const tailMaxLines = Math.max(1, maxLines - headMaxLines) + const headMaxBytes = Math.floor(maxBytes * HEAD_SHARE) + const tailMaxBytes = maxBytes - headMaxBytes + + const headOut: string[] = [] + let headBytes = 0 + let headHitBytes = false + for (let i = 0; i < lines.length && headOut.length < headMaxLines; i++) { + const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) + if (headBytes + size > headMaxBytes) { + headHitBytes = true + break + } + headOut.push(lines[i]) + headBytes += size + } + + const tailOut: string[] = [] + let tailBytes = 0 + let tailHitBytes = false + for (let i = lines.length - 1; i >= headOut.length && tailOut.length < tailMaxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (tailOut.length > 0 ? 1 : 0) + if (tailBytes + size > tailMaxBytes) { + tailHitBytes = true + break + } + tailOut.unshift(lines[i]) + tailBytes += size + } + + const keptLines = headOut.length + tailOut.length + const keptBytes = headBytes + tailBytes + const hitBytes = headHitBytes || tailHitBytes + const removed = hitBytes ? totalBytes - keptBytes : lines.length - keptLines + const unit = hitBytes ? "bytes" : "lines" + + const message = `${headOut.join("\n")}\n\n...${removed} ${unit} truncated...\n\n${hint}\n\n${tailOut.join("\n")}` + return { content: message, truncated: true, outputPath: filepath } + } + const out: string[] = [] let i = 0 let bytes = 0 @@ -89,13 +140,6 @@ export namespace Truncate { const unit = hitBytes ? "bytes" : "lines" const preview = out.join("\n") - 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 explore agent 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}` diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 09222f279fa1..c84e79438edd 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -41,9 +41,26 @@ describe("Truncate", () => { expect(result.content).toContain("truncated...") }) - test("truncates from head by default", async () => { + test("truncates from middle by default, keeping head and tail", async () => { + const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n") + const result = await Truncate.output(lines, { maxLines: 10 }) + + expect(result.truncated).toBe(true) + // Head portion (~30%) should be kept + expect(result.content).toContain("line0") + expect(result.content).toContain("line1") + expect(result.content).toContain("line2") + // Tail portion (~70%) should be kept + expect(result.content).toContain("line19") + expect(result.content).toContain("line18") + expect(result.content).toContain("line17") + // Middle should be truncated + expect(result.content).toContain("truncated") + }) + + test("truncates from head when direction is head", async () => { const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") - const result = await Truncate.output(lines, { maxLines: 3 }) + const result = await Truncate.output(lines, { maxLines: 3, direction: "head" }) expect(result.truncated).toBe(true) expect(result.content).toContain("line0") @@ -63,6 +80,23 @@ describe("Truncate", () => { expect(result.content).not.toContain("line0") }) + test("middle truncation prioritizes tail over head (70/30 split)", async () => { + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + const result = await Truncate.output(lines, { maxLines: 10, direction: "middle" }) + + expect(result.truncated).toBe(true) + // Head gets ~30% = 3 lines + expect(result.content).toContain("line0") + expect(result.content).toContain("line1") + expect(result.content).toContain("line2") + // Tail gets ~70% = 7 lines + expect(result.content).toContain("line99") + expect(result.content).toContain("line98") + expect(result.content).toContain("line93") + // Middle should not be present + expect(result.content).not.toContain("line50") + }) + test("uses default MAX_LINES and MAX_BYTES", () => { expect(Truncate.MAX_LINES).toBe(2000) expect(Truncate.MAX_BYTES).toBe(50 * 1024)