mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 08:26:25 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d88db2539 | ||
|
|
ffa4be15cb | ||
|
|
44c846e1a4 | ||
|
|
e6930bad7f | ||
|
|
84fe6101ef | ||
|
|
b7f88bbc78 | ||
|
|
008d571539 | ||
|
|
a26a978051 | ||
|
|
218a0dde97 |
@@ -113,8 +113,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
responseID = created
|
||||
return { type: "frame", frame }
|
||||
}
|
||||
// Keepalives carry no response state and may arrive before response.created.
|
||||
if (event.type === "keepalive") return { type: "frame", frame }
|
||||
// Keepalives and provider notifications carry no response state and may precede response.created.
|
||||
if (!event.type.startsWith("response.")) return { type: "frame", frame }
|
||||
if (!responseID)
|
||||
return yield* ProviderShared.eventError(
|
||||
options.id,
|
||||
|
||||
@@ -526,7 +526,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tolerates keepalive frames before response.created", () =>
|
||||
it.effect("tolerates keepalive and provider notifications before response.created", () =>
|
||||
Effect.gen(function* () {
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
@@ -534,6 +534,7 @@ describe("OpenAI Responses route", () => {
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
|
||||
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
|
||||
@@ -967,6 +967,20 @@ export type SessionLogOutput =
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly providerContext?:
|
||||
| {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: Provider.ID
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: Schema.Json
|
||||
}
|
||||
| undefined
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
}
|
||||
|
||||
@@ -138,6 +138,15 @@ export type SessionMessageCompactionRunning = {
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type SessionProviderContextProvenance = {
|
||||
providerID: string
|
||||
provider: string
|
||||
modelID: string
|
||||
route: string
|
||||
protocol: string
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -183,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>
|
||||
@@ -201,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"
|
||||
@@ -510,19 +509,6 @@ export type SessionMessageAssistantReasoning = {
|
||||
time?: { created: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type ToolContent = ToolTextContent | ToolFileContent
|
||||
|
||||
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
|
||||
@@ -537,6 +523,8 @@ export type SessionMessageCompactionFailed = {
|
||||
error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
|
||||
|
||||
export type SessionInboxSynthetic = {
|
||||
id: string
|
||||
sessionID: string
|
||||
@@ -1343,23 +1331,6 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
text: string
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
|
||||
|
||||
export type SessionMessageAssistantReasoning1 = {
|
||||
@@ -1379,6 +1350,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
|
||||
@@ -1740,10 +1724,37 @@ export type SessionMessageToolStateError = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionRunning
|
||||
| SessionMessageCompactionCompleted
|
||||
| SessionMessageCompactionFailed
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState
|
||||
summary: string
|
||||
recent: string
|
||||
providerContext?: SessionProviderContext
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
providerContext?: SessionProviderContext
|
||||
text: string
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionForked = {
|
||||
id: string
|
||||
@@ -1822,6 +1833,7 @@ export type ModelInfo = {
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
compaction?: ProviderCompaction
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
@@ -1996,6 +2008,7 @@ export type ConfigEntry =
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
canonical?: string
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
@@ -2005,6 +2018,7 @@ export type ConfigEntry =
|
||||
body?: { [x: string]: JsonValue }
|
||||
models?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
modelID?: string
|
||||
family?: string
|
||||
name?: string
|
||||
@@ -2082,6 +2096,11 @@ export type SessionMessageAssistantTool = {
|
||||
time: { created: number; ran?: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionRunning
|
||||
| SessionMessageCompactionCompleted
|
||||
| SessionMessageCompactionFailed
|
||||
|
||||
export type SessionMessageAssistantTool1 = {
|
||||
type: "tool"
|
||||
id: string
|
||||
@@ -3077,6 +3096,18 @@ export type SessionImportInput = {
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly providerContext?: {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: string
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3356,6 +3387,18 @@ export type SessionImportInput = {
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly providerContext?: {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: string
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3635,6 +3678,18 @@ export type SessionImportInput = {
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly providerContext?: {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: string
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -37,6 +37,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
|
||||
const resolved = yield* resolver.resolve(input.model).pipe(
|
||||
Effect.catchTag("AI.Error", (error) => new ModelSelectionError({ message: error.message })),
|
||||
Effect.catchTag(
|
||||
[
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ModelResolver from "./model-resolver.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { AIError, LanguageModel, LLM, LLMClient, UnsupportedOperationError } from "@opencode-ai/ai"
|
||||
import { Auth } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema, Struct } from "effect"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
@@ -53,6 +53,7 @@ export class UnresolvedProviderVariablesError extends Schema.TaggedError<Unresol
|
||||
}
|
||||
|
||||
export type Error =
|
||||
| AIError
|
||||
| VariantUnavailableError
|
||||
| UnsupportedPackageError
|
||||
| UnresolvedProviderVariablesError
|
||||
@@ -69,6 +70,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 +118,25 @@ export const fromCatalogModel = (
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
|
||||
): Effect.Effect<LanguageModel, AIError | UnsupportedPackageError | UnresolvedProviderVariablesError> =>
|
||||
resolveCatalogModel(model, credential, dependencies).pipe(
|
||||
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
|
||||
Effect.flatMap((resolved) => {
|
||||
if (model.compaction?.mode !== "provider") return Effect.succeed(resolved)
|
||||
const request = LLM.request({ model: resolved, messages: [] })
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" }) || LLMClient.canCompact(request))
|
||||
return Effect.succeed(resolved)
|
||||
return Effect.fail(
|
||||
new AIError({
|
||||
reason: new UnsupportedOperationError({
|
||||
operation: "compact",
|
||||
provider: resolved.provider,
|
||||
route: resolved.route.id,
|
||||
message: `Provider compaction is not supported by ${model.providerID}/${model.id} (${resolved.route.id})`,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
|
||||
@@ -296,6 +315,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()) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SessionEvent } from "./event.js"
|
||||
import type { SessionContext } from "./context.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"
|
||||
@@ -38,6 +39,7 @@ const OUTPUT_TOKEN_MAX = 32_000
|
||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const IMAGE_TOKEN_ESTIMATE = 1_500
|
||||
const PDF_TOKEN_ESTIMATE = 2_000
|
||||
const PROVIDER_KEEP_TOKENS = 64_000
|
||||
const SUMMARY_TEMPLATE = `You MUST use this format for your response (you may omit sections that aren't applicable). Do not include the <template> tags in your response.
|
||||
<template>
|
||||
## Objective
|
||||
@@ -147,7 +149,12 @@ 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,
|
||||
input.resolved.model.route.providerMetadataKey ?? input.resolved.model.provider,
|
||||
SessionProviderContext.provenance(input.resolved),
|
||||
),
|
||||
input.resolved.capabilities,
|
||||
)
|
||||
.filter((message) => message.role !== "assistant" || message.id !== last?.id)
|
||||
@@ -199,6 +206,29 @@ 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: SessionRunnerModel.Resolved) => {
|
||||
const users = SessionModelRequest.boundImages(
|
||||
SessionModelRequest.unsupportedParts(
|
||||
toLLMMessages(
|
||||
messages.filter((message) => message.type === "user").map((message) => ({ ...message, skills: undefined })),
|
||||
model.ref,
|
||||
model.model.route.providerMetadataKey ?? model.model.provider,
|
||||
),
|
||||
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 > PROVIDER_KEEP_TOKENS) 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
|
||||
@@ -357,6 +387,111 @@ export const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, input)
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
const executeProvider = Effect.fn("SessionCompaction.executeProvider")(function* (
|
||||
input: ExecuteInput,
|
||||
retained: Effect.Effect<readonly SessionMessage.Info[]>,
|
||||
) {
|
||||
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 transcript = SessionModelRequest.baseTranscript({
|
||||
agent: context.agent.info,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
initial: context.initial,
|
||||
messages: context.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: {
|
||||
...transcript,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
],
|
||||
},
|
||||
webSocket: "session",
|
||||
})
|
||||
const request = prepared.request
|
||||
const provenance = SessionProviderContext.provenance({ model: request.model, ref: context.model.ref })
|
||||
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(context.model)))
|
||||
return yield* reject(
|
||||
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
|
||||
)
|
||||
if (!LLMClient.canCompact(request, { mechanism: "trigger" }) && !LLMClient.canCompact(request))
|
||||
return yield* reject(
|
||||
`Provider compaction is not supported by ${request.model.provider}/${request.model.route.id}`,
|
||||
)
|
||||
if (!input.started)
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
recent: "",
|
||||
inputID: input.inputID,
|
||||
})
|
||||
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 messages = yield* retained
|
||||
const result = yield* llm.compact(request, { ...prepared.options, mechanism: "trigger" })
|
||||
return {
|
||||
replacement: [
|
||||
...retainUsers(messages, { ...context.model, model: request.model }),
|
||||
Message.assistant([result.checkpoint]),
|
||||
],
|
||||
usage: result.usage,
|
||||
}
|
||||
}
|
||||
if (LLMClient.canCompact(request))
|
||||
return yield* llm.compact(request, { mechanism: "endpoint", http: prepared.options.http })
|
||||
return yield* Effect.die("Compaction capability changed after preparation")
|
||||
}),
|
||||
)
|
||||
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.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)
|
||||
@@ -406,6 +541,7 @@ export const layer = Layer.effect(
|
||||
},
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
providerContext: transcript.providerContext,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
@@ -562,7 +698,13 @@ export const layer = Layer.effect(
|
||||
return estimateTokens(input) >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
if (findTailStart(input.messages, state.get().tokens) === undefined)
|
||||
if (
|
||||
!input.messages.some((message) =>
|
||||
message.type === "compaction" && message.status === "completed"
|
||||
? Boolean(message.providerContext || message.summary || message.recent)
|
||||
: serializeRecentMessage(message).length > 0,
|
||||
)
|
||||
)
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
@@ -578,15 +720,19 @@ 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, Effect.succeed(input.messages))
|
||||
: execute(request)
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SkillInstructions } from "../skill/instructions.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { AgentNotFoundError } from "./error.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
@@ -156,7 +157,12 @@ const layer = Layer.effect(
|
||||
|
||||
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
|
||||
const model = yield* resolveModel(selection.session)
|
||||
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
|
||||
const history = yield* SessionHistory.entriesForRunner(
|
||||
db,
|
||||
selection.session.id,
|
||||
selection.instructions,
|
||||
SessionProviderContext.provenance(model),
|
||||
)
|
||||
return {
|
||||
session: selection.session,
|
||||
agent: selection.agent,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Instructions } from "../instructions/index.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import type { AgentNotFoundError } from "./error.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
@@ -29,7 +30,12 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
const context = yield* SessionContext.Service
|
||||
const selection = yield* context.select(input.session.id)
|
||||
const model = yield* context.resolveModel(selection.session)
|
||||
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
|
||||
const history = yield* SessionHistory.preview(
|
||||
database.db,
|
||||
selection.session.id,
|
||||
selection.instructions,
|
||||
SessionProviderContext.provenance(model),
|
||||
)
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: selection.agent.info,
|
||||
model,
|
||||
@@ -42,6 +48,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
providerContext: transcript.providerContext,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, or, sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { MessageDecodeError } from "./error.js"
|
||||
@@ -6,13 +6,18 @@ import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { InstructionStateTable, SessionMessageTable } from "./sql.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
export const latestCompaction = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) {
|
||||
return yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
@@ -21,6 +26,17 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
|
||||
or(
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.providerContext') is null`,
|
||||
target === undefined
|
||||
? undefined
|
||||
: and(
|
||||
...Object.entries(target).map(
|
||||
([key, value]) =>
|
||||
sql`json_extract(${SessionMessageTable.data}, ${`$.providerContext.provenance.${key}`}) = ${value}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
@@ -31,6 +47,11 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
|
||||
|
||||
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.tap((message) =>
|
||||
message.type === "compaction" && message.status === "completed" && message.providerContext
|
||||
? SessionProviderContext.validate(message.providerContext)
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
@@ -40,8 +61,12 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
|
||||
),
|
||||
)
|
||||
|
||||
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const compaction = yield* latestCompaction(db, sessionID)
|
||||
const messageEntries = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) {
|
||||
const compaction = yield* latestCompaction(db, sessionID, target)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
@@ -54,24 +79,57 @@ const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, session
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
const entries = yield* Effect.forEach(rows, (row) =>
|
||||
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
||||
)
|
||||
const native = entries.findLast(
|
||||
(entry) =>
|
||||
entry.message.type === "compaction" && entry.message.status === "completed" && entry.message.providerContext,
|
||||
)
|
||||
const epoch = native
|
||||
? yield* db
|
||||
.select({ start: InstructionStateTable.epoch_start })
|
||||
.from(InstructionStateTable)
|
||||
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
// Skipped native checkpoints are not textual summaries. Their original transcript remains available.
|
||||
return entries.filter((entry) => {
|
||||
const message = entry.message
|
||||
// Re-expansion may cross native checkpoints, but their advanced baseline still applies.
|
||||
// Do not replay superseded instruction updates ahead of post-epoch updates.
|
||||
// Forks seed their baseline at sequence 0 but retain parent message sequences.
|
||||
// The copied native boundary still retires the instructions preceding it.
|
||||
if (message.type === "system" && native && entry.seq < Math.max(epoch?.start ?? 0, native.seq)) return false
|
||||
return (
|
||||
message.type !== "compaction" ||
|
||||
message.status !== "completed" ||
|
||||
!message.providerContext ||
|
||||
SessionProviderContext.compatible(message.providerContext.provenance, target)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
|
||||
/** Without a resolved target, native checkpoints are conservatively skipped. */
|
||||
export const load = Effect.fn("SessionHistory.load")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) {
|
||||
return (yield* messageEntries(db, sessionID, target)).map((entry) => entry.message)
|
||||
})
|
||||
|
||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
instructions: Instructions.List,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) {
|
||||
return yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* messageEntries(db, sessionID)
|
||||
const messages = yield* messageEntries(db, sessionID, target)
|
||||
return {
|
||||
initial: yield* InstructionState.initial(db, sessionID, instructions),
|
||||
entries: messages,
|
||||
@@ -85,12 +143,13 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
instructions: Instructions.List,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) {
|
||||
const observed = yield* Instructions.read(instructions)
|
||||
return yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* messageEntries(db, sessionID)
|
||||
const messages = yield* messageEntries(db, sessionID, target)
|
||||
// An active assistant may contain an unresolved tool call, so only preview the settled prefix.
|
||||
const unsettled = messages.findIndex(
|
||||
(entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined,
|
||||
|
||||
@@ -413,6 +413,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
providerContext: event.data.providerContext,
|
||||
recent: event.data.recent,
|
||||
})
|
||||
return
|
||||
@@ -427,6 +428,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
providerContext: event.data.providerContext,
|
||||
recent: event.data.recent,
|
||||
time: { created },
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
@@ -74,6 +75,8 @@ interface PrepareInput {
|
||||
readonly transcript: {
|
||||
readonly system: Array<SystemPart>
|
||||
readonly messages: Array<Message>
|
||||
/** Selected durable window, checked again after model request hooks resolve the route. */
|
||||
readonly providerContext?: SessionProviderContext.Provenance
|
||||
}
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
@@ -93,8 +96,13 @@ export const baseTranscript = (input: {
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
}) => {
|
||||
const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider
|
||||
const checkpoint = input.messages.findLast(
|
||||
(message): message is SessionMessage.CompactionCompleted =>
|
||||
message.type === "compaction" && message.status === "completed" && message.providerContext !== undefined,
|
||||
)
|
||||
return {
|
||||
providerMetadataKey,
|
||||
providerContext: checkpoint?.providerContext?.provenance,
|
||||
system: [
|
||||
input.agent.system
|
||||
? input.agent.system
|
||||
@@ -103,7 +111,12 @@ 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,
|
||||
SessionProviderContext.provenance(input.model),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +358,18 @@ export const layer = Layer.effect(
|
||||
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
|
||||
}),
|
||||
)
|
||||
// A newly installed routing hook must not send an existing opaque window to another deployment.
|
||||
// Checkpoint producers stamp the final prepared route, not the pre-hook catalog selection.
|
||||
if (
|
||||
input.transcript.providerContext &&
|
||||
!SessionProviderContext.compatible(
|
||||
input.transcript.providerContext,
|
||||
SessionProviderContext.provenance({ model: request.model, ref: resolved.ref }),
|
||||
)
|
||||
)
|
||||
return yield* Effect.die(
|
||||
new Error("Provider context is incompatible with the route selected by model request hooks"),
|
||||
)
|
||||
const hasHttpHooks =
|
||||
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
|
||||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SessionMessageUpdater } from "./message-updater.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
@@ -691,6 +692,8 @@ const layer = Layer.effectDiscard(
|
||||
yield* bus.project(SessionEvent.Compaction.Started, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Compaction.Ended, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.data.providerContext)
|
||||
yield* SessionProviderContext.validate(event.data.providerContext).pipe(Effect.orDie)
|
||||
yield* run(db, event)
|
||||
yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq)
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
export * as SessionProviderContext from "./provider-context.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { SessionProviderContext } from "@opencode-ai/schema/session-provider-context"
|
||||
import { Schema } from "effect"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
|
||||
export type Provenance = SessionProviderContext.Provenance
|
||||
export const Info = SessionProviderContext.Info
|
||||
export type Info = SessionProviderContext.Info
|
||||
|
||||
const messages = Schema.toCodecJson(Schema.Array(Message))
|
||||
|
||||
/** No guessed endpoints. Dynamic URL builders cannot establish a durable deployment identity here. */
|
||||
export function provenance(resolved: Pick<SessionRunnerModel.Resolved, "model" | "ref">): Provenance | undefined {
|
||||
const model = resolved.model
|
||||
const endpoint = model.route.endpoint
|
||||
if (!endpoint.baseURL || typeof endpoint.path !== "string") return undefined
|
||||
return {
|
||||
providerID: resolved.ref.providerID,
|
||||
provider: model.provider,
|
||||
modelID: model.id,
|
||||
route: model.route.id,
|
||||
protocol: model.route.protocol,
|
||||
endpoint: Hash.sha256(
|
||||
JSON.stringify([
|
||||
endpoint.baseURL,
|
||||
endpoint.path,
|
||||
Object.entries(endpoint.query ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
|
||||
]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export const compatible = (source: Provenance, target: Provenance | undefined) =>
|
||||
target !== undefined &&
|
||||
source.providerID === target.providerID &&
|
||||
source.provider === target.provider &&
|
||||
source.modelID === target.modelID &&
|
||||
source.route === target.route &&
|
||||
source.protocol === target.protocol &&
|
||||
source.endpoint === target.endpoint
|
||||
|
||||
/** Stores the canonical replacement, not a local summary or transport continuation.
|
||||
* Provider and attachment metadata can contain optional undefined entries. Use JSON's
|
||||
* omission semantics, while preserving canonical binary media as equivalent base64.
|
||||
*/
|
||||
export const encode = (provenance: Provenance, replacement: ReadonlyArray<Message>): Info => ({
|
||||
version: 1,
|
||||
provenance,
|
||||
messages: Schema.decodeSync(Schema.fromJsonString(Schema.Json))(
|
||||
JSON.stringify(
|
||||
replacement.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((part) =>
|
||||
part.type === "media" && part.data instanceof Uint8Array
|
||||
? { ...part, data: Buffer.from(part.data).toString("base64") }
|
||||
: part,
|
||||
),
|
||||
})),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(context.messages)
|
||||
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(context.messages)
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRunnerLLM from "./llm.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, sql } from "drizzle-orm"
|
||||
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
@@ -11,6 +11,7 @@ import { SessionContext } from "../context.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionInbox } from "../inbox.js"
|
||||
import { SessionHistory } from "../history.js"
|
||||
import { SessionProviderContext } from "../provider-context.js"
|
||||
import { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionModelTransport } from "../model-transport.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
@@ -113,7 +114,12 @@ const layer = Layer.effect(
|
||||
const selected = yield* context.select(session.id)
|
||||
const model = yield* context.resolveModel(selected.session)
|
||||
// Preview updates without admitting them after the already-delivered compaction marker.
|
||||
const history = yield* SessionHistory.preview(db, session.id, selected.instructions)
|
||||
const history = yield* SessionHistory.preview(
|
||||
db,
|
||||
session.id,
|
||||
selected.instructions,
|
||||
SessionProviderContext.provenance(model),
|
||||
)
|
||||
return {
|
||||
session: selected.session,
|
||||
agent: selected.agent,
|
||||
@@ -221,6 +227,7 @@ const layer = Layer.effect(
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
providerContext: transcript.providerContext,
|
||||
messages: stepLimitReached
|
||||
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||
: transcript.messages,
|
||||
@@ -313,7 +320,28 @@ const layer = Layer.effect(
|
||||
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
for (const message of yield* store.context(sessionID)) {
|
||||
// Recovery only needs unfinished tools, not every original message hidden by native checkpoints.
|
||||
const boundary = yield* SessionHistory.latestCompaction(db, sessionID)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
boundary ? gt(SessionMessageTable.seq, boundary.seq) : undefined,
|
||||
sql`exists (
|
||||
select 1 from json_each(${SessionMessageTable.data}, '$.content') as part
|
||||
where json_extract(part.value, '$.type') = 'tool'
|
||||
and json_extract(part.value, '$.state.status') in ('streaming', 'running')
|
||||
)`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
for (const row of rows) {
|
||||
const message = yield* SessionHistory.decodeMessageRow(row)
|
||||
if (message.type !== "assistant") continue
|
||||
for (const tool of message.content) {
|
||||
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
|
||||
|
||||
@@ -57,6 +57,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 +69,7 @@ export const resolved = (
|
||||
capabilities: options.capabilities,
|
||||
cost: options.cost,
|
||||
limit: options.limit,
|
||||
compaction: options.compaction,
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Model } from "@opencode-ai/schema/model"
|
||||
import { Option, Schema } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionProviderContext } from "../provider-context.js"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
@@ -221,7 +222,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,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
): Message[] {
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
@@ -274,6 +280,12 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
return assistant(message, model, providerMetadataKey)
|
||||
case "compaction":
|
||||
if (message.status !== "completed") return []
|
||||
// Explicit system updates inside a native replacement predate its completed
|
||||
// compaction epoch; the current epoch baseline supersedes those instructions.
|
||||
if (message.providerContext)
|
||||
return SessionProviderContext.compatible(message.providerContext.provenance, target)
|
||||
? SessionProviderContext.decode(message.providerContext).filter((message) => message.role !== "system")
|
||||
: []
|
||||
return [
|
||||
Message.make({
|
||||
id: message.id,
|
||||
@@ -300,4 +312,5 @@ export const toLLMMessages = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
model: Model.Ref,
|
||||
providerMetadataKey: string = model.providerID,
|
||||
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey, target))
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface Interface {
|
||||
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
/** Model-neutral history: native windows are skipped; request assembly uses model-aware SessionHistory. */
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
|
||||
@@ -32,6 +32,44 @@ 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({
|
||||
reason: { _tag: "UnsupportedOperation", operation: "compact" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds key auth for custom providers without env credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -136,7 +136,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)
|
||||
@@ -192,6 +196,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,328 @@
|
||||
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))
|
||||
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 } })
|
||||
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)).toEqual([])
|
||||
expect(SessionCompaction.retainUsers([user("x".repeat(63_000 * 4)), { ...newest, text: "new" }], model)).toHaveLength(
|
||||
1,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { CompactionPart, LanguageModel, Message, ToolCallPart } from "@opencode-ai/ai"
|
||||
import { OpenAIResponses } from "@opencode-ai/ai/protocols"
|
||||
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 { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Instructions } from "@opencode-ai/core/instructions/index"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
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 { 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 { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { InstructionStateTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "deployment", provider: "openai", route: OpenAIResponses.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 128_000, output: 4096 },
|
||||
},
|
||||
)
|
||||
const target = SessionProviderContext.provenance(model)
|
||||
if (!target) throw new Error("Fixture must have a concrete endpoint")
|
||||
const replacement = [
|
||||
Message.user("retained request"),
|
||||
Message.system("changed instructions"),
|
||||
Message.assistant(
|
||||
CompactionPart.make({ provider: model.model.provider, encrypted: "opaque-checkpoint", id: "cp_1" }),
|
||||
),
|
||||
]
|
||||
const providerContext = SessionProviderContext.encode(target, replacement)
|
||||
const sessionID = SessionSchema.ID.make("ses_provider_context")
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true }))],
|
||||
),
|
||||
)
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const inbox = yield* SessionInbox.Service
|
||||
yield* database.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: "provider-context",
|
||||
version: "test",
|
||||
})
|
||||
const state = { value: "initial instructions" }
|
||||
const instructions = Instructions.make({
|
||||
key: Instructions.Key.make("test/context"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.sync(() => state.value),
|
||||
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
|
||||
})
|
||||
const prepare = InstructionState.prepare(database.db, bus, instructions, sessionID)
|
||||
const prompt = Effect.fnUntraced(function* (text: string) {
|
||||
const id = SessionMessage.ID.create()
|
||||
yield* inbox.admit({ id, sessionID, item: { type: "user", payload: { text }, delivery: "steer" } })
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id })
|
||||
return id
|
||||
})
|
||||
const compact = (context?: SessionProviderContext.Info) =>
|
||||
bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: context ? "" : "local summary",
|
||||
recent: "",
|
||||
providerContext: context,
|
||||
})
|
||||
const load = (identity?: SessionProviderContext.Provenance) =>
|
||||
SessionHistory.entriesForRunner(database.db, sessionID, instructions, identity)
|
||||
return { db: database.db, bus, state, instructions, prepare, prompt, compact, load }
|
||||
})
|
||||
|
||||
test("canonical provider context round-trips tools, opaque checkpoints and binary media through JSON", () => {
|
||||
const messages = [
|
||||
...replacement,
|
||||
Message.assistant(ToolCallPart.make({ id: "call_1", name: "read", input: { path: "file" } })),
|
||||
Message.tool({ id: "call_1", name: "read", result: { text: "result" } }),
|
||||
Message.user({ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3]) }),
|
||||
]
|
||||
const context = SessionProviderContext.encode(providerContext.provenance, messages)
|
||||
const stored = Schema.decodeUnknownSync(Schema.fromJsonString(SessionProviderContext.Info))(JSON.stringify(context))
|
||||
const decoded = SessionProviderContext.decode(stored)
|
||||
expect(decoded.slice(0, -1)).toEqual(messages.slice(0, -1))
|
||||
expect(decoded.at(-1)?.content).toEqual([{ type: "media", mediaType: "image/png", data: "AQID" }])
|
||||
const optionalMetadata = SessionProviderContext.encode(providerContext.provenance, [
|
||||
Message.make({
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "attachment", metadata: { attachment: { name: undefined, source: { type: "inline" } } } },
|
||||
],
|
||||
providerMetadata: { openai: { itemId: undefined, type: "message", status: undefined, phase: undefined } },
|
||||
}),
|
||||
])
|
||||
expect(SessionProviderContext.decode(optionalMetadata)[0]).toMatchObject({
|
||||
providerMetadata: { openai: { type: "message" } },
|
||||
content: [{ metadata: { attachment: { source: { type: "inline" } } } }],
|
||||
})
|
||||
expect(() =>
|
||||
SessionProviderContext.decode({
|
||||
...context,
|
||||
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai" }] }],
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("compatibility uses the actual deployment and endpoint rather than a catalog alias or variant", () => {
|
||||
expect(
|
||||
SessionProviderContext.compatible(
|
||||
providerContext.provenance,
|
||||
SessionProviderContext.provenance({
|
||||
...model,
|
||||
ref: { ...model.ref, id: Model.ID.make("alias"), variant: Model.VariantID.make("high") },
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
for (const changed of [
|
||||
{ ...model, model: LanguageModel.update(model.model, { id: "other-deployment" }) },
|
||||
{
|
||||
...model,
|
||||
model: LanguageModel.update(model.model, {
|
||||
route: model.model.route.with({ endpoint: { baseURL: "https://another.example/v1?api-key=secret" } }),
|
||||
}),
|
||||
},
|
||||
{ ...model, model: LanguageModel.update(model.model, { route: model.model.route.with({ id: "other-route" }) }) },
|
||||
])
|
||||
expect(
|
||||
SessionProviderContext.compatible(providerContext.provenance, SessionProviderContext.provenance(changed)),
|
||||
).toBe(false)
|
||||
const privateEndpoint = SessionProviderContext.provenance({
|
||||
...model,
|
||||
model: LanguageModel.update(model.model, {
|
||||
route: model.model.route.with({ endpoint: { baseURL: "https://user:secret@example.com/v1?api-key=secret" } }),
|
||||
}),
|
||||
})
|
||||
expect(JSON.stringify(privateEndpoint)).not.toContain("secret")
|
||||
expect(
|
||||
SessionProviderContext.provenance({
|
||||
...model,
|
||||
model: LanguageModel.update(model.model, {
|
||||
route: model.model.route.with({ endpoint: { path: () => "/dynamic" } }),
|
||||
}),
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(SessionProviderContext.compatible(providerContext.provenance, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it.effect(
|
||||
"advances the native instruction epoch and omits superseded chronological updates after durable replay and provider switches",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* setup
|
||||
yield* s.prepare
|
||||
yield* s.prompt("original request")
|
||||
s.state.value = "changed instructions"
|
||||
yield* s.prepare
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
|
||||
const completed = yield* s.compact(providerContext)
|
||||
s.state.value = "newest instructions"
|
||||
yield* s.prepare
|
||||
yield* s.prompt("continue")
|
||||
|
||||
const verify = Effect.gen(function* () {
|
||||
expect(
|
||||
yield* s.db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
|
||||
).toMatchObject({
|
||||
epoch_start: completed.durable.seq,
|
||||
initial_values: { "test/context": Instructions.hash("changed instructions") },
|
||||
current_values: { "test/context": Instructions.hash("newest instructions") },
|
||||
})
|
||||
const native = yield* s.load(target)
|
||||
expect(native.initial).toBe("changed instructions")
|
||||
expect(
|
||||
toLLMMessages(
|
||||
native.entries.map((entry) => entry.message),
|
||||
model.ref,
|
||||
"openai",
|
||||
target,
|
||||
),
|
||||
).toEqual([
|
||||
replacement[0],
|
||||
replacement[2],
|
||||
Message.system("newest instructions"),
|
||||
expect.objectContaining({ role: "user", content: [Message.text("continue")] }),
|
||||
])
|
||||
for (const incompatible of [
|
||||
undefined,
|
||||
{ ...providerContext.provenance, modelID: "other" },
|
||||
{ ...providerContext.provenance, provider: "other" },
|
||||
]) {
|
||||
const expanded = yield* s.load(incompatible)
|
||||
expect(expanded.initial).toBe("changed instructions")
|
||||
expect(
|
||||
toLLMMessages(
|
||||
expanded.entries.map((entry) => entry.message),
|
||||
model.ref,
|
||||
).map((message) => message.content),
|
||||
).toEqual([
|
||||
[Message.text("original request")],
|
||||
[Message.text("newest instructions")],
|
||||
[Message.text("continue")],
|
||||
])
|
||||
}
|
||||
const preview = yield* SessionHistory.preview(s.db, sessionID, s.instructions, target)
|
||||
expect(preview.initial).toBe("changed instructions")
|
||||
expect(preview.messages).toEqual(native.entries.map((entry) => entry.message))
|
||||
const store = yield* SessionStore.Service
|
||||
expect((yield* store.messages({ sessionID })).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
"system",
|
||||
"compaction",
|
||||
"system",
|
||||
"user",
|
||||
])
|
||||
})
|
||||
yield* verify
|
||||
const recorded = yield* s.db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
expect(recorded.filter((event) => event.data.providerContext !== undefined)).toHaveLength(1)
|
||||
yield* s.bus.remove(sessionID)
|
||||
yield* s.db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
|
||||
for (const event of recorded)
|
||||
yield* s.bus.replay({
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
})
|
||||
yield* verify
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to an earlier compatible native or local checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* setup
|
||||
yield* s.prepare
|
||||
yield* s.prompt("before local")
|
||||
s.state.value = "local baseline"
|
||||
yield* s.prepare
|
||||
yield* s.compact()
|
||||
yield* s.prompt("after local")
|
||||
yield* s.compact(providerContext)
|
||||
yield* s.prompt("after native")
|
||||
s.state.value = "new native baseline"
|
||||
yield* s.prepare
|
||||
yield* s.compact({ ...providerContext, provenance: { ...providerContext.provenance, modelID: "other" } })
|
||||
s.state.value = "post-epoch update"
|
||||
yield* s.prepare
|
||||
const native = yield* s.load(target)
|
||||
expect(native.initial).toBe("new native baseline")
|
||||
expect(native.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "system"])
|
||||
expect(native.entries[0]?.message).toMatchObject({ providerContext })
|
||||
expect(
|
||||
toLLMMessages(
|
||||
native.entries.map((entry) => entry.message),
|
||||
model.ref,
|
||||
"openai",
|
||||
target,
|
||||
).filter((message) => message.role === "system"),
|
||||
).toEqual([Message.system("post-epoch update")])
|
||||
const local = yield* s.load()
|
||||
expect(local.initial).toBe("new native baseline")
|
||||
expect(local.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "user", "system"])
|
||||
expect(local.entries[0]?.message).toMatchObject({ summary: "local summary" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed installed or persisted native windows instead of silently dropping them", () =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* setup
|
||||
const malformed = { ...providerContext, messages: [{ role: "invalid", content: [] }] }
|
||||
expect(yield* s.compact(malformed).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
|
||||
yield* s.compact(providerContext)
|
||||
const row = yield* s.db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.get()
|
||||
if (!row) throw new Error("Expected projected checkpoint")
|
||||
const data = Schema.encodeSync(SessionMessage.CompactionCompleted)(
|
||||
Schema.decodeUnknownSync(SessionMessage.CompactionCompleted)({ ...row.data, id: row.id, type: row.type }),
|
||||
)
|
||||
yield* s.db
|
||||
.update(SessionMessageTable)
|
||||
.set({ data: { ...data, providerContext: malformed } })
|
||||
.where(eq(SessionMessageTable.id, row.id))
|
||||
.run()
|
||||
expect(yield* SessionHistory.load(s.db, sessionID, target).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageDecodeError",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
HttpContext,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -40,6 +41,7 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
@@ -1435,6 +1437,93 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario(
|
||||
"restores installed native context with auto disabled and preserves it across fork and revert",
|
||||
function* (s) {
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
yield* compaction.transform((editor) => editor.configure({ auto: false }))
|
||||
yield* s.runPrompt("Original request")
|
||||
s.systemBaseline = "Checkpoint instructions"
|
||||
yield* s.runPrompt("Before checkpoint")
|
||||
const target = SessionProviderContext.provenance({
|
||||
model: s.currentModel,
|
||||
ref: Model.Ref.make({
|
||||
id: Model.ID.make(s.currentModel.id),
|
||||
providerID: Provider.ID.make(s.currentModel.provider),
|
||||
}),
|
||||
})
|
||||
if (!target) throw new Error("Expected concrete fixture endpoint")
|
||||
const replacement = [
|
||||
Message.system("Checkpoint instructions"),
|
||||
Message.assistant(CompactionPart.make({ provider: s.currentModel.provider, encrypted: "checkpoint" })),
|
||||
]
|
||||
const providerContext = SessionProviderContext.encode(target, replacement)
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "",
|
||||
recent: "",
|
||||
providerContext,
|
||||
})
|
||||
const checkpoint = (yield* s.messages).find((message) => message.type === "compaction")
|
||||
if (!checkpoint) throw new Error("Expected checkpoint")
|
||||
|
||||
s.systemBaseline = "Newest instructions"
|
||||
const after = yield* s.runPrompt("After checkpoint")
|
||||
const continued = s.requests.at(-1)
|
||||
if (!continued) throw new Error("Expected continuation request")
|
||||
expect(continued.messages[0]).toEqual(replacement[1])
|
||||
expect(continued.system.map((part) => part.text)).toContain("Checkpoint instructions")
|
||||
expect(systemTexts(continued)).toEqual(["Newest instructions"])
|
||||
|
||||
const forked = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: after.id } })
|
||||
yield* s.session.prompt({ sessionID: forked.id, text: "Fork prompt", resume: false })
|
||||
yield* s.session.resume(forked.id)
|
||||
expect(s.requests.at(-1)?.messages[0]).toEqual(replacement[1])
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toContain("Newest instructions")
|
||||
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
|
||||
Message.system("Newest instructions"),
|
||||
])
|
||||
expect(
|
||||
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
|
||||
).toMatchObject({ providerContext })
|
||||
|
||||
const original = s.currentModel
|
||||
s.currentModel = LanguageModel.update(original, { id: "different-deployment" })
|
||||
yield* s.session.prompt({ sessionID: forked.id, text: "Switched fork", resume: false })
|
||||
yield* s.session.resume(forked.id)
|
||||
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
|
||||
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
|
||||
Message.system("Newest instructions"),
|
||||
])
|
||||
s.currentModel = original
|
||||
|
||||
yield* s.bus.publish(SessionEvent.RevertEvent.Committed, { sessionID, to: checkpoint.id })
|
||||
yield* s.runPrompt("After revert")
|
||||
expect(
|
||||
s.requests
|
||||
.at(-1)
|
||||
?.messages.flatMap((message) => message.content)
|
||||
.some((part) => part.type === "compaction"),
|
||||
).toBe(false)
|
||||
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
|
||||
expect(
|
||||
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
|
||||
).toMatchObject({ providerContext })
|
||||
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.baseURL = "https://another-deployment.example/v1"
|
||||
}),
|
||||
)
|
||||
const before = s.requests.length
|
||||
yield* s.session.prompt({ sessionID: forked.id, text: "Changed route", resume: false })
|
||||
expect(yield* s.session.resume(forked.id).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
|
||||
expect(s.requests).toHaveLength(before)
|
||||
},
|
||||
)
|
||||
|
||||
scenario("seeds a fork with the parent's newest instruction values", function* (s) {
|
||||
yield* s.runPrompt("First")
|
||||
s.systemBaseline = "Changed context"
|
||||
|
||||
@@ -14272,6 +14272,9 @@
|
||||
"Config.ModelEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14377,6 +14380,9 @@
|
||||
"Config.ProviderEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16309,6 +16315,9 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17242,6 +17251,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": {
|
||||
@@ -17264,6 +17284,9 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18221,11 +18244,20 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"recent": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18448,6 +18480,9 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18771,6 +18806,46 @@
|
||||
"Session.Metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.ProviderContext": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"enum": [1]
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
|
||||
},
|
||||
"messages": {}
|
||||
},
|
||||
"required": ["version", "provenance", "messages"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.ProviderContext.Provenance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
"route": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Revert": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -587,6 +587,7 @@ export namespace Compaction {
|
||||
reason: Started.data.fields.reason,
|
||||
model: SessionMessage.CompactionCompleted.fields.model,
|
||||
providerState: SessionMessage.CompactionCompleted.fields.providerState,
|
||||
providerContext: SessionMessage.CompactionCompleted.fields.providerContext,
|
||||
text: Schema.String,
|
||||
recent: Schema.String,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as SessionMessage from "./session-message.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { SessionProviderContext } from "./session-provider-context.js"
|
||||
import { optional } from "./schema.js"
|
||||
import { Content } from "./tool.js"
|
||||
import { Location } from "./location.js"
|
||||
@@ -254,6 +255,7 @@ export const CompactionCompleted = Schema.Struct({
|
||||
providerState: ProviderState.pipe(optional),
|
||||
summary: Schema.String,
|
||||
recent: Schema.String,
|
||||
providerContext: SessionProviderContext.Info.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
|
||||
|
||||
export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export * as SessionProviderContext from "./session-provider-context.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Provider } from "./provider.js"
|
||||
|
||||
/** Exact producing model/deployment and route identity, never credentials or a connection ID. */
|
||||
export interface Provenance extends Schema.Schema.Type<typeof Provenance> {}
|
||||
export const Provenance = Schema.Struct({
|
||||
providerID: Provider.ID,
|
||||
provider: Schema.String,
|
||||
modelID: Schema.String,
|
||||
route: Schema.String,
|
||||
protocol: Schema.String,
|
||||
/** Digest of the configured endpoint; raw URLs and query values are not persisted. */
|
||||
endpoint: Schema.String,
|
||||
}).annotate({ identifier: "Session.ProviderContext.Provenance" })
|
||||
|
||||
/** Core validates the versioned canonical AI Message[] payload on installation and replay. */
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
export const Info = Schema.Struct({
|
||||
version: Schema.Literal(1),
|
||||
provenance: Provenance,
|
||||
messages: Schema.Json,
|
||||
}).annotate({ identifier: "Session.ProviderContext" })
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -48,3 +48,25 @@ test("failed steps only override the assistant finish for content filters", () =
|
||||
})
|
||||
expect(() => decode({ ...input, finish: "stop" })).toThrow()
|
||||
})
|
||||
|
||||
test("provider compaction context is optional, versioned and JSON-only", () => {
|
||||
const decode = Schema.decodeUnknownSync(SessionEvent.Compaction.Ended.data)
|
||||
const encode = Schema.encodeSync(SessionEvent.Compaction.Ended.data)
|
||||
const local = { sessionID: "ses_context", reason: "manual" as const, text: "summary", recent: "" }
|
||||
expect(encode({ ...decode(local), providerContext: undefined })).toEqual(local)
|
||||
const providerContext = {
|
||||
version: 1 as const,
|
||||
provenance: {
|
||||
providerID: "openai",
|
||||
provider: "openai",
|
||||
modelID: "deployment",
|
||||
route: "responses",
|
||||
protocol: "responses",
|
||||
endpoint: "digest",
|
||||
},
|
||||
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai", encrypted: "opaque" }] }],
|
||||
}
|
||||
expect(encode(decode({ ...local, providerContext }))).toEqual({ ...local, providerContext })
|
||||
expect(() => decode({ ...local, providerContext: { ...providerContext, version: 2 } })).toThrow()
|
||||
expect(() => decode({ ...local, providerContext: { ...providerContext, messages: [() => "invalid"] } })).toThrow()
|
||||
})
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -2214,7 +2214,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>
|
||||
@@ -2926,6 +2930,7 @@ function InlineTool(props: {
|
||||
pending: string
|
||||
failure?: string
|
||||
spinner?: boolean
|
||||
running?: boolean
|
||||
status?: JSX.Element
|
||||
children: JSX.Element
|
||||
part: SessionMessageAssistantTool
|
||||
@@ -2937,7 +2942,9 @@ function InlineTool(props: {
|
||||
const [errorExpanded, setErrorExpanded] = createSignal(false)
|
||||
const permission = useToolPermission(() => props.part)
|
||||
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
|
||||
const error = createMemo(() =>
|
||||
!props.running && props.part.state.status === "error" ? props.part.state.error.message : undefined,
|
||||
)
|
||||
|
||||
const denied = createMemo(
|
||||
() =>
|
||||
@@ -3482,6 +3489,7 @@ function Subagent(props: ToolProps) {
|
||||
<InlineTool
|
||||
icon={continuation() ? "↳" : isRunning() ? "│" : props.part.state.status === "completed" ? "✓" : "│"}
|
||||
spinner={!continuation() && isRunning()}
|
||||
running={isRunning()}
|
||||
complete={description()}
|
||||
pending="Delegating…"
|
||||
part={props.part}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14272,6 +14272,9 @@
|
||||
"Config.ModelEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14377,6 +14380,9 @@
|
||||
"Config.ProviderEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16309,6 +16315,9 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17242,6 +17251,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": {
|
||||
@@ -17264,6 +17284,9 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18221,11 +18244,20 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"recent": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18448,6 +18480,9 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18771,6 +18806,46 @@
|
||||
"Session.Metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.ProviderContext": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"enum": [1]
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
|
||||
},
|
||||
"messages": {}
|
||||
},
|
||||
"required": ["version", "provenance", "messages"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.ProviderContext.Provenance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
"route": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Revert": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -14272,6 +14272,9 @@
|
||||
"Config.ModelEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14377,6 +14380,9 @@
|
||||
"Config.ProviderEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16309,6 +16315,9 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17242,6 +17251,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": {
|
||||
@@ -17264,6 +17284,9 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18221,11 +18244,20 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"recent": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18448,6 +18480,9 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18771,6 +18806,46 @@
|
||||
"Session.Metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.ProviderContext": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"enum": [1]
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
|
||||
},
|
||||
"messages": {}
|
||||
},
|
||||
"required": ["version", "provenance", "messages"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.ProviderContext.Provenance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
"route": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Revert": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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 up to approximately 64,000 tokens of whole, real user
|
||||
messages and attachments, 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