From 2acf08cca055f6bac41da762fefa08dc053a3a1a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 18:41:04 -0500 Subject: [PATCH] fix(core): harden semantic context estimation --- packages/core/src/session/compaction.ts | 43 +++++++++++++++++-- packages/core/test/session-compaction.test.ts | 28 ++++++++++-- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index ce4e84e91f..d3bb0a5cc6 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -80,22 +80,57 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SessionCompaction") {} -const stringify = (value: unknown) => (typeof value === "string" ? value : (JSON.stringify(value) ?? String(value))) +const serializeString = (value: unknown) => { + try { + return String(value) + } catch { + return "[unserializable]" + } +} + +const serializeJson = (value: unknown) => { + try { + return JSON.stringify(value) ?? serializeString(value) + } catch { + return serializeString(value) + } +} + +const serializeError = (value: unknown) => { + try { + const prototype = + typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) + const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null + return structured ? serializeJson(value) : serializeString(value) + } catch { + return serializeString(value) + } +} const serializeContent = (part: LLMRequest["messages"][number]["content"][number]) => { if (part.type === "text" || part.type === "reasoning") return part.text if (part.type === "media") return "" - if (part.type === "tool-call") return `${part.name}\n${stringify(part.input)}` + if (part.type === "tool-call") return `${part.name}\n${serializeJson(part.input)}` + // OpenAI replays hosted image generations by item reference; the opaque JSON result contains the image bytes. + if ( + part.providerExecuted && + part.name === "image_generation" && + part.result.type === "json" && + typeof part.providerMetadata?.openai?.itemId === "string" + ) + return part.name if (part.result.type === "content") return [part.name, ...part.result.value.flatMap((item) => (item.type === "text" ? [item.text] : []))].join("\n") - return `${part.name}\n${stringify(part.result.value)}` + if (part.result.type === "text") return `${part.name}\n${serializeString(part.result.value)}` + if (part.result.type === "error") return `${part.name}\n${serializeError(part.result.value)}` + return `${part.name}\n${serializeJson(part.result.value)}` } const estimate = (request: LLMRequest) => Token.estimate( [ ...request.system.map((part) => part.text), - JSON.stringify(request.tools), + serializeJson(request.tools), ...request.messages.flatMap((message) => message.content.map(serializeContent).filter(Boolean)), ].join("\n"), ) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index ae6c1c70a8..47f5608cbf 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -1,6 +1,15 @@ import { expect, test } from "bun:test" -import { LLM, LLMClient, LLMEvent, Message, Model, ToolCallPart, type LLMRequest } from "@opencode-ai/llm" -import { OpenAIChat } from "@opencode-ai/llm/protocols" +import { + LLM, + LLMClient, + LLMEvent, + Message, + Model, + ToolCallPart, + ToolResultPart, + type LLMRequest, +} from "@opencode-ai/llm" +import { OpenAIChat, OpenAIResponses } from "@opencode-ai/llm/protocols" import { Base64, FileAttachment } from "@opencode-ai/schema/prompt" import { Config } from "@opencode-ai/core/config" import { Database } from "@opencode-ai/core/database/database" @@ -87,7 +96,7 @@ it.effect("does not count image attachments as text context", () => const inputModel = Model.make({ id: "media-model", provider: "test", - route: OpenAIChat.route.with({ limits: { context: 30_000, output: 1_000 } }), + route: OpenAIResponses.route.with({ limits: { context: 30_000, output: 1_000 } }), }) const inputModelRef = ModelV2.Ref.make({ id: ModelV2.ID.make(inputModel.id), @@ -110,7 +119,18 @@ it.effect("does not count image attachments as text context", () => ] const request = LLM.request({ model: inputModel, - messages: toLLMMessages(messages, inputModelRef), + messages: [ + ...toLLMMessages(messages, inputModelRef), + Message.assistant( + ToolResultPart.make({ + id: "image_generation_1", + name: "image_generation", + result: { type: "image_generation_call", output: Buffer.alloc(64 * 1024).toString("base64") }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "image_generation_1" } }, + }), + ), + ], }) expect(request.messages.flatMap((message) => message.content)).toContainEqual({