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