diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index ac9ed1348ed..aa48bf5810d 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -192,6 +192,8 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens" +export type ProviderCompaction = { mode: "local" | "provider" } + export type ModelCapabilities = { tools: boolean input: Array @@ -210,18 +212,6 @@ export type MoneyUSDPerMillionTokens = number export type GenerateTextResponse = { data: { text: string } } -export type ProviderInfo = { - id: string - canonical?: string - integrationID?: string - name: string - activation: "auto" | "enabled" | "disabled" - package: string - settings?: { [x: string]: any } - headers?: { [x: string]: string } - body?: { [x: string]: any } -} - export type FormWhen = { key: string op: "eq" | "neq" @@ -1362,6 +1352,19 @@ export type ModelCompatibility = { requireAssistantAfterTool?: boolean } +export type ProviderInfo = { + id: string + canonical?: string + integrationID?: string + name: string + activation: "auto" | "enabled" | "disabled" + package: string + compaction?: ProviderCompaction + settings?: { [x: string]: any } + headers?: { [x: string]: string } + body?: { [x: string]: any } +} + export type ModelCost = { tier?: { type: "context"; size: number } input: MoneyUSDPerMillionTokens @@ -1832,6 +1835,7 @@ export type ModelInfo = { name: string compatibility?: ModelCompatibility package?: string + compaction?: ProviderCompaction settings?: { [x: string]: any } headers?: { [x: string]: string } body?: { [x: string]: any } @@ -2007,6 +2011,7 @@ export type ConfigEntry = warming?: boolean | { prompt?: string; interval?: string; duration?: string } providers?: { [x: string]: { + compaction?: ProviderCompaction canonical?: string name?: string env?: Array @@ -2016,6 +2021,7 @@ export type ConfigEntry = body?: { [x: string]: JsonValue } models?: { [x: string]: { + compaction?: ProviderCompaction modelID?: string family?: string name?: string diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index a69983759e9..3b00d35f4eb 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -76,6 +76,7 @@ const layer = Layer.effect( ...model, ...(provider.canonical === undefined ? {} : { canonical: provider.canonical }), package: model.package ?? provider.package, + compaction: model.compaction ?? provider.compaction, settings: Provider.mergeOverlay(provider.settings, model.settings), headers: Provider.mergeHeaders(provider.headers, model.headers), body: Provider.mergeOverlay(provider.body, model.body), diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 7c65e0b514e..1b2245e393f 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -57,6 +57,7 @@ export const Plugin = define({ if (item.canonical !== undefined) provider.canonical = item.canonical if (item.name !== undefined) provider.name = item.name if (item.package !== undefined) provider.package = item.package + if (item.compaction !== undefined) provider.compaction = { ...item.compaction } if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings) if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers) if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body) @@ -76,6 +77,7 @@ export const Plugin = define({ if (config.compatibility !== undefined) model.compatibility = { ...model.compatibility, ...config.compatibility } if (config.package !== undefined) model.package = config.package + if (config.compaction !== undefined) model.compaction = { ...config.compaction } if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings) if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers) if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body) diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index 2c0673d4107..d58023755d7 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -42,6 +42,7 @@ export const layer = Layer.effect( "SessionRunnerModel.VariantUnavailableError", "SessionRunnerModel.UnsupportedPackageError", "SessionRunnerModel.UnresolvedProviderVariablesError", + "SessionRunnerModel.UnsupportedCompactionError", ], (error) => { const mapped: Error = input.model diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index d791e371ee7..2894c4f8cde 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -52,10 +52,24 @@ export class UnresolvedProviderVariablesError extends Schema.TaggedError()( + "SessionRunnerModel.UnsupportedCompactionError", + { + providerID: Provider.ID, + modelID: ID, + route: Schema.String, + }, +) { + override get message() { + return `Provider compaction is not supported by ${this.providerID}/${this.modelID} (${this.route})` + } +} + export type Error = | VariantUnavailableError | UnsupportedPackageError | UnresolvedProviderVariablesError + | UnsupportedCompactionError | Integration.AuthorizationError export interface Resolved { @@ -69,6 +83,8 @@ export interface Resolved { readonly cost: Info["cost"] /** Catalog token limits used by Core for context management. */ readonly limit: Info["limit"] + /** Model policy overrides the provider policy; omitted means local compaction. */ + readonly compaction?: Info["compaction"] } export interface Interface { @@ -115,9 +131,20 @@ export const fromCatalogModel = ( model: Info, credential?: Credential.Value, dependencies?: Dependencies, -): Effect.Effect => +): Effect.Effect< + LanguageModel, + UnsupportedPackageError | UnresolvedProviderVariablesError | UnsupportedCompactionError +> => resolveCatalogModel(model, credential, dependencies).pipe( Effect.flatMap((resolved) => validateProviderVariables(model, resolved)), + Effect.flatMap((resolved) => { + // Reject provider compaction policies up front so the misconfiguration surfaces before any step runs. + if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint) + return Effect.succeed(resolved) + return Effect.fail( + new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }), + ) + }), ) const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* ( @@ -296,6 +323,7 @@ export const layer = Layer.effect( capabilities: selected.capabilities, cost: selected.cost, limit: selected.limit, + compaction: selected.compaction, } }) return Service.of({ diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index c297a3adda2..9c5e3b79d7c 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -263,6 +263,7 @@ export const OpenAIPlugin = define({ const account = chatgpt.metadata?.accountID item.provider.headers = Provider.mergeHeaders(item.provider.headers, { originator: "opencode", + "x-codex-beta-features": "remote_compaction_v2", ...(typeof account === "string" ? { "chatgpt-account-id": account } : {}), }) for (const model of item.models.values()) { diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 48856a004ed..097e35958bf 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -15,12 +15,15 @@ import { Agent } from "@opencode-ai/schema/agent" import { SessionError } from "@opencode-ai/schema/session-error" import { Context, Effect, Layer, Stream } from "effect" import { Bus } from "../bus.js" +import { Database } from "../database/database.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { llmClient } from "../effect/app-node-platform.js" import { SessionEvent } from "./event.js" import type { SessionContext } from "./context.js" +import { SessionHistory } from "./history.js" import type { SessionMessage } from "./message.js" import { SessionModelRequest } from "./model-request.js" +import { SessionProviderContext } from "./provider-context.js" import type { SessionRunnerModel } from "./runner/model.js" import { SessionRunnerRetry } from "./runner/retry.js" import { SessionSchema } from "./schema.js" @@ -199,6 +202,32 @@ const estimatePart = (part: ContentPart): number => { ) } +/** Keep whole, real user messages, never synthetic guidance or half an attachment/tool exchange. */ +export const retainUsers = ( + messages: readonly SessionMessage.Info[], + model: Pick, + keepTokens: number, +) => { + const users = SessionModelRequest.boundImages( + SessionModelRequest.unsupportedParts( + toLLMMessages( + messages.filter((message) => message.type === "user").map((message) => ({ ...message, skills: undefined })), + model.ref, + ), + model.capabilities, + ), + ) + let tokens = 0 + let start = users.length + for (let index = users.length - 1; index >= 0; index--) { + const size = users[index].content.reduce((sum, part) => sum + estimatePart(part), 0) + if (tokens + size > keepTokens) break + tokens += size + start = index + } + return users.slice(start) +} + export const truncateToolOutput = (value: string) => { if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value let end = 0 @@ -336,6 +365,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const bus = yield* Bus.Service const llm = yield* LLMClient.Service + const db = (yield* Database.Service).db const state = State.create({ name: "session-compaction", @@ -357,6 +387,132 @@ export const layer = Layer.effect( yield* bus.publish(SessionEvent.Compaction.Failed, input) return { status: "failed" as const, error: input.error } }) + const started = (input: ExecuteInput, recent: string) => + input.started + ? Effect.void + : bus.publish(SessionEvent.Compaction.Started, { + sessionID: input.context.session.id, + reason: input.reason, + recent, + inputID: input.inputID, + }) + // Manual controls settle through the inbox; only automatic work needs a durable interruption record. + const interrupted = (input: ExecuteInput) => + input.reason === "auto" + ? failed({ + sessionID: input.context.session.id, + reason: input.reason, + inputID: input.inputID, + error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, + }).pipe(Effect.asVoid) + : Effect.void + const compactionRequest = ( + input: ExecuteInput, + messages: readonly SessionMessage.Info[], + prompt: Message[], + webSocket?: "session", + ) => { + const context = input.context + const transcript = SessionModelRequest.baseTranscript({ + agent: context.agent.info, + model: context.model, + tools: context.tools, + initial: context.initial, + messages, + }) + return input.prepare({ + kind: "compaction", + scope: { + session: context.session, + agentID: Agent.ID.make("compaction"), + contextAgentID: context.agent.id, + model: context.model, + tools: context.tools, + }, + transcript: { + system: transcript.system, + messages: [ + ...transcript.messages, + ...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []), + ...prompt, + ], + }, + webSocket, + }) + } + /** The durable transcript since the last local summary, re-expanding every native window. */ + const original = (sessionID: SessionSchema.ID) => SessionHistory.load(db, sessionID, "local").pipe(Effect.orDie) + const executeProvider = Effect.fn("SessionCompaction.executeProvider")(function* (input: ExecuteInput) { + const context = input.context + const reject = (message: string) => + failed({ + sessionID: context.session.id, + reason: input.reason, + inputID: input.inputID, + error: { type: "provider.unsupported-operation", message }, + }) + const prepared = yield* compactionRequest(input, context.messages, [], "session") + const request = prepared.request + const provenance = SessionProviderContext.provenance(context.model) + if (!provenance) return yield* reject("Provider compaction requires a stable, configured endpoint") + // History is selected before request hooks. Until that interface can select on the final route, + // require routing in the catalog; never install a checkpoint that the next request would skip. + if ( + !SessionProviderContext.compatible( + provenance, + SessionProviderContext.provenance({ model: request.model, ref: context.model.ref }), + ) + ) + return yield* reject( + "Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite", + ) + yield* started(input, "") + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + // One physical attempt, with no local-summary fallback or provider-error retry. + const result = yield* restore( + Effect.gen(function* () { + if (LLMClient.canCompact(request, { mechanism: "trigger" })) { + const retained = retainUsers(yield* original(context.session.id), context.model, state.get().tokens) + const result = yield* llm.compact(request, { ...prepared.options, mechanism: "trigger" }) + return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage } + } + if (LLMClient.canCompact(request)) + return yield* llm.compact(request, { mechanism: "endpoint", http: prepared.options.http }) + // Model resolution admits provider policies only for routes with a compaction operation. + return yield* Effect.die( + new Error(`${request.model.provider}/${request.model.route.id} has no compaction operation`), + ) + }), + ) + if (result.usage) + yield* bus.publish(SessionEvent.UsageRecorded, { + sessionID: context.session.id, + source: "compaction" as const, + ...SessionUsage.record(result.usage, context.model.cost), + }) + yield* bus.publish(SessionEvent.Compaction.Ended, { + sessionID: context.session.id, + reason: input.reason, + model: context.model.ref, + text: "", + recent: "", + providerContext: SessionProviderContext.encode(provenance, result.replacement), + }) + return { status: "completed" as const } + }), + ).pipe( + Effect.onInterrupt(() => interrupted(input)), + Effect.catchTag("AI.Error", (cause) => + failed({ + sessionID: context.session.id, + reason: input.reason, + inputID: input.inputID, + error: toSessionError(cause), + }), + ), + ) + }) const execute = Effect.fn("SessionCompaction.execute")(function* (input: ExecuteInput) { const context = input.context const history = splitHistory(context.messages, state.get().tokens) @@ -367,13 +523,7 @@ export const layer = Layer.effect( error: { type: "compaction.unavailable", message: "Nothing to compact yet" }, inputID: input.inputID, }) - if (!input.started) - yield* bus.publish(SessionEvent.Compaction.Started, { - sessionID: context.session.id, - reason: input.reason, - recent: history.recent, - inputID: input.inputID, - }) + yield* started(input, history.recent) const chunks: string[] = [] let failure: SessionError.Error | undefined @@ -388,35 +538,13 @@ export const layer = Layer.effect( }) : Effect.void, ) - const transcript = SessionModelRequest.baseTranscript({ - agent: context.agent.info, - model: context.model, - tools: context.tools, - initial: context.initial, - messages: history.messages, - }) - const prepared = yield* input.prepare({ - kind: "compaction", - scope: { - session: context.session, - agentID: Agent.ID.make("compaction"), - contextAgentID: context.agent.id, - model: context.model, - tools: context.tools, - }, - transcript: { - system: transcript.system, - messages: [ - ...transcript.messages, - ...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []), - Message.user( - buildPrompt( - history.messages.some((message) => message.type === "compaction" && message.status === "completed"), - ), - ), - ], - }, - }) + const prepared = yield* compactionRequest(input, history.messages, [ + Message.user( + buildPrompt( + history.messages.some((message) => message.type === "compaction" && message.status === "completed"), + ), + ), + ]) const retry = yield* SessionRunnerRetry.policy(context.session.id) // Both requests share the retry allowance; rejected output never enters the reminder request. for (const request of [ @@ -501,20 +629,7 @@ export const layer = Layer.effect( failure = toSessionError(error) }), ), - Effect.onInterrupt(() => - recordUsage.pipe( - Effect.andThen( - input.reason === "auto" - ? failed({ - sessionID: context.session.id, - reason: input.reason, - error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, - inputID: input.inputID, - }).pipe(Effect.asVoid) - : Effect.void, - ), - ), - ), + Effect.onInterrupt(() => recordUsage.pipe(Effect.andThen(interrupted(input)))), ) if (failure || hasSummarySection(chunks.join(""))) break } @@ -578,15 +693,17 @@ export const layer = Layer.effect( error: toSessionError(cause), inputID: input.inputID, }), - onSuccess: (context) => - execute({ + onSuccess: (context) => { + const request = { context, instructionUpdate: context.instructionUpdate, prepare: input.prepare, - reason: "manual", + reason: "manual" as const, inputID: input.inputID, started: input.started, - }), + } + return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request) + }, }), ) }) @@ -604,5 +721,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, llmClient], + deps: [Bus.node, Database.node, llmClient], }) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 5cb6a5c6009..2afc1af49e2 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -35,6 +35,8 @@ export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError +export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError +export type UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error export type Resolved = ModelResolver.Resolved @@ -57,6 +59,7 @@ export const resolved = ( readonly variant?: Model.VariantID readonly cost: Model.Info["cost"] readonly limit: Model.Info["limit"] + readonly compaction?: Provider.Compaction }, ): Resolved => ({ model, @@ -68,6 +71,7 @@ export const resolved = ( capabilities: options.capabilities, cost: options.cost, limit: options.limit, + compaction: options.compaction, }) const layer = Layer.effect( diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index 90a1f4ed900..bcbc9626e01 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -46,6 +46,8 @@ export function toSessionError(cause: unknown): SessionError.Error { return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped } if (cause instanceof StepFailedError) return cause.error + if (cause instanceof SessionRunnerModel.UnsupportedCompactionError) + return { type: "provider.unsupported-operation", message: cause.message } if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message } if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message } if ( diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 3db83374b5c..ac7383d0d20 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -32,6 +32,45 @@ function required(value: T | undefined): T { const decode = Schema.decodeUnknownSync(Info) describe("ConfigProviderPlugin.Plugin", () => { + it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* addPlugin([ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "@opencode-ai/ai/providers/openai/responses", + compaction: { mode: "provider" }, + models: { + native: {}, + local: { compaction: { mode: "local" }, package: "@opencode-ai/ai/providers/openai/chat" }, + unsupported: { package: "@opencode-ai/ai/providers/openai/chat" }, + }, + }, + default: { package: "@opencode-ai/ai/providers/openai/chat", models: { chat: {} } }, + }, + }), + }), + ]) + const native = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("native"))) + const local = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("local"))) + const unsupported = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("unsupported"))) + const defaultModel = required(yield* catalog.model.get(Provider.ID.make("default"), Model.ID.make("chat"))) + expect(native.compaction).toEqual({ mode: "provider" }) + expect(local.compaction).toEqual({ mode: "local" }) + expect(defaultModel.compaction).toBeUndefined() + yield* ModelResolver.fromCatalogModel(native) + yield* ModelResolver.fromCatalogModel(local) + yield* ModelResolver.fromCatalogModel(defaultModel) + expect(yield* ModelResolver.fromCatalogModel(unsupported).pipe(Effect.flip)).toMatchObject({ + _tag: "SessionRunnerModel.UnsupportedCompactionError", + message: "Provider compaction is not supported by custom/unsupported (openai-chat)", + }) + }), + ) + it.effect("adds key auth for custom providers without env credentials", () => Effect.gen(function* () { const integrations = yield* Integration.Service diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index d6f4bfa32e4..a31f786e57b 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -145,7 +145,11 @@ describe("OpenAIPlugin", () => { const provider = required(yield* catalog.provider.get(Provider.ID.openai)) expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai")) expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" }) - expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" }) + expect(provider.headers).toMatchObject({ + originator: "opencode", + "chatgpt-account-id": "acct_123", + "x-codex-beta-features": "remote_compaction_v2", + }) expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex") expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) expect(direct.hasHttpHooks).toBe(false) @@ -206,6 +210,8 @@ describe("OpenAIPlugin", () => { expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) expect(model.capabilities.responsesWebsockets).toBe(true) expect(direct.headers).not.toHaveProperty("originator") + expect(direct.baseURL).toBe("https://api.openai.com/v1") + expect(provider.headers).not.toHaveProperty("x-codex-beta-features") expect(direct.hasHttpHooks).toBe(false) expect(provider.headers).not.toHaveProperty("originator") expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true) diff --git a/packages/core/test/session-native-compaction.test.ts b/packages/core/test/session-native-compaction.test.ts new file mode 100644 index 00000000000..0b21f85815a --- /dev/null +++ b/packages/core/test/session-native-compaction.test.ts @@ -0,0 +1,336 @@ +import { expect, test } from "bun:test" +import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode-ai/ai" +import { OpenAI } from "@opencode-ai/ai/providers" +import { Agent } from "@opencode-ai/core/agent" +import { Bus } from "@opencode-ai/core/bus" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { llmClient } from "@opencode-ai/core/effect/app-node-platform" +import { Instructions } from "@opencode-ai/core/instructions/index" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionCompaction } from "@opencode-ai/core/session/compaction" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionHistory } from "@opencode-ai/core/session/history" +import { SessionInbox } from "@opencode-ai/core/session/inbox" +import { InstructionState } from "@opencode-ai/core/session/instruction-state" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionModelRequest } from "@opencode-ai/core/session/model-request" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionProviderContext } from "@opencode-ai/core/session/provider-context" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { SessionStore } from "@opencode-ai/core/session/store" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { DateTime, Deferred, Effect, Fiber, Schema } from "effect" +import { testEffect } from "./lib/effect" + +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + Bus.node, + SessionProjector.node, + SessionInbox.node, + SessionStore.node, + SessionCompaction.node, + SessionModelRequest.node, + PluginHooks.node, + llmClient, + ]), + [Bus.node.replace(Bus.configured({ persist: true }))], + ), +) + +const setup = Effect.fnUntraced(function* (endpoint = false) { + const db = (yield* Database.Service).db + const bus = yield* Bus.Service + const inbox = yield* SessionInbox.Service + const store = yield* SessionStore.Service + const compaction = yield* SessionCompaction.Service + const requests = yield* SessionModelRequest.Service + const hooks = yield* PluginHooks.Service + const blocked = Deferred.makeUnsafe() + const hanging = Promise.withResolvers() + const state = { failure: false, hang: false, calls: 0 } + const bodies: Record[] = [] + const headers: Headers[] = [] + const server = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + state.calls++ + headers.push(request.headers) + bodies.push( + Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)))( + await request.text(), + ), + ) + if (state.hang) { + Deferred.doneUnsafe(blocked, Effect.void) + return hanging.promise + } + if (state.failure) + return Response.json( + { error: { message: "fixture rate limit", type: "rate_limit_error" } }, + { status: 429 }, + ) + const checkpoint = { + type: "compaction", + id: `cmp_${state.calls}`, + encrypted_content: `encrypted_${state.calls}`, + } + if (new URL(request.url).pathname.endsWith("/compact")) + return Response.json({ + id: "compact_endpoint", + object: "response.compaction", + output: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "endpoint retained" }] }, + checkpoint, + ], + usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 }, + }) + const output = JSON.stringify(bodies.at(-1)).includes("compaction_trigger") ? [checkpoint] : [] + return new Response( + `data: ${JSON.stringify({ + type: "response.completed", + response: { + id: `resp_${state.calls}`, + status: "completed", + output, + usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 }, + }, + })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }), + ), + (server) => + Effect.sync(() => { + hanging.resolve(new Response("cancelled")) + void server.stop(true) + }), + ) + const native = OpenAI.configure({ apiKey: "fixture", baseURL: server.url.toString() }).responses("gpt-5.4-mini") + const model = SessionRunnerModel.resolved( + endpoint + ? LanguageModel.update(native, { + route: native.route.with({ compact: { endpoint: native.route.compact.endpoint } }), + }) + : native, + { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [], + limit: { context: 200_000, output: 32_000 }, + compaction: { mode: "provider" }, + }, + ) + const sessionID = SessionSchema.ID.create() + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* bus.publish(SessionEvent.Created, { + sessionID, + projectID: Project.ID.global, + location: { directory: AbsolutePath.make("/project") }, + slug: "native-compaction", + version: "test", + }) + const session = yield* store.get(sessionID) + if (!session) return yield* Effect.die("Missing fixture session") + const instructions = Instructions.make({ + key: Instructions.Key.make("test/native"), + codec: Schema.toCodecJson(Schema.String), + read: Effect.succeed("Current instructions"), + render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" }, + }) + yield* InstructionState.prepare(db, bus, instructions, sessionID) + yield* hooks.register("session", "model.request", (event) => + Effect.sync(() => { + event.headers["x-test-hook"] = event.kind + }), + ) + yield* hooks.register("session", "http.request", (event) => + Effect.sync(() => event.request.headers.set("x-http-hook", event.kind)), + ) + const prompt = Effect.fnUntraced(function* (text: string, synthetic = false) { + const id = SessionMessage.ID.create() + yield* inbox.admit({ + id, + sessionID, + item: { type: synthetic ? "synthetic" : "user", payload: { text }, delivery: "steer" }, + }) + yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id }) + }) + const load = Effect.gen(function* () { + const history = yield* SessionHistory.preview( + db, + sessionID, + instructions, + SessionProviderContext.provenance(model) ?? "local", + ) + return { + session, + model, + initial: history.initial, + messages: history.messages, + instructionUpdate: history.instructionUpdate, + agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) }, + tools: { + definitions: [ + ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }), + ], + execute: () => Effect.die("Compaction must never dispatch tools"), + }, + } + }) + const compact = Effect.gen(function* () { + return yield* compaction.compactManual({ + session, + messages: yield* store.context(sessionID), + inputID: SessionMessage.ID.create(), + resolveContext: () => load, + prepare: requests.prepare, + }) + }) + const checkpoint = Effect.gen(function* () { + const messages = (yield* load).messages + const last = messages.findLast((message) => message.type === "compaction" && message.status === "completed") + if (last?.type !== "compaction" || last.status !== "completed" || !last.providerContext) + return yield* Effect.die("Missing native checkpoint") + expect(last.summary).toBe("") + expect(last.recent).toBe("") + return last.providerContext + }) + return { + compact, + checkpoint, + prompt, + load, + requests, + bodies, + headers, + state, + blocked, + sessionID, + store, + hooks, + model, + } +}) + +it.live( + "manual trigger persists and continues, retains earlier users repeatedly, and preserves context on failure/cancellation", + () => + Effect.gen(function* () { + const fixture = yield* setup() + yield* fixture.prompt("First real user request") + yield* fixture.prompt("Synthetic context, not a user request", true) + expect(yield* fixture.compact).toEqual({ status: "completed" }) + const first = yield* fixture.checkpoint + expect(SessionProviderContext.decode(first).map((message) => message.role)).toEqual(["user", "assistant"]) + expect(JSON.stringify(first.messages)).not.toContain("Synthetic context") + expect(fixture.bodies[0]).toMatchObject({ + input: expect.arrayContaining([{ type: "compaction_trigger" }]), + tools: [expect.objectContaining({ name: "read" })], + }) + expect(fixture.bodies[0]).not.toHaveProperty("context_management") + expect(fixture.headers[0]?.get("x-test-hook")).toBe("compaction") + expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction") + yield* fixture.prompt("Second real user request") + const context = yield* fixture.load + const prepared = yield* fixture.requests.prepare({ + kind: "primary", + scope: { session: context.session, model: context.model, agentID: context.agent.id, tools: context.tools }, + transcript: SessionModelRequest.baseTranscript({ ...context, agent: context.agent.info }), + }) + const client = yield* LLMClient.Service + yield* client.generate(prepared.request, prepared.options) + expect(JSON.stringify(fixture.bodies[1])).toContain("encrypted_1") + expect(JSON.stringify(fixture.bodies[1])).toContain("Current instructions") + expect(JSON.stringify(fixture.bodies[1])).toContain("Second real user request") + expect(yield* fixture.compact).toEqual({ status: "completed" }) + const second = yield* fixture.checkpoint + expect( + SessionProviderContext.decode(second) + .filter((message) => message.role === "user") + .map((message) => message.content), + ).toEqual([[Message.text("First real user request")], [Message.text("Second real user request")]]) + expect(JSON.stringify(second.messages)).not.toContain("encrypted_1") + expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 40, output: 8 } }) + // Nothing new since the checkpoint is not compactable, exactly like a fresh local summary. + expect(yield* fixture.compact).toMatchObject({ status: "failed", error: { type: "compaction.unavailable" } }) + yield* fixture.prompt("Third real user request") + fixture.state.failure = true + expect(yield* fixture.compact).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } }) + expect(fixture.state.calls).toBe(4) + expect(yield* fixture.checkpoint).toEqual(second) + fixture.state.hang = true + const pending = yield* fixture.compact.pipe(Effect.forkScoped) + yield* Deferred.await(fixture.blocked) + yield* Fiber.interrupt(pending) + expect(fixture.state.calls).toBe(5) + expect(yield* fixture.checkpoint).toEqual(second) + }), + 15000, +) + +it.live("endpoint-only compaction keeps the provider replacement unchanged", () => + Effect.gen(function* () { + const fixture = yield* setup(true) + yield* fixture.prompt("Original user") + expect(yield* fixture.compact).toEqual({ status: "completed" }) + const replacement = SessionProviderContext.decode(yield* fixture.checkpoint) + expect(replacement[0]?.content).toEqual([Message.text("endpoint retained")]) + expect(JSON.stringify(replacement)).not.toContain("Original user") + expect(fixture.state.calls).toBe(1) + expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction") + expect(fixture.bodies[0]).not.toHaveProperty("context_management") + }), +) + +it.live("rejects request-hook route rewrites before provider compaction", () => + Effect.gen(function* () { + const fixture = yield* setup() + yield* fixture.prompt("Original user") + yield* fixture.hooks.register("session", "model.request", (event) => + Effect.sync(() => { + event.baseURL = "https://another.example/v1" + }), + ) + expect(yield* fixture.compact).toMatchObject({ + status: "failed", + error: { type: "provider.unsupported-operation" }, + }) + expect(fixture.state.calls).toBe(0) + }), +) + +test("retained user budget counts attachments and drops whole oldest messages", () => { + const model = SessionRunnerModel.resolved(OpenAI.responses("gpt-5.4-mini"), { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [], + limit: { context: 200_000, output: 32_000 }, + }) + const user = (text: string) => + SessionMessage.User.make({ + id: SessionMessage.ID.create(), + type: "user", + text, + time: { created: DateTime.makeUnsafe(0) }, + }) + const newest = { + ...user("x".repeat(63_000 * 4)), + files: [{ mime: "image/png", data: "aGVsbG8=", source: { type: "inline" as const } }], + } + expect(SessionCompaction.retainUsers([user("old"), newest], model, 64_000)).toEqual([]) + expect( + SessionCompaction.retainUsers([user("x".repeat(63_000 * 4)), { ...newest, text: "new" }], model, 64_000), + ).toHaveLength(1) +}) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 57b5435e8fd..c6e47f1ea97 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -14384,6 +14384,9 @@ "Config.ModelEncoded": { "type": "object", "properties": { + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "modelID": { "type": "string" }, @@ -14489,6 +14492,9 @@ "Config.ProviderEncoded": { "type": "object", "properties": { + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "canonical": { "type": "string" }, @@ -16432,6 +16438,9 @@ "package": { "type": "string" }, + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "settings": { "type": "object" }, @@ -17365,6 +17374,17 @@ "required": ["id"], "additionalProperties": false }, + "Provider.Compaction": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["local", "provider"] + } + }, + "required": ["mode"], + "additionalProperties": false + }, "Provider.Info": { "type": "object", "properties": { @@ -17387,6 +17407,9 @@ "package": { "type": "string" }, + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "settings": { "type": "object" }, diff --git a/packages/schema/src/config/provider.ts b/packages/schema/src/config/provider.ts index c90482e6ed4..56b1db48db5 100644 --- a/packages/schema/src/config/provider.ts +++ b/packages/schema/src/config/provider.ts @@ -41,6 +41,7 @@ class Limit extends Schema.Class("Config.Model.Limit")({ }) {} class Model extends Schema.Class("Config.Model")({ + compaction: Provider.Compaction.pipe(optional), modelID: ID.pipe(optional), family: Family.pipe(optional), name: Schema.String.pipe(optional), @@ -58,6 +59,7 @@ class Model extends Schema.Class("Config.Model")({ }) {} export class Info extends Schema.Class("Config.Provider")({ + compaction: Provider.Compaction.pipe(optional), canonical: Provider.ID.pipe(optional), name: Schema.String.pipe(optional), env: Schema.String.pipe(Schema.Array, optional), diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index efe09fa44b7..50abfa7ac7d 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -106,6 +106,7 @@ export const Info = Schema.Struct({ name: Schema.String, compatibility: Compatibility.pipe(optional), package: Provider.Package.pipe(optional), + compaction: Provider.Compaction.pipe(optional), ...Provider.Overlays, capabilities: Capabilities, variants: Schema.Array(Variant), diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index 11a8ef414b3..84780c0a25e 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -28,6 +28,11 @@ export type Package = typeof Package.Type export const Activation = Schema.Literals(["auto", "enabled", "disabled"]) export type Activation = typeof Activation.Type +export interface Compaction extends Schema.Schema.Type {} +export const Compaction = Schema.Struct({ + mode: Schema.Literals(["local", "provider"]), +}).annotate({ identifier: "Provider.Compaction" }) + export const Overlays = { settings: Schema.Record(Schema.String, Schema.Any).pipe(optional), headers: Schema.Record(Schema.String, Schema.String).pipe(optional), @@ -52,6 +57,7 @@ export const Info = Schema.Struct({ name: Schema.String, activation: Activation, package: Package, + compaction: Compaction.pipe(optional), ...Overlays, }) .annotate({ identifier: "Provider.Info" }) diff --git a/packages/schema/test/model.test.ts b/packages/schema/test/model.test.ts index 97d075563c0..94dbf3014ed 100644 --- a/packages/schema/test/model.test.ts +++ b/packages/schema/test/model.test.ts @@ -56,6 +56,16 @@ describe("Model.Compatibility", () => { }) describe("Model.Info", () => { + test("provider compaction policy is optional and uses the canonical closed schema", () => { + const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini")) + expect(Schema.encodeSync(Model.Info)({ ...model, compaction: undefined })).not.toHaveProperty("compaction") + expect(Schema.decodeUnknownSync(Model.Info)({ ...model, compaction: { mode: "provider" } }).compaction).toEqual({ + mode: "provider", + }) + expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "local" })).toEqual({ mode: "local" }) + expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "automatic" })).toThrow() + }) + test("uses practical token limits for unknown models", () => { const model = Model.Info.default(Provider.ID.make("custom"), Model.ID.make("gpt-5.6")) diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index 88bdc5f658e..397d8abf3e1 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -388,7 +388,13 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact return (
- +
diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index a96aa6a8a18..517c3bac71f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2113,7 +2113,11 @@ function CompactionMessage(props: { message: Extract✗ - Compaction + + {props.message.status === "completed" && props.message.providerContext + ? "Provider compaction" + : "Compaction"} + · cancelled diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 7a40e1f1d86..24e180694ac 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -104,6 +104,7 @@ const source = { "ui.messagePart.review.title": "Review your answers", "ui.messagePart.questions.dismissed": "Questions dismissed", "ui.messagePart.compaction": "Session compacted", + "ui.messagePart.providerCompaction": "Session compacted by provider", "ui.messagePart.context.details": "Details", "ui.messagePart.context.read.one": "{{count}} read", "ui.messagePart.context.read.other": "{{count}} reads", diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 57b5435e8fd..c6e47f1ea97 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -14384,6 +14384,9 @@ "Config.ModelEncoded": { "type": "object", "properties": { + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "modelID": { "type": "string" }, @@ -14489,6 +14492,9 @@ "Config.ProviderEncoded": { "type": "object", "properties": { + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "canonical": { "type": "string" }, @@ -16432,6 +16438,9 @@ "package": { "type": "string" }, + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "settings": { "type": "object" }, @@ -17365,6 +17374,17 @@ "required": ["id"], "additionalProperties": false }, + "Provider.Compaction": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["local", "provider"] + } + }, + "required": ["mode"], + "additionalProperties": false + }, "Provider.Info": { "type": "object", "properties": { @@ -17387,6 +17407,9 @@ "package": { "type": "string" }, + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "settings": { "type": "object" }, diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 57b5435e8fd..c6e47f1ea97 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -14384,6 +14384,9 @@ "Config.ModelEncoded": { "type": "object", "properties": { + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "modelID": { "type": "string" }, @@ -14489,6 +14492,9 @@ "Config.ProviderEncoded": { "type": "object", "properties": { + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "canonical": { "type": "string" }, @@ -16432,6 +16438,9 @@ "package": { "type": "string" }, + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "settings": { "type": "object" }, @@ -17365,6 +17374,17 @@ "required": ["id"], "additionalProperties": false }, + "Provider.Compaction": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["local", "provider"] + } + }, + "required": ["mode"], + "additionalProperties": false + }, "Provider.Info": { "type": "object", "properties": { @@ -17387,6 +17407,9 @@ "package": { "type": "string" }, + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, "settings": { "type": "object" }, diff --git a/packages/www/src/docs/content/compaction.mdx b/packages/www/src/docs/content/compaction.mdx index 31a7bb20fcc..b0ecce4a3d7 100644 --- a/packages/www/src/docs/content/compaction.mdx +++ b/packages/www/src/docs/content/compaction.mdx @@ -75,7 +75,44 @@ Add `compaction` to any [OpenCode configuration file](/config): preserves more recent detail but leaves less room for future work. Larger `buffer` triggers preflight compaction earlier. -## Checkpoint contents +## Provider compaction (manual) + +By default, compaction generates a local text summary. To use the selected +provider's native compaction operation for manual requests, set a provider policy. +An individual model's policy overrides it: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "providers": { + "openai": { + "compaction": { "mode": "provider" }, + "models": { + "gpt-4.1": { "compaction": { "mode": "local" } }, + }, + }, + }, +} +``` + +- Automatic and overflow compaction still use local summaries. This policy only + changes manual compaction; it does not enable in-band provider context management. +- OpenAI Responses uses a streamed compaction trigger when the route supports it. + Endpoint-only routes use their standalone compaction endpoint. Deployment/model + support can vary; provider errors are returned without a retry or local fallback. +- Unsupported routes are rejected during model resolution. Configure custom + endpoints through provider/model `settings.baseURL`, not a `model.request` hook; + native compaction rejects endpoint rewrites by that hook. +- Trigger checkpoints retain whole, real user messages and attachments up to the + `compaction.tokens` budget, including users retained across earlier native + compactions. Synthetic guidance is not retained as user input. Endpoint results + are stored as the provider returned them. Neither path fabricates a text summary. +- Successful native checkpoints advance the instruction epoch and are replayed + only with a matching provider, model, protocol, and endpoint. Switching to an + incompatible route reuses an earlier compatible checkpoint or retained transcript. + Disabling automatic compaction does not remove an installed checkpoint. + +## Local checkpoint contents V2 uses the session's selected agent, model, and variant to generate the summary. The request reuses the normal instructions, tool definitions, and structured