From 4545537220c275a7432da98e29fcfd784ff897b9 Mon Sep 17 00:00:00 2001 From: Tim Richardson Date: Sun, 31 May 2026 12:12:36 +1000 Subject: [PATCH 1/3] fix(opencode): bound compaction request payload --- packages/opencode/src/session/compaction.ts | 64 +++++++++- packages/opencode/src/session/message-v2.ts | 10 +- .../opencode/test/session/compaction.test.ts | 111 +++++++++++++++++ .../opencode/test/session/message-v2.test.ts | 115 ++++++++++++++++++ 4 files changed, 291 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ceb78e632140..6c2f3b1a3582 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -35,7 +35,8 @@ export const Event = { export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 -const TOOL_OUTPUT_MAX_CHARS = 2_000 +const MAX_COMPACTION_TOOL_OUTPUT_CHARS = 2_000 +const COMPACTION_INPUT_BUDGET_RATIO = 0.85 const PRUNE_PROTECTED_TOOLS = ["skill"] const DEFAULT_TAIL_TURNS = 2 const MIN_PRESERVE_RECENT_TOKENS = 2_000 @@ -242,6 +243,57 @@ export const layer = Layer.effect( return Token.estimate(JSON.stringify(msgs)) }) + const prepareModelMessages = Effect.fn("SessionCompaction.prepareModelMessages")(function* (input: { + messages: MessageV2.WithParts[] + model: Provider.Model + cfg: Config.Info + prompt: string + }) { + const budget = Math.floor( + usable({ cfg: input.cfg, model: input.model, outputTokenMax: flags.outputTokenMax }) * + COMPACTION_INPUT_BUDGET_RATIO, + ) + + const build = (options: { stripReasoning?: boolean; toolOutputMaxChars: number }) => + Effect.gen(function* () { + const modelMessages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { + stripMedia: true, + stripReasoning: options.stripReasoning, + toolOutputMaxChars: options.toolOutputMaxChars, + }) + return { + modelMessages, + tokens: Token.estimate(JSON.stringify(modelMessages) + input.prompt), + toolOutputMaxChars: options.toolOutputMaxChars, + } + }) + + const legacy = yield* build({ toolOutputMaxChars: MAX_COMPACTION_TOOL_OUTPUT_CHARS }) + // Preserve the old payload shape when it fits; only reduce history when + // the compaction request itself would exceed the safe model budget. + if (input.model.limit.context === 0 || legacy.tokens <= budget) return { ...legacy, budget } + + let low = 0 + let high = MAX_COMPACTION_TOOL_OUTPUT_CHARS + let best = yield* build({ stripReasoning: true, toolOutputMaxChars: 0 }) + + while (low <= high) { + const mid = Math.floor((low + high) / 2) + const candidate = yield* build({ stripReasoning: true, toolOutputMaxChars: mid }) + if (candidate.tokens <= budget) { + best = candidate + low = mid + 1 + } else { + high = mid - 1 + } + } + + return { + ...best, + budget, + } + }) + const select = Effect.fn("SessionCompaction.select")(function* (input: { messages: SessionLegacy.WithParts[] cfg: Config.Info @@ -405,9 +457,11 @@ export const layer = Layer.effect( const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { - stripMedia: true, - toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + const prepared = yield* prepareModelMessages({ + messages: msgs, + model, + cfg, + prompt: nextPrompt, }) const ctx = yield* InstanceState.context const msg: SessionLegacy.Assistant = { @@ -449,7 +503,7 @@ export const layer = Layer.effect( tools: {}, system: [], messages: [ - ...modelMessages, + ...prepared.modelMessages, { role: "user", content: [{ type: "text", text: nextPrompt }], diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 862e06fc214e..23fb5ba18edf 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -50,7 +50,7 @@ export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } function truncateToolOutput(text: string, maxChars?: number) { - if (!maxChars || text.length <= maxChars) return text + if (maxChars === undefined || text.length <= maxChars) return text const omitted = text.length - maxChars return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]` } @@ -143,7 +143,9 @@ function providerMeta(metadata: Record | undefined) { export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, + // Keep this inline with the Promise wrapper below so call sites can see + // conversion-only options without following a separate type alias. + options?: { stripMedia?: boolean; stripReasoning?: boolean; toolOutputMaxChars?: number }, ) { const result: UIMessage[] = [] const toolNames = new Set() @@ -370,7 +372,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } - if (part.type === "reasoning") { + if (part.type === "reasoning" && !options?.stripReasoning) { if (differentModel) { if (part.text.trim().length > 0) assistantMessage.parts.push({ @@ -428,7 +430,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, + options?: { stripMedia?: boolean; stripReasoning?: boolean; toolOutputMaxChars?: number }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 9bff89c348ad..cc36a7370b2e 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1052,6 +1052,117 @@ describe("session.compaction.process", () => { { git: true }, ) + itCompaction.instance( + "preserves legacy compaction payload when it fits budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const user = yield* createUserMessage(session.id, "summarize this") + const assistant = yield* createAssistantMessage(session.id, user.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "reasoning", + text: "legacy reasoning should be preserved", + metadata: { openai: { reasoningEncryptedContent: "legacy-encrypted-reasoning" } }, + time: { start: Date.now(), end: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "small output" }, + output: "small legacy tool output", + title: "Shell", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + expect(captured).toContain("legacy reasoning should be preserved") + expect(captured).toContain("small legacy tool output") + expect(captured).not.toContain("Tool output truncated for compaction") + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 0 }) })) + }, + { git: true }, + ) + + itCompaction.instance( + "reduces compaction payload to fit constrained model budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const user = yield* createUserMessage(session.id, "summarize this") + const assistant = yield* createAssistantMessage(session.id, user.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "reasoning", + text: "hidden reasoning should not be compacted", + metadata: { openai: { reasoningEncryptedContent: "encrypted-reasoning".repeat(1_000) } }, + time: { start: Date.now(), end: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "generate large output" }, + output: "tool-output-should-be-truncated".repeat(1_000), + title: "Shell", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + expect(captured).not.toContain("hidden reasoning should not be compacted") + expect(captured).not.toContain("encrypted-reasoning") + expect(captured).not.toContain("tool-output-should-be-truncated") + expect(captured).toContain("Tool output truncated for compaction") + }).pipe( + withCompaction({ + llm: stub.layer, + provider: ProviderTest.fake({ model: createModel({ context: 100, output: 10 }) }), + config: cfg({ tail_turns: 0 }), + }), + ) + }, + { git: true }, + ) + itCompaction.instance( "retains a split turn suffix when a later message fits the preserve token budget", () => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 75850e59fb69..22eee920be6c 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -820,6 +820,121 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("allows tool output truncation to zero characters", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "ls" }, + output: "abcdefghij", + title: "Shell", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model, { toolOutputMaxChars: 0 })).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { cmd: "ls" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { + type: "text", + value: "\n[Tool output truncated for compaction: omitted 10 chars]", + }, + }, + ], + }, + ]) + }) + + test("strips reasoning parts when requested", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "hidden chain of thought", + metadata: { openai: { reasoningEncryptedContent: "x".repeat(10_000) } }, + }, + { + ...basePart(assistantID, "a2"), + type: "text", + text: "visible answer", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model, { stripReasoning: true })).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "visible answer" }], + }, + ]) + }) + test("converts assistant tool error into error-text tool result", async () => { const userID = "m-user" const assistantID = "m-assistant" From 448474293ed4f2373c277c12bdd0fd7112e6dd80 Mon Sep 17 00:00:00 2001 From: Tim Richardson Date: Sun, 31 May 2026 12:12:40 +1000 Subject: [PATCH 2/3] fix(opencode): align compaction session types --- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/test/session/message-v2.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 6c2f3b1a3582..d8f84cd992db 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -244,7 +244,7 @@ export const layer = Layer.effect( }) const prepareModelMessages = Effect.fn("SessionCompaction.prepareModelMessages")(function* (input: { - messages: MessageV2.WithParts[] + messages: SessionLegacy.WithParts[] model: Provider.Model cfg: Config.Info prompt: string diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 22eee920be6c..0a802cfacc35 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -824,7 +824,7 @@ describe("session.message-v2.toModelMessage", () => { const userID = "m-user" const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -833,7 +833,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "run tool", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID), @@ -852,7 +852,7 @@ describe("session.message-v2.toModelMessage", () => { time: { start: 0, end: 1 }, }, }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] @@ -894,7 +894,7 @@ describe("session.message-v2.toModelMessage", () => { const userID = "m-user" const assistantID = "m-assistant" - const input: MessageV2.WithParts[] = [ + const input: SessionLegacy.WithParts[] = [ { info: userInfo(userID), parts: [ @@ -903,7 +903,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "hello", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, { info: assistantInfo(assistantID, userID), @@ -919,7 +919,7 @@ describe("session.message-v2.toModelMessage", () => { type: "text", text: "visible answer", }, - ] as MessageV2.Part[], + ] as SessionLegacy.Part[], }, ] From bb5570bd2489b4675d35cf2fd1c4ad88fb122ff1 Mon Sep 17 00:00:00 2001 From: Tim Richardson Date: Sun, 31 May 2026 21:02:31 +1000 Subject: [PATCH 3/3] test(opencode): cover compaction payload regressions --- .../opencode/test/session/compaction.test.ts | 199 +++++++++++++++++- 1 file changed, 191 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index cc36a7370b2e..6659a1187be6 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -21,6 +21,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { SessionV2 } from "@opencode-ai/core/session" +import { usable } from "../../src/session/overflow" import type { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" @@ -225,6 +226,11 @@ function cfg(compaction?: Config.Info["compaction"]) { }) } +function compactionInputBudget(model: Provider.Model, compaction?: Config.Info["compaction"]) { + const base = Schema.decodeUnknownSync(Config.Info)({}) as Config.Info + return Math.floor(usable({ cfg: { ...base, compaction }, model }) * 0.85) +} + const deps = Layer.mergeAll( wide().layer, layer("continue"), @@ -1108,8 +1114,10 @@ describe("session.compaction.process", () => { "reduces compaction payload to fit constrained model budget", () => { const stub = llm() - let captured = "" - stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + let captured: LLM.StreamInput | undefined + const model = createModel({ context: 5_000, output: 500 }) + const compaction = { tail_turns: 0 } + stub.push(reply("summary", (input) => (captured = input))) return Effect.gen(function* () { const test = yield* TestInstance const ssn = yield* SessionNs.Service @@ -1148,15 +1156,190 @@ describe("session.compaction.process", () => { expect(parent).toBeTruthy() yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - expect(captured).not.toContain("hidden reasoning should not be compacted") - expect(captured).not.toContain("encrypted-reasoning") - expect(captured).not.toContain("tool-output-should-be-truncated") - expect(captured).toContain("Tool output truncated for compaction") + expect(captured).toBeDefined() + if (!captured) return + const text = JSON.stringify(captured.messages) + expect(Token.estimate(text)).toBeLessThanOrEqual(compactionInputBudget(model, compaction)) + expect(text).not.toContain("hidden reasoning should not be compacted") + expect(text).not.toContain("encrypted-reasoning") + expect(text).not.toContain("tool-output-should-be-truncated".repeat(100)) + expect(text).toContain("Tool output truncated for compaction") + }).pipe( + withCompaction({ + llm: stub.layer, + provider: ProviderTest.fake({ model }), + config: cfg(compaction), + }), + ) + }, + { git: true }, + ) + + itCompaction.instance( + "marks compaction as errored when stripped payload still exceeds budget", + () => { + const stub = llm() + let captured: LLM.StreamInput | undefined + const model = createModel({ context: 400, output: 50 }) + const compaction = { tail_turns: 0 } + stub.push((input) => { + captured = input + return Stream.fail( + new APICallError({ + message: "prompt is too long: 1200 tokens > 400 maximum", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 400, + responseHeaders: { "content-type": "application/json" }, + isRetryable: false, + }), + ) + }) + + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const user = yield* createUserMessage(session.id, "summarize this") + const assistant = yield* createAssistantMessage(session.id, user.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "reasoning", + text: "reasoning should still be stripped", + metadata: { openai: { reasoningEncryptedContent: "encrypted-reasoning".repeat(1_000) } }, + time: { start: Date.now(), end: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "generate large output" }, + output: "irreducible-tool-output".repeat(1_000), + title: "Shell", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + expect(result).toBe("stop") + expect(captured).toBeDefined() + if (!captured) return + const text = JSON.stringify(captured.messages) + expect(Token.estimate(text)).toBeGreaterThan(compactionInputBudget(model, compaction)) + expect(text).not.toContain("reasoning should still be stripped") + expect(text).not.toContain("encrypted-reasoning") + expect(text).not.toContain("irreducible-tool-output") + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (item) => item.info.role === "assistant" && item.info.summary, + ) + expect(summary?.info.role).toBe("assistant") + if (summary?.info.role === "assistant") { + expect(summary.info.finish).toBe("error") + expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") + } + }).pipe( + withCompaction({ + llm: stub.layer, + provider: ProviderTest.fake({ model }), + config: cfg(compaction), + }), + ) + }, + { git: true }, + ) + + itCompaction.instance( + "includes plugin compaction context when sizing the payload", + () => { + const stub = llm() + let captured: LLM.StreamInput | undefined + const model = createModel({ context: 6_000, output: 500 }) + const compaction = { tail_turns: 0 } + const pluginContext = "plugin context " + "p".repeat(4_000) + const compacting = Layer.mock(Plugin.Service)({ + trigger: (name: Name, _input: Input, output: Output) => { + if (name !== "experimental.session.compacting") return Effect.succeed(output) + return Effect.sync(() => { + ;(output as { context: string[] }).context = [pluginContext] + return output + }) + }, + list: () => Effect.succeed([]), + init: () => Effect.void, + }) + stub.push(reply("summary", (input) => (captured = input))) + + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const user = yield* createUserMessage(session.id, "summarize this") + const assistant = yield* createAssistantMessage(session.id, user.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "reasoning", + text: "plugin pressure reasoning should be stripped", + metadata: { openai: { reasoningEncryptedContent: "plugin-encrypted-reasoning".repeat(1_000) } }, + time: { start: Date.now(), end: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "generate large output" }, + output: "plugin-pressure-tool-output".repeat(1_000), + title: "Shell", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + expect(captured).toBeDefined() + if (!captured) return + const text = JSON.stringify(captured.messages) + expect(Token.estimate(text)).toBeLessThanOrEqual(compactionInputBudget(model, compaction)) + expect(text).toContain(pluginContext) + expect(text).not.toContain("plugin pressure reasoning should be stripped") + expect(text).not.toContain("plugin-encrypted-reasoning") + expect(text).not.toContain("plugin-pressure-tool-output".repeat(100)) + expect(text).toContain("Tool output truncated for compaction") }).pipe( withCompaction({ llm: stub.layer, - provider: ProviderTest.fake({ model: createModel({ context: 100, output: 10 }) }), - config: cfg({ tail_turns: 0 }), + provider: ProviderTest.fake({ model }), + config: cfg(compaction), + plugin: compacting, }), ) },