From 4d6a67f352aa625ad95cabd8114b348b65c38b91 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 09:45:23 -0700 Subject: [PATCH 1/3] fix(opencode): bound oversized tool errors --- packages/opencode/src/session/llm.ts | 40 ++++++++++-- packages/opencode/src/session/session.ts | 10 ++- packages/opencode/src/tool/truncate.ts | 23 ++++++- packages/opencode/test/session/llm.test.ts | 65 ++++++++++++++++++- .../opencode/test/session/session.test.ts | 46 +++++++++++++ .../opencode/test/tool/truncation.test.ts | 32 +++++++++ 6 files changed, 207 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff2..ceafd55329 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -29,9 +29,37 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { LLMAISDK } from "./llm/ai-sdk" import { LLMNativeRuntime } from "./llm/native-runtime" import { LLMRequestPrep } from "./llm/request" +import { Truncate } from "@/tool/truncate" export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX +const TOOL_CALL_ERROR_MAX_CHARS = Truncate.MAX_ERROR_CHARS +const TOOL_CALL_ERROR_CONTEXT_CHARS = Math.floor(TOOL_CALL_ERROR_MAX_CHARS / 2) +const TOOL_CALL_NAME_MAX_CHARS = 256 + +export function invalidToolCallInput(tool: string, message: string) { + const exact = JSON.stringify({ tool, error: message }) + if (exact.length <= TOOL_CALL_ERROR_MAX_CHARS) return exact + + const name = tool.length <= TOOL_CALL_NAME_MAX_CHARS ? tool : `${tool.slice(0, TOOL_CALL_NAME_MAX_CHARS - 3)}...` + const maximum = Math.min(TOOL_CALL_ERROR_CONTEXT_CHARS, Math.floor(message.length / 2)) + let lower = 0 + let upper = maximum + let result = JSON.stringify({ tool: name, error: `... ${message.length} characters omitted ...` }) + while (lower <= upper) { + const context = Math.floor((lower + upper) / 2) + const error = `${message.slice(0, context)}\n... ${message.length - context * 2} characters omitted ...\n${context === 0 ? "" : message.slice(-context)}` + const candidate = JSON.stringify({ tool: name, error }) + if (candidate.length > TOOL_CALL_ERROR_MAX_CHARS) { + upper = context - 1 + continue + } + result = candidate + lower = context + 1 + } + return result +} + export type StreamInput = { user: SessionV1.User sessionID: string @@ -70,6 +98,7 @@ const live: Layer.Layer< | EventV2Bridge.Service | LLMClientService | RuntimeFlags.Service + | Truncate.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -81,6 +110,7 @@ const live: Layer.Layer< const events = yield* EventV2Bridge.Service const llmClient = yield* LLMClient.Service const flags = yield* RuntimeFlags.Service + const truncate = yield* Truncate.Service const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { yield* Effect.logInfo("stream", { @@ -142,7 +172,8 @@ const live: Layer.Layer< title: typeof result === "object" ? result?.title : undefined, } } catch (e: any) { - return { result: "", error: e.message ?? String(e) } + const error = await bridge.promise(truncate.error(e.message ?? String(e))) + return { result: "", error: error.content } } } @@ -301,12 +332,10 @@ const live: Layer.Layer< toolName: lower, } } + const error = await bridge.promise(truncate.error(failed.error.message)) return { ...failed.toolCall, - input: JSON.stringify({ - tool: failed.toolCall.toolName, - error: failed.error.message, - }), + input: invalidToolCallInput(failed.toolCall.toolName, error.content), toolName: "invalid", } }, @@ -398,6 +427,7 @@ export const node = LayerNode.make({ EventV2Bridge.node, llmClient, RuntimeFlags.node, + Truncate.node, ], }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f8dbb57765..58b2d61c17 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -44,6 +44,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Truncate } from "@/tool/truncate" const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " @@ -488,7 +489,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" const layer: Layer.Layer< Service, never, - BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service + BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service | Truncate.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -497,6 +498,7 @@ const layer: Layer.Layer< const background = yield* BackgroundJob.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + const truncate = yield* Truncate.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -636,6 +638,10 @@ const layer: Layer.Layer< const updatePart = (part: T): Effect.Effect => Effect.gen(function* () { + if (part.type === "tool" && part.state.status === "error") { + const bounded = yield* truncate.error(part.state.error) + part.state.error = bounded.content + } yield* events.publish(SessionV1.Event.PartUpdated, { sessionID: part.sessionID, part: structuredClone(part), @@ -1012,7 +1018,7 @@ function listByProject( export const node = LayerNode.make({ service: Service, layer: layer, - deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node], + deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node, Truncate.node], }) export * as Session from "./session" diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 3a48c90a98..cbbcb2a084 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -14,6 +14,7 @@ const RETENTION = Duration.days(7) export const MAX_LINES = 2000 export const MAX_BYTES = 50 * 1024 +export const MAX_ERROR_CHARS = 10_000 export const DIR = TRUNCATION_DIR export const GLOB = path.join(TRUNCATION_DIR, "*") @@ -33,6 +34,11 @@ function hasTaskTool(agent?: Agent.Info) { export interface Interface { readonly cleanup: () => Effect.Effect readonly write: (text: string) => Effect.Effect + /** + * Keeps short tool errors unchanged. Larger errors are written to the same + * retained output store and replaced with a bounded head/tail preview. + */ + readonly error: (text: string) => Effect.Effect /** * Returns output unchanged when it fits within the limits, otherwise writes the full text * to the truncation directory and returns a preview plus a hint to inspect the saved file. @@ -72,6 +78,21 @@ const layer = Layer.effect( return file }) + const error = Effect.fn("Truncate.error")(function* (text: string) { + if (text.length <= MAX_ERROR_CHARS) return { content: text, truncated: false } as const + + const file = yield* write(text) + const notice = (omitted: number) => + `\n\n...${omitted} characters truncated...\n\nThe tool call failed and the full error was saved to: ${file}\nUse Grep to search the full error or Read with offset/limit to inspect specific sections.\n\n` + const context = Math.max(0, Math.floor((MAX_ERROR_CHARS - notice(text.length).length) / 2)) + const omitted = text.length - context * 2 + return { + content: `${text.slice(0, context)}${notice(omitted)}${context === 0 ? "" : text.slice(-context)}`, + truncated: true, + outputPath: file, + } as const + }) + const limits = Effect.fn("Truncate.limits")(function* () { const configSvc = yield* Effect.serviceOption(Config.Service) if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES } @@ -147,7 +168,7 @@ const layer = Layer.effect( Effect.forkScoped, ) - return Service.of({ cleanup, write, output, limits }) + return Service.of({ cleanup, write, error, output, limits }) }), ) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 61aac13ac3..ef29fb6425 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -3,7 +3,8 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import path from "path" -import { tool, type ModelMessage } from "ai" +import { InvalidToolInputError, tool, type ModelMessage } from "ai" +import { JSONParseError } from "@ai-sdk/provider" import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -27,6 +28,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { Truncate } from "@/tool/truncate" type ConfigModel = NonNullable[string]["models"]>[string] @@ -172,6 +174,67 @@ describe("session.llm.hasToolCalls", () => { }) }) +describe("session.llm.invalidToolCallInput", () => { + test("bounds JSON parsing errors stored in repaired tool calls", () => { + const dropped = "GIANT_MIDDLE_SHOULD_NOT_BE_STORED" + const malformed = `{"query":"USEFUL_PREFIX:${"x".repeat(100_000)}${dropped}${"y".repeat(100_000)}:USEFUL_INPUT_TAIL` + expect(malformed.length).toBeGreaterThan(200_000) + + let cause: unknown + try { + JSON.parse(malformed) + } catch (error) { + cause = error + } + if (!cause) throw new Error("expected malformed input to fail JSON parsing") + + const failed = new InvalidToolInputError({ + toolName: "lookup", + toolInput: malformed, + cause: new JSONParseError({ text: malformed, cause }), + }) + expect(failed.message.length).toBeGreaterThan(200_000) + + const storedSchema = z.object({ tool: z.string(), error: z.string() }) + const stored = LLM.invalidToolCallInput("lookup", failed.message) + const repaired = storedSchema.parse(JSON.parse(stored)) + const omission = repaired.error.match(/\n\.\.\. (\d+) characters omitted \.\.\.\n/) + + expect(stored.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) + expect(repaired.error.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) + expect(repaired.tool).toBe("lookup") + expect(repaired.error).toContain("USEFUL_PREFIX") + expect(repaired.error).toContain("USEFUL_INPUT_TAIL") + expect(repaired.error).toContain("JSON Parse error: Unterminated string") + expect(omission).not.toBeNull() + if (!omission) throw new Error("expected repaired error to report omitted characters") + expect(Number(omission[1])).toBe(failed.message.length - (repaired.error.length - omission[0].length)) + expect(repaired.error).not.toContain(dropped) + + const short = "short tool error with exact whitespace\n" + const unchanged = storedSchema.parse(JSON.parse(LLM.invalidToolCallInput("lookup", short))) + expect(unchanged.error).toBe(short) + + const whitespaceMalformed = + '{"description":"Test resumed player extraction","code":"const x=1;' + "\n\t".repeat(118_000) + '"}' + let whitespaceCause: unknown + try { + JSON.parse(whitespaceMalformed) + } catch (error) { + whitespaceCause = error + } + if (!whitespaceCause) throw new Error("expected whitespace-heavy input to fail JSON parsing") + const whitespaceError = new InvalidToolInputError({ + toolName: "browser_execute", + toolInput: whitespaceMalformed, + cause: new JSONParseError({ text: whitespaceMalformed, cause: whitespaceCause }), + }) + const boundedWhitespace = LLM.invalidToolCallInput("browser_execute", whitespaceError.message) + expect(boundedWhitespace.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) + expect(JSON.parse(boundedWhitespace).error).toContain("characters omitted") + }) +}) + describe("session.llm.ai-sdk adapter", () => { type AISDKAdapterEvent = Parameters[1] diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index d109181986..5e2b38ab52 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -16,6 +16,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" +import { Truncate } from "@/tool/truncate" const it = testEffect( AppNodeBuilder.build( @@ -206,6 +207,51 @@ describe("step-finish token propagation via event", () => { }) describe("Session", () => { + it.instance("bounds every persisted tool error and saves the full text", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const info = yield* Effect.acquireRelease(session.create({ title: "tool-error" }), (created) => + session.remove(created.id).pipe(Effect.ignore), + ) + const messageID = MessageID.ascending() + yield* session.updateMessage({ + id: messageID, + sessionID: info.id, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: "test", modelID: "test" }, + } as unknown as SessionV1.Info) + + const error = `ERROR_HEAD:${"e".repeat(15_000)}GIANT_MIDDLE${"r".repeat(15_000)}:ERROR_TAIL` + const part = yield* session.updatePart({ + id: PartID.ascending(), + messageID, + sessionID: info.id, + type: "tool", + tool: "lookup", + callID: "call-1", + state: { + status: "error", + input: {}, + error, + time: { start: Date.now(), end: Date.now() }, + }, + } satisfies SessionV1.ToolPart) + + expect(part.state.status).toBe("error") + if (part.state.status !== "error") return + expect(part.state.error.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) + expect(part.state.error).toContain("ERROR_HEAD") + expect(part.state.error).toContain("ERROR_TAIL") + expect(part.state.error).not.toContain("GIANT_MIDDLE") + const outputPath = part.state.error.match(/full error was saved to: (.+)\n/)?.[1] + expect(outputPath).toBeDefined() + if (!outputPath) throw new Error("expected full error path") + expect(yield* Effect.promise(() => Bun.file(outputPath).text())).toBe(error) + }), + ) + it.live("remove works without an instance", () => Effect.gen(function* () { const session = yield* SessionNs.Service diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index d575a58ffa..5bc7b1f8fd 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -25,6 +25,38 @@ const configuredLayer = (cfg: ConfigV1.Info) => const configuredIt = (cfg: ConfigV1.Info) => testEffect(configuredLayer(cfg)) describe("Truncate", () => { + describe("error", () => { + test("uses a 10,000 character model-facing limit", () => { + expect(Truncate.MAX_ERROR_CHARS).toBe(10_000) + }) + + it.live("keeps short errors unchanged", () => + Effect.gen(function* () { + const content = "short tool error with exact whitespace\n" + const result = yield* (yield* Truncate.Service).error(content) + + expect(result).toEqual({ content, truncated: false }) + }), + ) + + it.live("saves the full error and returns a bounded head and tail", () => + Effect.gen(function* () { + const content = `ERROR_HEAD:${"h".repeat(15_000)}GIANT_MIDDLE${"t".repeat(15_000)}:ERROR_TAIL` + const result = yield* (yield* Truncate.Service).error(content) + + expect(result.truncated).toBe(true) + expect(result.content.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) + expect(result.content).toContain("ERROR_HEAD") + expect(result.content).toContain("ERROR_TAIL") + expect(result.content).not.toContain("GIANT_MIDDLE") + expect(result.content).toContain("the full error was saved to") + if (!result.truncated) throw new Error("expected truncated") + expect(result.content).toContain(result.outputPath) + expect(yield* (yield* FSUtil.Service).readFileString(result.outputPath)).toBe(content) + }), + ) + }) + describe("output", () => { it.live("truncates large json file by bytes", () => Effect.gen(function* () { From d8f9242bbf6e6925c8c0855e08a5e7da6cf6b92a Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 12:26:22 -0700 Subject: [PATCH 2/3] fix(opencode): preserve tool error recovery --- packages/opencode/src/session/llm.ts | 21 ++++++++++---- packages/opencode/src/tool/truncate.ts | 29 +++++++++++++------ packages/opencode/test/session/llm.test.ts | 15 +++++++--- .../opencode/test/tool/truncation.test.ts | 13 +++++++++ 4 files changed, 59 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index ceafd55329..a06e0416b6 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -37,18 +37,24 @@ const TOOL_CALL_ERROR_MAX_CHARS = Truncate.MAX_ERROR_CHARS const TOOL_CALL_ERROR_CONTEXT_CHARS = Math.floor(TOOL_CALL_ERROR_MAX_CHARS / 2) const TOOL_CALL_NAME_MAX_CHARS = 256 -export function invalidToolCallInput(tool: string, message: string) { - const exact = JSON.stringify({ tool, error: message }) +export function invalidToolCallInput( + tool: string, + message: string, + options?: { originalChars?: number; outputPath?: string }, +) { + const detail = options?.outputPath ? `${message}\n\nFull error saved to: ${options.outputPath}` : message + const exact = JSON.stringify({ tool, error: detail }) if (exact.length <= TOOL_CALL_ERROR_MAX_CHARS) return exact const name = tool.length <= TOOL_CALL_NAME_MAX_CHARS ? tool : `${tool.slice(0, TOOL_CALL_NAME_MAX_CHARS - 3)}...` - const maximum = Math.min(TOOL_CALL_ERROR_CONTEXT_CHARS, Math.floor(message.length / 2)) + const maximum = Math.min(TOOL_CALL_ERROR_CONTEXT_CHARS, Math.floor(detail.length / 2)) + const omission = `... error truncated; original contained ${options?.originalChars ?? message.length} characters ...` let lower = 0 let upper = maximum - let result = JSON.stringify({ tool: name, error: `... ${message.length} characters omitted ...` }) + let result = JSON.stringify({ tool: name, error: omission }) while (lower <= upper) { const context = Math.floor((lower + upper) / 2) - const error = `${message.slice(0, context)}\n... ${message.length - context * 2} characters omitted ...\n${context === 0 ? "" : message.slice(-context)}` + const error = `${detail.slice(0, context)}\n${omission}\n${context === 0 ? "" : detail.slice(-context)}` const candidate = JSON.stringify({ tool: name, error }) if (candidate.length > TOOL_CALL_ERROR_MAX_CHARS) { upper = context - 1 @@ -335,7 +341,10 @@ const live: Layer.Layer< const error = await bridge.promise(truncate.error(failed.error.message)) return { ...failed.toolCall, - input: invalidToolCallInput(failed.toolCall.toolName, error.content), + input: invalidToolCallInput(failed.toolCall.toolName, error.content, { + originalChars: failed.error.message.length, + outputPath: error.outputPath, + }), toolName: "invalid", } }, diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index cbbcb2a084..271bd50e77 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -1,6 +1,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { NodePath } from "@effect/platform-node" -import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect" +import { Cause, Duration, Effect, Exit, Layer, Option, Schedule, Context } from "effect" import path from "path" import type { Agent } from "../agent/agent" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -19,6 +19,17 @@ export const DIR = TRUNCATION_DIR export const GLOB = path.join(TRUNCATION_DIR, "*") export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } +export type ErrorResult = { content: string; truncated: boolean; outputPath?: string } + +export function errorPreview(text: string, outputPath?: string) { + const notice = (omitted: number) => + outputPath + ? `\n\n...${omitted} characters truncated...\n\nThe tool call failed and the full error was saved to: ${outputPath}\nUse Grep to search the full error or Read with offset/limit to inspect specific sections.\n\n` + : `\n\n...${omitted} characters truncated...\n\nThe tool call failed, and the full error could not be saved.\n\n` + const context = Math.max(0, Math.floor((MAX_ERROR_CHARS - notice(text.length).length) / 2)) + const omitted = text.length - context * 2 + return `${text.slice(0, context)}${notice(omitted)}${context === 0 ? "" : text.slice(-context)}` +} export interface Options { maxLines?: number @@ -38,7 +49,7 @@ export interface Interface { * Keeps short tool errors unchanged. Larger errors are written to the same * retained output store and replaced with a bounded head/tail preview. */ - readonly error: (text: string) => Effect.Effect + readonly error: (text: string) => Effect.Effect /** * Returns output unchanged when it fits within the limits, otherwise writes the full text * to the truncation directory and returns a preview plus a hint to inspect the saved file. @@ -81,15 +92,15 @@ const layer = Layer.effect( const error = Effect.fn("Truncate.error")(function* (text: string) { if (text.length <= MAX_ERROR_CHARS) return { content: text, truncated: false } as const - const file = yield* write(text) - const notice = (omitted: number) => - `\n\n...${omitted} characters truncated...\n\nThe tool call failed and the full error was saved to: ${file}\nUse Grep to search the full error or Read with offset/limit to inspect specific sections.\n\n` - const context = Math.max(0, Math.floor((MAX_ERROR_CHARS - notice(text.length).length) / 2)) - const omitted = text.length - context * 2 + const saved = yield* write(text).pipe(Effect.exit) + const file = Exit.isSuccess(saved) ? saved.value : undefined + if (Exit.isFailure(saved)) { + yield* Effect.logWarning("failed to save full tool error", { cause: Cause.pretty(saved.cause) }) + } return { - content: `${text.slice(0, context)}${notice(omitted)}${context === 0 ? "" : text.slice(-context)}`, + content: errorPreview(text, file), truncated: true, - outputPath: file, + ...(file ? { outputPath: file } : {}), } as const }) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index ef29fb6425..810df11eb5 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -198,7 +198,7 @@ describe("session.llm.invalidToolCallInput", () => { const storedSchema = z.object({ tool: z.string(), error: z.string() }) const stored = LLM.invalidToolCallInput("lookup", failed.message) const repaired = storedSchema.parse(JSON.parse(stored)) - const omission = repaired.error.match(/\n\.\.\. (\d+) characters omitted \.\.\.\n/) + const omission = repaired.error.match(/\.\.\. error truncated; original contained (\d+) characters \.\.\./) expect(stored.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) expect(repaired.error.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) @@ -208,7 +208,7 @@ describe("session.llm.invalidToolCallInput", () => { expect(repaired.error).toContain("JSON Parse error: Unterminated string") expect(omission).not.toBeNull() if (!omission) throw new Error("expected repaired error to report omitted characters") - expect(Number(omission[1])).toBe(failed.message.length - (repaired.error.length - omission[0].length)) + expect(Number(omission[1])).toBe(failed.message.length) expect(repaired.error).not.toContain(dropped) const short = "short tool error with exact whitespace\n" @@ -229,9 +229,16 @@ describe("session.llm.invalidToolCallInput", () => { toolInput: whitespaceMalformed, cause: new JSONParseError({ text: whitespaceMalformed, cause: whitespaceCause }), }) - const boundedWhitespace = LLM.invalidToolCallInput("browser_execute", whitespaceError.message) + const outputPath = "/tmp/tool-output/tool_full_error" + const boundedWhitespace = LLM.invalidToolCallInput("browser_execute", whitespaceError.message, { + originalChars: whitespaceError.message.length, + outputPath, + }) expect(boundedWhitespace.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) - expect(JSON.parse(boundedWhitespace).error).toContain("characters omitted") + expect(JSON.parse(boundedWhitespace).error).toContain( + `original contained ${whitespaceError.message.length} characters`, + ) + expect(JSON.parse(boundedWhitespace).error).toContain(outputPath) }) }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 5bc7b1f8fd..b7891e6998 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -51,10 +51,23 @@ describe("Truncate", () => { expect(result.content).not.toContain("GIANT_MIDDLE") expect(result.content).toContain("the full error was saved to") if (!result.truncated) throw new Error("expected truncated") + expect(result.outputPath).toBeDefined() + if (!result.outputPath) throw new Error("expected full error path") expect(result.content).toContain(result.outputPath) expect(yield* (yield* FSUtil.Service).readFileString(result.outputPath)).toBe(content) }), ) + + test("keeps a bounded preview when the full text cannot be saved", () => { + const content = `ERROR_HEAD:${"h".repeat(15_000)}GIANT_MIDDLE${"t".repeat(15_000)}:ERROR_TAIL` + const result = Truncate.errorPreview(content) + + expect(result.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) + expect(result).toContain("ERROR_HEAD") + expect(result).toContain("ERROR_TAIL") + expect(result).not.toContain("GIANT_MIDDLE") + expect(result).toContain("full error could not be saved") + }) }) describe("output", () => { From a25fda2e1ea96d5ee7ee1d914d53720c7b3260db Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 12:55:58 -0700 Subject: [PATCH 3/3] refactor(opencode): share tool truncation --- packages/opencode/src/session/llm.ts | 43 +------ packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/tool/truncate.ts | 54 +++------ packages/opencode/test/session/llm.test.ts | 72 +----------- .../opencode/test/session/session.test.ts | 46 -------- .../opencode/test/tool/truncation.test.ts | 106 ++++++++++-------- 6 files changed, 79 insertions(+), 244 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a06e0416b6..3fe9509015 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -33,39 +33,6 @@ import { Truncate } from "@/tool/truncate" export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX -const TOOL_CALL_ERROR_MAX_CHARS = Truncate.MAX_ERROR_CHARS -const TOOL_CALL_ERROR_CONTEXT_CHARS = Math.floor(TOOL_CALL_ERROR_MAX_CHARS / 2) -const TOOL_CALL_NAME_MAX_CHARS = 256 - -export function invalidToolCallInput( - tool: string, - message: string, - options?: { originalChars?: number; outputPath?: string }, -) { - const detail = options?.outputPath ? `${message}\n\nFull error saved to: ${options.outputPath}` : message - const exact = JSON.stringify({ tool, error: detail }) - if (exact.length <= TOOL_CALL_ERROR_MAX_CHARS) return exact - - const name = tool.length <= TOOL_CALL_NAME_MAX_CHARS ? tool : `${tool.slice(0, TOOL_CALL_NAME_MAX_CHARS - 3)}...` - const maximum = Math.min(TOOL_CALL_ERROR_CONTEXT_CHARS, Math.floor(detail.length / 2)) - const omission = `... error truncated; original contained ${options?.originalChars ?? message.length} characters ...` - let lower = 0 - let upper = maximum - let result = JSON.stringify({ tool: name, error: omission }) - while (lower <= upper) { - const context = Math.floor((lower + upper) / 2) - const error = `${detail.slice(0, context)}\n${omission}\n${context === 0 ? "" : detail.slice(-context)}` - const candidate = JSON.stringify({ tool: name, error }) - if (candidate.length > TOOL_CALL_ERROR_MAX_CHARS) { - upper = context - 1 - continue - } - result = candidate - lower = context + 1 - } - return result -} - export type StreamInput = { user: SessionV1.User sessionID: string @@ -178,7 +145,7 @@ const live: Layer.Layer< title: typeof result === "object" ? result?.title : undefined, } } catch (e: any) { - const error = await bridge.promise(truncate.error(e.message ?? String(e))) + const error = await bridge.promise(truncate.output(e.message ?? String(e))) return { result: "", error: error.content } } } @@ -338,12 +305,12 @@ const live: Layer.Layer< toolName: lower, } } - const error = await bridge.promise(truncate.error(failed.error.message)) + const error = await bridge.promise(truncate.output(failed.error.message)) return { ...failed.toolCall, - input: invalidToolCallInput(failed.toolCall.toolName, error.content, { - originalChars: failed.error.message.length, - outputPath: error.outputPath, + input: JSON.stringify({ + tool: failed.toolCall.toolName, + error: error.content, }), toolName: "invalid", } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 58b2d61c17..cef504785f 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -639,7 +639,7 @@ const layer: Layer.Layer< const updatePart = (part: T): Effect.Effect => Effect.gen(function* () { if (part.type === "tool" && part.state.status === "error") { - const bounded = yield* truncate.error(part.state.error) + const bounded = yield* truncate.output(part.state.error) part.state.error = bounded.content } yield* events.publish(SessionV1.Event.PartUpdated, { diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 271bd50e77..ab3d696c65 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -13,23 +13,11 @@ import { TRUNCATION_DIR } from "./truncation-dir" const RETENTION = Duration.days(7) export const MAX_LINES = 2000 -export const MAX_BYTES = 50 * 1024 -export const MAX_ERROR_CHARS = 10_000 +export const MAX_BYTES = 40 * 1024 export const DIR = TRUNCATION_DIR export const GLOB = path.join(TRUNCATION_DIR, "*") -export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } -export type ErrorResult = { content: string; truncated: boolean; outputPath?: string } - -export function errorPreview(text: string, outputPath?: string) { - const notice = (omitted: number) => - outputPath - ? `\n\n...${omitted} characters truncated...\n\nThe tool call failed and the full error was saved to: ${outputPath}\nUse Grep to search the full error or Read with offset/limit to inspect specific sections.\n\n` - : `\n\n...${omitted} characters truncated...\n\nThe tool call failed, and the full error could not be saved.\n\n` - const context = Math.max(0, Math.floor((MAX_ERROR_CHARS - notice(text.length).length) / 2)) - const omitted = text.length - context * 2 - return `${text.slice(0, context)}${notice(omitted)}${context === 0 ? "" : text.slice(-context)}` -} +export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath?: string } export interface Options { maxLines?: number @@ -45,11 +33,6 @@ function hasTaskTool(agent?: Agent.Info) { export interface Interface { readonly cleanup: () => Effect.Effect readonly write: (text: string) => Effect.Effect - /** - * Keeps short tool errors unchanged. Larger errors are written to the same - * retained output store and replaced with a bounded head/tail preview. - */ - readonly error: (text: string) => Effect.Effect /** * Returns output unchanged when it fits within the limits, otherwise writes the full text * to the truncation directory and returns a preview plus a hint to inspect the saved file. @@ -89,21 +72,6 @@ const layer = Layer.effect( return file }) - const error = Effect.fn("Truncate.error")(function* (text: string) { - if (text.length <= MAX_ERROR_CHARS) return { content: text, truncated: false } as const - - const saved = yield* write(text).pipe(Effect.exit) - const file = Exit.isSuccess(saved) ? saved.value : undefined - if (Exit.isFailure(saved)) { - yield* Effect.logWarning("failed to save full tool error", { cause: Cause.pretty(saved.cause) }) - } - return { - content: errorPreview(text, file), - truncated: true, - ...(file ? { outputPath: file } : {}), - } as const - }) - const limits = Effect.fn("Truncate.limits")(function* () { const configSvc = yield* Effect.serviceOption(Config.Service) if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES } @@ -156,11 +124,17 @@ const layer = Layer.effect( const removed = hitBytes ? totalBytes - bytes : lines.length - out.length const unit = hitBytes ? "bytes" : "lines" const preview = out.join("\n") - const file = yield* write(text) + const saved = yield* write(text).pipe(Effect.exit) + const file = Exit.isSuccess(saved) ? saved.value : undefined + if (Exit.isFailure(saved)) { + yield* Effect.logWarning("failed to save full truncated tool response", { cause: Cause.pretty(saved.cause) }) + } - const hint = hasTaskTool(agent) - ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\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: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + const hint = file + ? hasTaskTool(agent) + ? `The tool response was truncated. Full content saved to: ${file}\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 response was truncated. Full content saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + : "The tool response was truncated, and the full content could not be saved." return { content: @@ -168,7 +142,7 @@ const layer = Layer.effect( ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, truncated: true, - outputPath: file, + ...(file ? { outputPath: file } : {}), } as const }) @@ -179,7 +153,7 @@ const layer = Layer.effect( Effect.forkScoped, ) - return Service.of({ cleanup, write, error, output, limits }) + return Service.of({ cleanup, write, output, limits }) }), ) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 810df11eb5..61aac13ac3 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -3,8 +3,7 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import path from "path" -import { InvalidToolInputError, tool, type ModelMessage } from "ai" -import { JSONParseError } from "@ai-sdk/provider" +import { tool, type ModelMessage } from "ai" import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -28,7 +27,6 @@ import { ModelV2 } from "@opencode-ai/core/model" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" -import { Truncate } from "@/tool/truncate" type ConfigModel = NonNullable[string]["models"]>[string] @@ -174,74 +172,6 @@ describe("session.llm.hasToolCalls", () => { }) }) -describe("session.llm.invalidToolCallInput", () => { - test("bounds JSON parsing errors stored in repaired tool calls", () => { - const dropped = "GIANT_MIDDLE_SHOULD_NOT_BE_STORED" - const malformed = `{"query":"USEFUL_PREFIX:${"x".repeat(100_000)}${dropped}${"y".repeat(100_000)}:USEFUL_INPUT_TAIL` - expect(malformed.length).toBeGreaterThan(200_000) - - let cause: unknown - try { - JSON.parse(malformed) - } catch (error) { - cause = error - } - if (!cause) throw new Error("expected malformed input to fail JSON parsing") - - const failed = new InvalidToolInputError({ - toolName: "lookup", - toolInput: malformed, - cause: new JSONParseError({ text: malformed, cause }), - }) - expect(failed.message.length).toBeGreaterThan(200_000) - - const storedSchema = z.object({ tool: z.string(), error: z.string() }) - const stored = LLM.invalidToolCallInput("lookup", failed.message) - const repaired = storedSchema.parse(JSON.parse(stored)) - const omission = repaired.error.match(/\.\.\. error truncated; original contained (\d+) characters \.\.\./) - - expect(stored.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) - expect(repaired.error.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) - expect(repaired.tool).toBe("lookup") - expect(repaired.error).toContain("USEFUL_PREFIX") - expect(repaired.error).toContain("USEFUL_INPUT_TAIL") - expect(repaired.error).toContain("JSON Parse error: Unterminated string") - expect(omission).not.toBeNull() - if (!omission) throw new Error("expected repaired error to report omitted characters") - expect(Number(omission[1])).toBe(failed.message.length) - expect(repaired.error).not.toContain(dropped) - - const short = "short tool error with exact whitespace\n" - const unchanged = storedSchema.parse(JSON.parse(LLM.invalidToolCallInput("lookup", short))) - expect(unchanged.error).toBe(short) - - const whitespaceMalformed = - '{"description":"Test resumed player extraction","code":"const x=1;' + "\n\t".repeat(118_000) + '"}' - let whitespaceCause: unknown - try { - JSON.parse(whitespaceMalformed) - } catch (error) { - whitespaceCause = error - } - if (!whitespaceCause) throw new Error("expected whitespace-heavy input to fail JSON parsing") - const whitespaceError = new InvalidToolInputError({ - toolName: "browser_execute", - toolInput: whitespaceMalformed, - cause: new JSONParseError({ text: whitespaceMalformed, cause: whitespaceCause }), - }) - const outputPath = "/tmp/tool-output/tool_full_error" - const boundedWhitespace = LLM.invalidToolCallInput("browser_execute", whitespaceError.message, { - originalChars: whitespaceError.message.length, - outputPath, - }) - expect(boundedWhitespace.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) - expect(JSON.parse(boundedWhitespace).error).toContain( - `original contained ${whitespaceError.message.length} characters`, - ) - expect(JSON.parse(boundedWhitespace).error).toContain(outputPath) - }) -}) - describe("session.llm.ai-sdk adapter", () => { type AISDKAdapterEvent = Parameters[1] diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 5e2b38ab52..d109181986 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -16,7 +16,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" -import { Truncate } from "@/tool/truncate" const it = testEffect( AppNodeBuilder.build( @@ -207,51 +206,6 @@ describe("step-finish token propagation via event", () => { }) describe("Session", () => { - it.instance("bounds every persisted tool error and saves the full text", () => - Effect.gen(function* () { - const session = yield* SessionNs.Service - const info = yield* Effect.acquireRelease(session.create({ title: "tool-error" }), (created) => - session.remove(created.id).pipe(Effect.ignore), - ) - const messageID = MessageID.ascending() - yield* session.updateMessage({ - id: messageID, - sessionID: info.id, - role: "user", - time: { created: Date.now() }, - agent: "build", - model: { providerID: "test", modelID: "test" }, - } as unknown as SessionV1.Info) - - const error = `ERROR_HEAD:${"e".repeat(15_000)}GIANT_MIDDLE${"r".repeat(15_000)}:ERROR_TAIL` - const part = yield* session.updatePart({ - id: PartID.ascending(), - messageID, - sessionID: info.id, - type: "tool", - tool: "lookup", - callID: "call-1", - state: { - status: "error", - input: {}, - error, - time: { start: Date.now(), end: Date.now() }, - }, - } satisfies SessionV1.ToolPart) - - expect(part.state.status).toBe("error") - if (part.state.status !== "error") return - expect(part.state.error.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) - expect(part.state.error).toContain("ERROR_HEAD") - expect(part.state.error).toContain("ERROR_TAIL") - expect(part.state.error).not.toContain("GIANT_MIDDLE") - const outputPath = part.state.error.match(/full error was saved to: (.+)\n/)?.[1] - expect(outputPath).toBeDefined() - if (!outputPath) throw new Error("expected full error path") - expect(yield* Effect.promise(() => Bun.file(outputPath).text())).toBe(error) - }), - ) - it.live("remove works without an instance", () => Effect.gen(function* () { const session = yield* SessionNs.Service diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index b7891e6998..d842a47559 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,9 +1,10 @@ import { describe, test, expect } from "bun:test" +import { JSONParseError } from "@ai-sdk/provider" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { filesystem } from "@opencode-ai/core/effect/app-node-platform" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Effect, FileSystem } from "effect" +import { Effect, FileSystem, Layer } from "effect" import { Truncate } from "@/tool/truncate" import { Config } from "@/config/config" import { Identifier } from "../../src/id/id" @@ -12,11 +13,26 @@ import path from "path" import { testEffect } from "../lib/effect" import { writeFileStringScoped } from "../lib/filesystem" import { TestConfig } from "../fixture/config" +import { InvalidToolInputError } from "ai" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem]))) +const failedWriteFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + writeFileString: () => Effect.die("blocked test write"), + }), + ), + ), +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) +const failedWriteIt = testEffect( + LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem]), [[FSUtil.node, failedWriteFS]]), +) const configuredLayer = (cfg: ConfigV1.Info) => LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem, Config.node]), [ @@ -25,51 +41,6 @@ const configuredLayer = (cfg: ConfigV1.Info) => const configuredIt = (cfg: ConfigV1.Info) => testEffect(configuredLayer(cfg)) describe("Truncate", () => { - describe("error", () => { - test("uses a 10,000 character model-facing limit", () => { - expect(Truncate.MAX_ERROR_CHARS).toBe(10_000) - }) - - it.live("keeps short errors unchanged", () => - Effect.gen(function* () { - const content = "short tool error with exact whitespace\n" - const result = yield* (yield* Truncate.Service).error(content) - - expect(result).toEqual({ content, truncated: false }) - }), - ) - - it.live("saves the full error and returns a bounded head and tail", () => - Effect.gen(function* () { - const content = `ERROR_HEAD:${"h".repeat(15_000)}GIANT_MIDDLE${"t".repeat(15_000)}:ERROR_TAIL` - const result = yield* (yield* Truncate.Service).error(content) - - expect(result.truncated).toBe(true) - expect(result.content.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) - expect(result.content).toContain("ERROR_HEAD") - expect(result.content).toContain("ERROR_TAIL") - expect(result.content).not.toContain("GIANT_MIDDLE") - expect(result.content).toContain("the full error was saved to") - if (!result.truncated) throw new Error("expected truncated") - expect(result.outputPath).toBeDefined() - if (!result.outputPath) throw new Error("expected full error path") - expect(result.content).toContain(result.outputPath) - expect(yield* (yield* FSUtil.Service).readFileString(result.outputPath)).toBe(content) - }), - ) - - test("keeps a bounded preview when the full text cannot be saved", () => { - const content = `ERROR_HEAD:${"h".repeat(15_000)}GIANT_MIDDLE${"t".repeat(15_000)}:ERROR_TAIL` - const result = Truncate.errorPreview(content) - - expect(result.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS) - expect(result).toContain("ERROR_HEAD") - expect(result).toContain("ERROR_TAIL") - expect(result).not.toContain("GIANT_MIDDLE") - expect(result).toContain("full error could not be saved") - }) - }) - describe("output", () => { it.live("truncates large json file by bytes", () => Effect.gen(function* () { @@ -147,7 +118,7 @@ describe("Truncate", () => { test("uses default MAX_LINES and MAX_BYTES", () => { expect(Truncate.MAX_LINES).toBe(2000) - expect(Truncate.MAX_BYTES).toBe(50 * 1024) + expect(Truncate.MAX_BYTES).toBe(40 * 1024) }) it.live("limits() falls back to MAX_LINES/MAX_BYTES when Config is not provided", () => @@ -225,7 +196,7 @@ describe("Truncate", () => { const result = yield* svc.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("The tool response was truncated") expect(result.content).toContain("Grep") if (!result.truncated) throw new Error("expected truncated") expect(result.outputPath).toBeDefined() @@ -237,6 +208,45 @@ describe("Truncate", () => { }), ) + it.live("archives the production-shaped malformed tool error", () => + Effect.gen(function* () { + const malformed = + '{"description":"Test resumed player extraction","code":"const x=1;' + "\n\t".repeat(118_000) + '"}' + let cause: unknown + try { + JSON.parse(malformed) + } catch (error) { + cause = error + } + if (!cause) throw new Error("expected malformed input to fail JSON parsing") + + const failed = new InvalidToolInputError({ + toolName: "browser_execute", + toolInput: malformed, + cause: new JSONParseError({ text: malformed, cause }), + }) + const result = yield* (yield* Truncate.Service).output(failed.message) + expect(result.truncated).toBe(true) + if (!result.truncated || !result.outputPath) throw new Error("expected archived malformed-tool error") + + const stored = JSON.stringify({ tool: "browser_execute", error: result.content }) + expect(stored.length).toBeLessThanOrEqual(Truncate.MAX_BYTES) + expect(JSON.parse(stored).error).toContain(result.outputPath) + expect(yield* (yield* FSUtil.Service).readFileString(result.outputPath)).toBe(failed.message) + }), + ) + + failedWriteIt.live("keeps a bounded response when the full content cannot be saved", () => + Effect.gen(function* () { + const result = yield* (yield* Truncate.Service).output("x".repeat(Truncate.MAX_BYTES + 1)) + + expect(result.truncated).toBe(true) + if (!result.truncated) throw new Error("expected truncated output") + expect(result.outputPath).toBeUndefined() + expect(result.content).toContain("full content could not be saved") + }), + ) + it.live("suggests Task tool when agent has task permission", () => Effect.gen(function* () { const svc = yield* Truncate.Service