mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(core): support explicit provider compaction (#47323)
This commit is contained in:
@@ -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<string>
|
||||
@@ -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<string>
|
||||
@@ -2016,6 +2021,7 @@ export type ConfigEntry =
|
||||
body?: { [x: string]: JsonValue }
|
||||
models?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
modelID?: string
|
||||
family?: string
|
||||
name?: string
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -42,6 +42,7 @@ export const layer = Layer.effect(
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
"SessionRunnerModel.UnsupportedPackageError",
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
"SessionRunnerModel.UnsupportedCompactionError",
|
||||
],
|
||||
(error) => {
|
||||
const mapped: Error = input.model
|
||||
|
||||
@@ -52,10 +52,24 @@ export class UnresolvedProviderVariablesError extends Schema.TaggedError<Unresol
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCompactionError>()(
|
||||
"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<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
|
||||
): 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({
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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<SessionRunnerModel.Resolved, "ref" | "capabilities">,
|
||||
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<Settings, Editor>({
|
||||
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],
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -32,6 +32,45 @@ function required<T>(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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void>()
|
||||
const hanging = Promise.withResolvers<Response>()
|
||||
const state = { failure: false, hang: false, calls: 0 }
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
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)
|
||||
})
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
||||
}) {}
|
||||
|
||||
class Model extends Schema.Class<Model>("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<Model>("Config.Model")({
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
canonical: Provider.ID.pipe(optional),
|
||||
name: Schema.String.pipe(optional),
|
||||
env: Schema.String.pipe(Schema.Array, optional),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<typeof Compaction> {}
|
||||
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" })
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -388,7 +388,13 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
return (
|
||||
<div data-component="session-compaction-message">
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={i18n.t("ui.messagePart.compaction")} />
|
||||
<TimelineSeparator
|
||||
label={i18n.t(
|
||||
props.message.status === "completed" && props.message.providerContext
|
||||
? "ui.messagePart.providerCompaction"
|
||||
: "ui.messagePart.compaction",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
|
||||
@@ -2113,7 +2113,11 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
<text fg={color()}>✗</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={color()}>Compaction</text>
|
||||
<text fg={color()}>
|
||||
{props.message.status === "completed" && props.message.providerContext
|
||||
? "Provider compaction"
|
||||
: "Compaction"}
|
||||
</text>
|
||||
<Show when={cancelled()}>
|
||||
<text fg={color()}>· cancelled</text>
|
||||
</Show>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user