From 4ecaca553049b6926a65b6971b37d2da4ee3edb7 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 6 Mar 2026 12:00:27 +0000 Subject: [PATCH] feat(tool): add middle truncation to preserve recent content When tool output exceeds size limits, truncation now defaults to "middle" mode which keeps both the beginning (~30%) and end (~70%) of the output, removing the middle. This ensures recent messages (e.g. latest Slack thread replies, recent errors) are preserved alongside initial context (issue metadata, headers). Previously the default "head" truncation kept only the beginning, causing the most relevant recent content to be lost for long Slack/issue threads in the CX intake flow. Closes CX-3760 Co-Authored-By: Claude Opus 4.6 --- packages/opencode/src/tool/truncation.ts | 62 ++++++++++++++++--- .../opencode/test/tool/truncation.test.ts | 38 +++++++++++- 2 files changed, 89 insertions(+), 11 deletions(-) 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)