Compare commits

...
Author SHA1 Message Date
Ryan Vogel 6f70369a34 feat(core): opt models into provider compaction
Allow model configuration and existing plugin catalog transforms to select the provider compaction strategy. Keep summary compaction as the default. For opted-in models, replace text-only model context with the complete native replacement window using the existing AI compact API.

Persist the replacement in the normal checkpoint lifecycle, derive a readable UI and model-switch fallback from the compacted context, bind replay to catalog and runtime identities, and omit opaque payloads from sanitized exports. Preserve existing manual, automatic, and overflow scheduling.

Verified focused compaction/replay, configuration, fork/revert, export, client, and AI regression checks; Core, Schema, Protocol, Plugin, Server, and Client typechecks; regenerated clients; and a live synthetic compact/persist/replay/recall flow through the changed Core service. A 350k-token workload was not stress-tested.
2026-09-03 19:52:37 -04:00
23 changed files with 728 additions and 76 deletions
+4
View File
@@ -967,6 +967,10 @@ export type SessionLogOutput =
readonly reason: "auto" | "manual"
readonly model?: Model.Ref | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly replacement?: ReadonlyArray<Schema.Json> | undefined
readonly replacementModel?:
| { readonly provider: string; readonly id: string; readonly route: string }
| undefined
readonly text: string
readonly recent: string
}
@@ -179,6 +179,8 @@ export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: nu
export type SessionInterruptResponse = { interrupted: boolean }
export type ModelCompaction = "summary" | "provider"
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
@@ -519,6 +521,8 @@ export type SessionMessageCompactionCompleted = {
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
replacement?: Array<JsonValue>
replacementModel?: { provider: string; id: string; route: string }
summary: string
recent: string
}
@@ -1355,6 +1359,8 @@ export type SessionCompactionEnded = {
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
replacement?: Array<JsonValue>
replacementModel?: { provider: string; id: string; route: string }
text: string
recent: string
}
@@ -1820,6 +1826,7 @@ export type ModelInfo = {
canonical?: string
family?: string
name: string
compaction?: ModelCompaction
compatibility?: ModelCompatibility
package?: string
settings?: { [x: string]: any }
@@ -2008,6 +2015,7 @@ export type ConfigEntry =
modelID?: string
family?: string
name?: string
compaction?: ModelCompaction
compatibility?: ModelCompatibility
package?: string
settings?: { [x: string]: JsonValue }
@@ -3075,6 +3083,8 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly replacement?: ReadonlyArray<JsonValue>
readonly replacementModel?: { readonly provider: string; readonly id: string; readonly route: string }
readonly summary: string
readonly recent: string
}
@@ -3354,6 +3364,8 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly replacement?: ReadonlyArray<JsonValue>
readonly replacementModel?: { readonly provider: string; readonly id: string; readonly route: string }
readonly summary: string
readonly recent: string
}
@@ -3633,6 +3645,8 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly replacement?: ReadonlyArray<JsonValue>
readonly replacementModel?: { readonly provider: string; readonly id: string; readonly route: string }
readonly summary: string
readonly recent: string
}
+4
View File
@@ -1040,6 +1040,8 @@ export function createData(config: CreateDataInput) {
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
replacement: event.data.replacement,
replacementModel: event.data.replacementModel,
summary: event.data.text,
recent: event.data.recent,
})
@@ -1052,6 +1054,8 @@ export function createData(config: CreateDataInput) {
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
replacement: event.data.replacement,
replacementModel: event.data.replacementModel,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.created },
+23 -2
View File
@@ -100,13 +100,34 @@ test.each(["started", "cancelled", "failed"])(
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
const model = { providerID: "demo", id: "model" }
const providerState = { responseId: "summary-response" }
const replacementModel = { provider: "openai", id: "upstream-model", route: "openai-responses" }
const replacement = [
{ role: "assistant", content: [{ type: "compaction", provider: "demo", encrypted: "opaque" }] },
]
fixture.emit({
...event,
type: "session.compaction.ended",
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" },
data: {
sessionID,
reason: "manual",
model,
providerState,
replacement,
replacementModel,
text: "Summary",
recent: "Recent",
},
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
{
type: "compaction",
status: "completed",
summary: "Summary",
model,
providerState,
replacement,
replacementModel,
},
])
}
},
@@ -72,6 +72,7 @@ export const Plugin = define({
}
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.compaction !== undefined) model.compaction = config.compaction
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
+3
View File
@@ -69,6 +69,8 @@ export interface Resolved {
readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** When omitted, use OpenCode summary compaction. */
readonly compaction?: Info["compaction"]
}
export interface Interface {
@@ -296,6 +298,7 @@ export const layer = Layer.effect(
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
compaction: runtimeInfo.compaction,
}
})
return Service.of({
@@ -136,6 +136,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
Object.assign(model, structuredClone(base ?? model))
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.compaction !== undefined) model.compaction = config.compaction
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
+88 -31
View File
@@ -3,7 +3,7 @@ export * as SessionCompaction from "./compaction.js"
import { LLMClient, LLMEvent, LLMRequest, Message, type ContentPart } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Context, Effect, Layer, Result, Schema, Stream } from "effect"
import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
@@ -17,7 +17,7 @@ import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
import { SessionUsage } from "./usage.js"
import { State } from "../state.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
import { CompactionReplacement, toLLMMessages } from "./runner/to-llm-message.js"
import type { AgentNotFoundError } from "./error.js"
import type { Instructions } from "../instructions/index.js"
@@ -136,7 +136,7 @@ export const estimateTokens = (input: RequiredInput) => {
const last = input.messages[index]
// Keep the anchor's local tool results: they are not covered by its provider usage.
const added = SessionModelRequest.unsupportedParts(
toLLMMessages(input.messages.slice(Math.max(0, index)), input.resolved.ref),
toLLMMessages(input.messages.slice(Math.max(0, index)), input.resolved.ref, undefined, input.resolved.model),
input.resolved.capabilities,
)
.filter((message) => message.role !== "assistant" || message.id !== last?.id)
@@ -348,14 +348,17 @@ export const layer = Layer.effect(
})
const execute = Effect.fn("SessionCompaction.execute")(function* (input: ExecuteInput) {
const context = input.context
const history = splitHistory(context.messages, state.get().tokens)
if (!history)
const native = context.model.compaction === "provider"
const split = splitHistory(context.messages, state.get().tokens)
if (!split)
return yield* failed({
sessionID: context.session.id,
reason: input.reason,
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
// The provider returns the entire next window; do not prune it or append a second retained tail.
const history = native ? { messages: context.messages, recent: "" } : split
if (!input.started)
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: context.session.id,
@@ -368,6 +371,7 @@ export const layer = Layer.effect(
let failure: SessionError.Error | undefined
let usage: SessionUsage.Recorded | undefined
let providerState: SessionMessage.ProviderState | undefined
let replacement: SessionMessage.CompactionCompleted["replacement"]
const recordUsage = Effect.suspend(() =>
usage
? bus.publish(SessionEvent.UsageRecorded, {
@@ -377,6 +381,18 @@ export const layer = Layer.effect(
})
: Effect.void,
)
const interrupted = 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,
),
)
const transcript = SessionModelRequest.baseTranscript({
agent: context.agent.info,
model: context.model,
@@ -397,14 +413,62 @@ export const layer = Layer.effect(
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
),
...(native
? []
: [
Message.user(
buildPrompt(
history.messages.some(
(message) => message.type === "compaction" && message.status === "completed",
),
),
),
]),
],
},
})
let request = prepared.request
if (native) {
if (!LLMClient.canCompact(request))
return yield* failed({
sessionID: context.session.id,
reason: input.reason,
error: {
type: "compaction.unavailable",
message: "The selected model route does not support provider compaction",
},
inputID: input.inputID,
})
const result = yield* llm.compact(request, prepared.options).pipe(
Effect.result,
Effect.onInterrupt(() => interrupted),
)
if (Result.isFailure(result))
return yield* failed({
sessionID: context.session.id,
reason: input.reason,
error: toSessionError(result.failure),
inputID: input.inputID,
})
usage = SessionUsage.record(result.success.usage, context.model.cost)
const encoded = yield* Schema.encodeEffect(CompactionReplacement)(result.success.replacement).pipe(
Effect.result,
)
if (Result.isFailure(encoded)) {
yield* recordUsage
return yield* failed({
sessionID: context.session.id,
reason: input.reason,
error: { type: "compaction.failed", message: "Provider compaction returned an invalid replacement window" },
inputID: input.inputID,
})
}
replacement = encoded.success
// Keep the readable UI and a portable model-switch fallback, but resume from the provider window.
request = LLMRequest.update(request, {
messages: [...result.success.replacement, Message.user(buildPrompt(false))],
})
}
// Ignored tool calls never enter the follow-up history or need fabricated results.
for (let attempt = 0; attempt < 2; attempt++) {
chunks.length = 0
@@ -412,10 +476,10 @@ export const layer = Layer.effect(
yield* llm
.stream(
attempt === 0
? prepared.request
: LLMRequest.update(prepared.request, {
? request
: LLMRequest.update(request, {
messages: [
...prepared.request.messages,
...request.messages,
Message.user(
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
),
@@ -438,10 +502,12 @@ export const layer = Layer.effect(
})
}
if (LLMEvent.is.stepFinish(event)) {
providerState =
event.providerMetadata?.[
context.model.model.route.providerMetadataKey ?? context.model.model.provider
]
// The display summary is not part of a native checkpoint's continuation chain.
if (!native)
providerState =
event.providerMetadata?.[
context.model.model.route.providerMetadataKey ?? context.model.model.provider
]
const step = SessionUsage.record(event.usage, context.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
@@ -452,20 +518,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(() => interrupted),
)
if (failure || hasSummarySection(chunks.join(""))) break
}
@@ -490,6 +543,10 @@ export const layer = Layer.effect(
reason: input.reason,
model: context.model.ref,
providerState,
replacement,
replacementModel: replacement
? { provider: request.model.provider, id: request.model.id, route: request.model.route.id }
: undefined,
text: summary,
recent: history.recent,
})
@@ -412,6 +412,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
replacement: event.data.replacement,
replacementModel: event.data.replacementModel,
summary: event.data.text,
recent: event.data.recent,
})
@@ -426,6 +428,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
replacement: event.data.replacement,
replacementModel: event.data.replacementModel,
summary: event.data.text,
recent: event.data.recent,
time: { created },
+1 -1
View File
@@ -100,7 +100,7 @@ export const baseTranscript = (input: {
]
.filter((part) => part.length > 0)
.map(SystemPart.make),
messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey),
messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey, input.model.model),
}
}
@@ -57,6 +57,7 @@ export const resolved = (
readonly variant?: Model.VariantID
readonly cost: Model.Info["cost"]
readonly limit: Model.Info["limit"]
readonly compaction?: Model.Compaction
},
): Resolved => ({
model,
@@ -68,6 +69,7 @@ export const resolved = (
capabilities: options.capabilities,
cost: options.cost,
limit: options.limit,
compaction: options.compaction,
})
const layer = Layer.effect(
@@ -1,4 +1,11 @@
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import {
Message,
ToolCallPart,
ToolResultPart,
type ContentPart,
type LanguageModel,
type ProviderMetadata,
} from "@opencode-ai/ai"
import type { Model } from "@opencode-ai/schema/model"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
@@ -7,6 +14,13 @@ import type { FileAttachment } from "@opencode-ai/schema/prompt"
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
// The AI JSON codec also omits undefined values inside opaque provider metadata.
export const CompactionReplacement = Schema.fromJsonString(Schema.Array(Schema.Json)).pipe(
Schema.flip,
Schema.decodeTo(Schema.fromJsonString(Schema.Array(Message).check(Schema.isMinLength(1)))),
)
const decodeReplacement = Schema.decodeUnknownOption(CompactionReplacement)
const media = (file: FileAttachment): ContentPart => ({
type: "media",
mediaType: file.mime,
@@ -221,7 +235,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
]
}
function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMetadataKey: string): Message[] {
function toLLMMessage(
message: SessionMessage.Info,
model: Model.Ref,
providerMetadataKey: string,
runtime?: LanguageModel,
): Message[] {
switch (message.type) {
case "agent-switched":
case "model-switched":
@@ -274,6 +293,19 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
return assistant(message, model, providerMetadataKey)
case "compaction":
if (message.status !== "completed") return []
if (
message.replacement !== undefined &&
message.model?.providerID === model.providerID &&
message.model.id === model.id &&
runtime?.route.compact !== undefined &&
message.replacementModel?.provider === runtime.provider &&
message.replacementModel.id === runtime.id &&
message.replacementModel.route === runtime.route.id
) {
const replacement = decodeReplacement(message.replacement)
if (Option.isSome(replacement)) return [...replacement.value]
}
// Other models and unrecognized checkpoint formats use the portable summary.
return [
Message.make({
id: message.id,
@@ -300,4 +332,5 @@ export const toLLMMessages = (
messages: readonly SessionMessage.Info[],
model: Model.Ref,
providerMetadataKey: string = model.providerID,
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
runtime?: LanguageModel,
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey, runtime))
+4 -1
View File
@@ -298,7 +298,10 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
summary: redact("compaction-summary", message.id, message.summary),
recent: redact("compaction-recent", message.id, message.recent),
...(message.status === "completed"
? { providerState: metadata("compaction-provider-state", message.id, message.providerState) }
? {
providerState: metadata("compaction-provider-state", message.id, message.providerState),
replacement: undefined,
}
: {}),
}
}
@@ -83,6 +83,46 @@ describe("ConfigProviderPlugin.Plugin", () => {
const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] })
expect(model.limit).toEqual({ context: 200_000, output: 32_000 })
expect(model.compaction).toBeUndefined()
expect(Schema.encodeSync(Model.Info)(model)).not.toHaveProperty("compaction")
}),
)
for (const compaction of ["summary", "provider"] as const) {
it.effect(`configures ${compaction} compaction without changing other models`, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
yield* catalog.transform((editor) => {
for (const id of ["inherited", "overridden"])
editor.model.update(providerID, Model.ID.make(id), (model) => {
model.compaction = "provider"
})
})
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
custom: {
models: { inherited: {}, overridden: { compaction }, fresh: { compaction }, unchanged: {} },
},
},
}),
}),
])
expect((yield* catalog.model.get(providerID, Model.ID.make("inherited")))?.compaction).toBe("provider")
expect((yield* catalog.model.get(providerID, Model.ID.make("overridden")))?.compaction).toBe(compaction)
expect((yield* catalog.model.get(providerID, Model.ID.make("fresh")))?.compaction).toBe(compaction)
expect((yield* catalog.model.get(providerID, Model.ID.make("unchanged")))?.compaction).toBeUndefined()
}),
)
}
it.effect("rejects unknown model compaction strategies", () =>
Effect.sync(() => {
expect(() => decode({ providers: { custom: { models: { chat: { compaction: "unknown" } } } } })).toThrow()
}),
)
+22 -17
View File
@@ -363,24 +363,29 @@ describe("ModelResolver", () => {
return withConfigEnv({}, () =>
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
yield* Effect.forEach(selections, (selection) =>
Effect.gen(function* () {
const resolved = yield* resolver.resolveModel(selection)
const headers = yield* resolved.model.route.auth.apply({
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
method: "POST",
url: resolved.model.route.endpoint.baseURL ?? "",
body: "{}",
headers: Headers.fromInput(resolved.model.route.defaults.headers),
})
yield* Effect.forEach(
selections.flatMap((selection) =>
([undefined, "summary", "provider"] as const).map((compaction) => ({ ...selection, compaction })),
),
(selection) =>
Effect.gen(function* () {
const resolved = yield* resolver.resolveModel(selection)
const headers = yield* resolved.model.route.auth.apply({
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
method: "POST",
url: resolved.model.route.endpoint.baseURL ?? "",
body: "{}",
headers: Headers.fromInput(resolved.model.route.defaults.headers),
})
expect(resolved.limit).toEqual(selection.limit)
expect(resolved.ref.providerID).toBe(selection.providerID)
expect(String(resolved.model.provider)).toBe(selection.canonical ?? selection.providerID)
expect(headers["cf-access-token"]).toBe("access-token")
expect(headers.authorization).toBeUndefined()
expect(headers["x-goog-api-key"]).toBeUndefined()
}),
expect(resolved.limit).toEqual(selection.limit)
expect(resolved.compaction).toBe(selection.compaction)
expect(resolved.ref.providerID).toBe(selection.providerID)
expect(String(resolved.model.provider)).toBe(selection.canonical ?? selection.providerID)
expect(headers["cf-access-token"]).toBe("access-token")
expect(headers.authorization).toBeUndefined()
expect(headers["x-goog-api-key"]).toBeUndefined()
}),
)
}).pipe(Effect.provide(layer)),
)
@@ -227,6 +227,7 @@ describe("OpencodePlugin", () => {
modelID: "api-model",
name: "Remote Model",
family: "remote",
compaction: "provider",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
settings: {
apiKey: "model-secret",
@@ -320,6 +321,7 @@ describe("OpencodePlugin", () => {
canonical: "openai",
name: "Remote Model",
family: "remote",
compaction: "provider",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }],
limit: { context: 1000, output: 100 },
@@ -330,6 +332,7 @@ describe("OpencodePlugin", () => {
expect(model.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 })
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
expect(override.package).toBe(Provider.aisdk("@ai-sdk/anthropic"))
expect(override.compaction).toBeUndefined()
expect(override.settings?.baseURL).toBe(`${server.url.origin}/anthropic`)
expect(model.variants).toEqual([
{
+286 -15
View File
@@ -1,6 +1,18 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import {
AIError,
CompactionPart,
CompactionResponse,
InvalidProviderOutputError,
LLMEvent,
LanguageModel,
Message,
ToolDefinition,
Usage,
type LLMRequest,
} from "@opencode-ai/ai"
import { LLMClient } from "@opencode-ai/ai/route"
import { OpenAIChat, OpenAIResponses } from "@opencode-ai/ai/protocols"
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"
@@ -13,6 +25,7 @@ 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 { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { CompactionReplacement, toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Session } from "@opencode-ai/core/session"
@@ -27,6 +40,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { Skill } from "@opencode-ai/schema/skill"
import { Shell } from "@opencode-ai/schema/shell"
import { Base64, FileAttachment } from "@opencode-ai/schema/prompt"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -76,19 +90,21 @@ const resolved = SessionRunnerModel.resolved(model, {
cost,
limit: { context: 200_000, output: 32_000 },
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
SessionCompaction.node,
SessionModelRequest.node,
]),
[Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)],
),
)
const compactionTests = (layer: typeof client) =>
testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
SessionCompaction.node,
SessionModelRequest.node,
]),
[Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(layer)],
),
)
const it = compactionTests(client)
test("compaction prompt preserves detailed work state and relevant files", () => {
const prompt = SessionCompaction.buildPrompt(false)
@@ -491,3 +507,258 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
expect(requests[0]?.promptCacheKey).toBe(rootID)
}),
)
const nativeResolved = {
...resolved,
compaction: "provider" as const,
model: LanguageModel.make({ id: model.id, provider: model.provider, route: OpenAIResponses.route }),
}
const replacement = [
Message.make({
role: "user",
providerMetadata: { openai: { itemId: "retained_user", type: "message", status: "completed" } },
content: [
{ type: "text", text: "Retained request" },
{
type: "media",
data: "https://example.com/image.png",
mediaType: "image/png",
providerMetadata: { openai: { detail: "high" } },
},
],
}),
Message.assistant(
CompactionPart.make({ provider: model.provider, id: "cmp_native", encrypted: "opaque-checkpoint" }),
),
Message.make({
role: "assistant",
providerMetadata: { openai: { itemId: "retained_assistant", phase: "commentary" } },
content: [{ type: "text", text: "Retained response" }],
}),
]
const compacted = new CompactionResponse({
replacement,
usage: new Usage({ inputTokens: 100, nonCachedInputTokens: 100, outputTokens: 10, reasoningTokens: 2 }),
})
for (const reason of ["auto", "manual"] as const) {
const calls: Array<{ type: string; request: LLMRequest }> = []
compactionTests(
Layer.mock(LLMClient.Service)({
compact: (request) =>
Effect.sync(() => {
calls.push({ type: "compact", request })
return compacted
}),
stream: (request) => {
calls.push({ type: "summary", request })
return Stream.make(
LLMEvent.textDelta({ id: "summary", text: "## Objective\n- portable summary" }),
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
providerMetadata: { openai: { responseId: "display-only-response" } },
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({ reason: { normalized: "stop" } }),
)
},
}),
).effect(
`${reason} provider compaction preserves structured history and commits the whole replacement with combined usage`,
() =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const modelRequests = yield* SessionModelRequest.Service
const store = yield* SessionStore.Service
const session = yield* insertSession(Session.ID.make(`ses_native_${reason}`))
const messages = [
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Original request",
time: { created: DateTime.makeUnsafe(0) },
}),
SessionMessage.Assistant.make({
id: SessionMessage.ID.create(),
type: "assistant",
agent: Agent.defaultID,
model: nativeResolved.ref,
content: [
SessionMessage.AssistantTool.make({
type: "tool",
id: "read_image",
name: "read",
providerState: { itemId: "fc_read" },
state: {
status: "completed",
input: { path: "image.png" },
content: [
{ type: "text", text: "x".repeat(4_001) },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png" },
],
},
time: { created: DateTime.makeUnsafe(0), completed: DateTime.makeUnsafe(1) },
}),
],
time: { created: DateTime.makeUnsafe(0), completed: DateTime.makeUnsafe(1) },
}),
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Newest request",
files: [
FileAttachment.make({ data: Base64.make("aGVsbG8="), mime: "image/png", source: { type: "inline" } }),
],
time: { created: DateTime.makeUnsafe(2) },
}),
]
const context = {
...loaded(session, messages),
model: nativeResolved,
tools: {
definitions: [
ToolDefinition.make({ name: "read", description: "Read files", inputSchema: { type: "object" } }),
],
execute: () => Effect.die("Compaction must not execute tools"),
},
}
const outcome =
reason === "auto"
? yield* compaction.compact({ context, prepare: modelRequests.prepare })
: yield* compaction.compactManual({
session,
messages,
inputID: SessionMessage.ID.create(),
resolveContext: () => Effect.succeed(context),
prepare: modelRequests.prepare,
})
expect(outcome).toEqual({ status: "completed" })
expect(calls.map((call) => call.type)).toEqual(["compact", "summary"])
expect(calls[0]?.request.messages).toEqual(
toLLMMessages(messages, nativeResolved.ref, "openai", nativeResolved.model),
)
expect(calls[0]?.request.tools.map((tool) => tool.name)).toEqual(["read"])
expect(calls[0]?.request.system.some((part) => part.text.includes("Session instructions"))).toBe(true)
expect(calls[1]?.request.messages).toEqual([...replacement, Message.user(SessionCompaction.buildPrompt(false))])
const history = yield* store.context(session.id)
expect(history).toHaveLength(1)
expect(history[0]).toMatchObject({
type: "compaction",
status: "completed",
reason,
model: nativeResolved.ref,
summary: "## Objective\n- portable summary",
recent: "",
replacement: Schema.encodeSync(CompactionReplacement)(replacement),
})
expect(history[0]).not.toHaveProperty("providerState")
expect(toLLMMessages(history, nativeResolved.ref, "openai", nativeResolved.model)).toEqual(replacement)
const stored = yield* store.get(session.id)
expect(stored?.cost).toBeCloseTo(0.0001433, 10)
expect(stored?.tokens).toEqual({ input: 110, output: 12, reasoning: 4, cache: { read: 3, write: 2 } })
}),
)
}
for (const failure of ["unsupported", "compact", "summary"] as const) {
const calls: string[] = []
compactionTests(
Layer.mock(LLMClient.Service)({
compact: () =>
Effect.suspend(() => {
calls.push("compact")
return failure === "compact"
? Effect.fail(new AIError({ reason: new InvalidProviderOutputError({ message: "Compaction rejected" }) }))
: Effect.succeed(compacted)
}),
stream: () => {
calls.push("summary")
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "Not a checkpoint template" }))
},
}),
).effect(`provider ${failure} failure retains the prior checkpoint without committing a replacement`, () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const modelRequests = yield* SessionModelRequest.Service
const store = yield* SessionStore.Service
const bus = yield* Bus.Service
const session = yield* insertSession(Session.ID.make(`ses_native_failure_${failure}`))
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: session.id,
reason: "auto",
text: "## Objective\n- prior summary",
recent: "",
})
yield* bus.publish(SessionEvent.Synthetic, { sessionID: session.id, text: "New work must survive failure" })
const previous = yield* store.context(session.id)
const outcome = yield* compaction.compact({
context: {
...loaded(session, previous),
model: failure === "unsupported" ? { ...resolved, compaction: "provider" } : nativeResolved,
},
prepare: modelRequests.prepare,
})
expect(outcome.status).toBe("failed")
expect(calls).toEqual(
failure === "unsupported" ? [] : failure === "compact" ? ["compact"] : ["compact", "summary", "summary"],
)
const history = yield* store.context(session.id)
expect(history.slice(0, previous.length)).toEqual(previous)
expect(history.at(-1)).toMatchObject({ type: "compaction", status: "failed" })
expect(history.filter((message) => message.type === "compaction" && message.status === "completed")).toHaveLength(
1,
)
expect(history.every((message) => !("replacement" in message))).toBe(true)
if (failure === "summary") expect((yield* store.get(session.id))?.tokens.input).toBe(100)
}),
)
}
compactionTests(
Layer.mock(LLMClient.Service)({
compact: () => Effect.succeed(compacted),
stream: () => Stream.concat(Stream.make(LLMEvent.textDelta({ id: "summary", text: "## Objective" })), Stream.never),
}),
).effect(
"interrupted native summary retains prior history and records compact usage without committing replacement",
() =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const modelRequests = yield* SessionModelRequest.Service
const store = yield* SessionStore.Service
const bus = yield* Bus.Service
const session = yield* insertSession(Session.ID.make("ses_native_interrupted"))
yield* bus.publish(SessionEvent.Synthetic, { sessionID: session.id, text: "Keep this work" })
const messages = yield* store.context(session.id)
const delta = yield* bus
.subscribe(SessionEvent.Compaction.Delta)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const fiber = yield* compaction
.compact({
context: { ...loaded(session, messages), model: nativeResolved },
prepare: modelRequests.prepare,
})
.pipe(Effect.forkScoped)
yield* Fiber.join(delta)
yield* Fiber.interrupt(fiber)
const history = yield* store.context(session.id)
expect(history[0]).toEqual(messages[0])
expect(history.at(-1)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "compaction.interrupted" },
})
expect(history.every((message) => !("replacement" in message))).toBe(true)
expect((yield* store.get(session.id))?.tokens.input).toBe(100)
}),
)
+48 -2
View File
@@ -578,6 +578,41 @@ describe("Session.create", () => {
}),
)
it.effect("copies native checkpoints into forks and removes them when reverting before the checkpoint", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const store = yield* SessionStore.Service
const bus = yield* Bus.Service
const database = yield* Database.Service
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
const parent = yield* session.create({ location, model })
yield* session.prompt({ sessionID: parent.id, text: "Original conversation", resume: false })
yield* SessionInbox.promote(database.db, bus, parent.id, "steer")
const replacementModel = { provider: "provider", id: "upstream-model", route: "openai-responses" }
const replacement = [
{ role: "user", content: [{ type: "text", text: "Retained input" }] },
{ role: "assistant", content: [{ type: "compaction", provider: "provider", encrypted: "opaque-checkpoint" }] },
]
yield* bus.publish(SessionEvent.Compaction.Started, { sessionID: parent.id, reason: "manual", recent: "" })
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: parent.id,
reason: "manual",
model,
replacement,
replacementModel,
text: "## Objective\n- Portable summary",
recent: "",
})
const fork = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const context = yield* store.context(fork.id)
expect(context).toMatchObject([{ type: "compaction", status: "completed", model, replacement, replacementModel }])
yield* bus.publish(SessionEvent.RevertEvent.Committed, { sessionID: fork.id, to: context[0].id })
expect(yield* store.context(fork.id)).toMatchObject([{ type: "user", text: "Original conversation" }])
expect(yield* store.context(parent.id)).toMatchObject([{ type: "compaction", replacement }])
}),
)
it.effect("replays a fork with stable projected identities", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -1244,6 +1279,9 @@ describe("SessionTransfer", () => {
const completedCompactionID = SessionMessage.ID.create()
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
const providerState = { responseId: "summary-response" }
const replacement = [
{ role: "assistant", content: [{ type: "compaction", provider: "provider", encrypted: "opaque-checkpoint" }] },
]
yield* transfer.import({
data: {
@@ -1300,6 +1338,7 @@ describe("SessionTransfer", () => {
reason: "manual",
model,
providerState,
replacement,
summary: "summary",
recent: "recent",
time: { created: DateTime.makeUnsafe(9) },
@@ -1316,11 +1355,18 @@ describe("SessionTransfer", () => {
completedCompactionID,
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState })
expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({
model,
providerState,
replacement,
})
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
expect(sanitized.messages.at(-1)).toMatchObject({
model,
providerState: { redacted: `compaction-provider-state:${completedCompactionID}` },
})
expect(JSON.parse(JSON.stringify(sanitized)).messages.at(-1)).not.toHaveProperty("replacement")
expect(JSON.stringify(sanitized)).not.toContain("opaque-checkpoint")
}),
)
@@ -1,16 +1,17 @@
import { describe, expect, test } from "bun:test"
import { Message } from "@opencode-ai/ai"
import { CompactionPart, LanguageModel, Message, ProviderID } from "@opencode-ai/ai"
import { OpenAIChat, OpenAIResponses } from "@opencode-ai/ai/protocols"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, Base64, FileAttachment, SkillAttachment } from "@opencode-ai/schema/prompt"
import { Skill } from "@opencode-ai/schema/skill"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { CompactionReplacement, toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { Agent } from "@opencode-ai/core/agent"
import { Shell } from "@opencode-ai/schema/shell"
import { Location } from "@opencode-ai/schema/location"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { DateTime } from "effect"
import { DateTime, Schema } from "effect"
import path from "path"
import { pathToFileURL } from "url"
@@ -18,8 +19,131 @@ const created = DateTime.makeUnsafe(0)
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
const build = Agent.defaultID
const runtime = LanguageModel.make({ id: "api-model", provider: "provider", route: OpenAIResponses.route })
const replacementModel = { provider: runtime.provider, id: runtime.id, route: runtime.route.id }
describe("toLLMMessages", () => {
test("replays the entire native replacement and subsequent messages after JSON persistence", () => {
const replacement = [
Message.make({
role: "user",
providerMetadata: { openai: { itemId: "retained", type: "message", status: "completed" } },
content: [
{ type: "text", text: "Retained context" },
{
type: "media",
mediaType: "image/png",
data: "https://example.com/image.png",
providerMetadata: { openai: { detail: "high" } },
},
],
}),
Message.assistant(
CompactionPart.make({ provider: ProviderID.make("provider"), id: "cmp_1", encrypted: "opaque-state" }),
),
Message.make({
role: "assistant",
providerMetadata: { openai: { itemId: "retained_assistant", phase: "commentary" } },
content: [
{
type: "reasoning",
text: "Retained reasoning",
providerMetadata: { openai: { reasoningEncryptedContent: "reasoning-state" } },
},
],
}),
]
const checkpoint = SessionMessage.CompactionCompleted.make({
id: id("native-checkpoint"),
type: "compaction",
status: "completed",
reason: "auto",
model,
summary: "Portable summary",
recent: "",
replacement: Schema.encodeSync(CompactionReplacement)(replacement),
replacementModel,
time: { created },
})
const codec = Schema.fromJsonString(SessionMessage.CompactionCompleted)
const persisted = Schema.decodeSync(codec)(Schema.encodeSync(codec)(checkpoint))
const user = SessionMessage.User.make({ id: id("after-native"), type: "user", text: "Continue", time: { created } })
expect(toLLMMessages([persisted, user], model, "openai", runtime)).toEqual([
...replacement,
Message.make({ id: user.id, role: "user", content: user.text, metadata: {} }),
])
})
test("normalizes undefined provider metadata while persisting the complete replacement", () => {
const message = Message.make({
role: "user",
content: "Retained input",
providerMetadata: { openai: { itemId: "retained", status: "completed", phase: undefined } },
})
const encoded = Schema.encodeSync(CompactionReplacement)([message])
expect(encoded).toEqual([
{
role: "user",
content: [{ type: "text", text: "Retained input" }],
providerMetadata: { openai: { itemId: "retained", status: "completed" } },
},
])
expect(Schema.decodeUnknownSync(CompactionReplacement)(encoded)[0].content).toEqual(message.content)
})
for (const scenario of [
"model",
"provider",
"missing-model",
"malformed",
"empty",
"missing-runtime",
"api-model",
"api-provider",
"route",
] as const) {
test(`uses the portable summary for a ${scenario} native checkpoint mismatch`, () => {
const replacement = Schema.encodeSync(CompactionReplacement)([
Message.assistant(CompactionPart.make({ provider: ProviderID.make("provider"), encrypted: "opaque-state" })),
])
const checkpoint = SessionMessage.CompactionCompleted.make({
id: id(`native-${scenario}`),
type: "compaction",
status: "completed",
reason: "manual",
model: scenario === "missing-model" ? undefined : model,
replacementModel: scenario === "missing-runtime" ? undefined : replacementModel,
summary: "Portable summary",
recent: "Recent context",
time: { created },
replacement:
scenario === "malformed"
? [{ role: "assistant", content: [{ type: "compaction", encrypted: 123 }] }]
: scenario === "empty"
? []
: replacement,
})
const selected =
scenario === "model"
? { ...model, id: Model.ID.make("other-model") }
: scenario === "provider"
? { ...model, providerID: Provider.ID.make("other-provider") }
: model
const active = LanguageModel.make({
id: scenario === "api-model" ? "other-api-model" : runtime.id,
provider: scenario === "api-provider" ? "other-api-provider" : runtime.provider,
route: scenario === "route" ? OpenAIChat.route : runtime.route,
})
const actual = toLLMMessages([checkpoint], selected, "openai", active)
expect(actual).toEqual(toLLMMessages([{ ...checkpoint, replacement: undefined }], selected))
expect(JSON.stringify(actual)).toContain("Portable summary")
expect(JSON.stringify(actual)).toContain("Recent context")
expect(JSON.stringify(actual)).not.toContain("opaque-state")
})
}
test("background user shells enter model context only through their completion notification", () => {
const shell = SessionMessage.Shell.make({
id: id("background-shell"),
+2 -1
View File
@@ -2,7 +2,7 @@ export * as ConfigProvider from "./provider.js"
import { Schema } from "effect"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { Capabilities, Compaction, Compatibility, Family, ID, VariantID } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
@@ -44,6 +44,7 @@ class Model extends Schema.Class<Model>("Config.Model")({
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
compaction: Compaction.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
...Overlays,
+5
View File
@@ -41,6 +41,9 @@ export interface Ref extends Schema.Schema.Type<typeof Ref> {}
export const Family = Schema.String.pipe(Schema.brand("Model.Family"))
export type Family = typeof Family.Type
export const Compaction = Schema.Literals(["summary", "provider"]).annotate({ identifier: "Model.Compaction" })
export type Compaction = typeof Compaction.Type
export type ReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
@@ -104,6 +107,8 @@ export const Info = Schema.Struct({
canonical: Provider.ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String,
/** When omitted, use OpenCode summary compaction. */
compaction: Compaction.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
...Provider.Overlays,
+2
View File
@@ -587,6 +587,8 @@ export namespace Compaction {
reason: Started.data.fields.reason,
model: SessionMessage.CompactionCompleted.fields.model,
providerState: SessionMessage.CompactionCompleted.fields.providerState,
replacement: SessionMessage.CompactionCompleted.fields.replacement,
replacementModel: SessionMessage.CompactionCompleted.fields.replacementModel,
text: Schema.String,
recent: Schema.String,
},
+8
View File
@@ -252,6 +252,14 @@ export const CompactionCompleted = Schema.Struct({
reason: Schema.Literals(["auto", "manual"]),
model: Model.Ref.pipe(optional),
providerState: ProviderState.pipe(optional),
/** Complete provider replacement window, JSON-encoded by the owning AI codec. */
replacement: Schema.Array(Schema.Json).pipe(optional),
/** Runtime identity, since a catalog alias can resolve to a different deployment or protocol. */
replacementModel: Schema.Struct({
provider: Schema.String,
id: Schema.String,
route: Schema.String,
}).pipe(optional),
summary: Schema.String,
recent: Schema.String,
}).annotate({ identifier: "Session.Message.Compaction.Completed" })