Compare commits

...
22 changed files with 951 additions and 53 deletions
+14
View File
@@ -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
}
+83 -34
View File
@@ -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"
@@ -510,19 +519,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 +533,8 @@ export type SessionMessageCompactionFailed = {
error: SessionStructuredError
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
export type SessionInboxSynthetic = {
id: string
sessionID: string
@@ -1343,23 +1341,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 = {
@@ -1740,10 +1721,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
@@ -2082,6 +2090,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 +3090,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 +3381,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 +3672,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"
+8 -1
View File
@@ -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"
@@ -147,7 +148,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)
@@ -406,6 +412,7 @@ export const layer = Layer.effect(
},
transcript: {
system: transcript.system,
providerContext: transcript.providerContext,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
+7 -1
View File
@@ -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,
+8 -1
View File
@@ -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)] : []),
+69 -10
View File
@@ -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 },
}),
+26 -1
View File
@@ -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))
+3
View File
@@ -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)
+31 -3
View File
@@ -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
@@ -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))
+1
View File
@@ -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,
@@ -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",
})
}),
)
+89
View File
@@ -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"
+52
View File
@@ -18221,11 +18221,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 +18457,9 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {
@@ -18771,6 +18783,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": {
+1
View File
@@ -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,
},
+2
View File
@@ -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" })
@@ -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()
})
+52
View File
@@ -18221,11 +18221,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 +18457,9 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {
@@ -18771,6 +18783,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": {
+52
View File
@@ -18221,11 +18221,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 +18457,9 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {
@@ -18771,6 +18783,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": {