Compare commits

..
26 changed files with 213 additions and 954 deletions
-14
View File
@@ -967,20 +967,6 @@ 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
}
+34 -83
View File
@@ -138,15 +138,6 @@ 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"
@@ -519,6 +510,19 @@ 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 }
@@ -533,8 +537,6 @@ export type SessionMessageCompactionFailed = {
error: SessionStructuredError
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
export type SessionInboxSynthetic = {
id: string
sessionID: string
@@ -1341,6 +1343,23 @@ 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 = {
@@ -1721,37 +1740,10 @@ export type SessionMessageToolStateError = {
metadata?: { [x: string]: JsonValue }
}
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 SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionForked = {
id: string
@@ -2090,11 +2082,6 @@ export type SessionMessageAssistantTool = {
time: { created: number; ran?: number; completed?: number }
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
@@ -3090,18 +3077,6 @@ 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"
@@ -3381,18 +3356,6 @@ 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"
@@ -3672,18 +3635,6 @@ 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"
+1 -8
View File
@@ -21,7 +21,6 @@ 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"
@@ -148,12 +147,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,
input.resolved.model.route.providerMetadataKey ?? input.resolved.model.provider,
SessionProviderContext.provenance(input.resolved),
),
toLLMMessages(input.messages.slice(Math.max(0, index)), input.resolved.ref),
input.resolved.capabilities,
)
.filter((message) => message.role !== "assistant" || message.id !== last?.id)
@@ -412,7 +406,6 @@ export const layer = Layer.effect(
},
transcript: {
system: transcript.system,
providerContext: transcript.providerContext,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
+1 -7
View File
@@ -18,7 +18,6 @@ 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"
@@ -157,12 +156,7 @@ 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,
SessionProviderContext.provenance(model),
)
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
return {
session: selection.session,
agent: selection.agent,
+1 -8
View File
@@ -9,7 +9,6 @@ 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"
@@ -30,12 +29,7 @@ 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,
SessionProviderContext.provenance(model),
)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const transcript = SessionModelRequest.baseTranscript({
agent: selection.agent.info,
model,
@@ -48,7 +42,6 @@ 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)] : []),
+10 -69
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gte, or, sql } from "drizzle-orm"
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { MessageDecodeError } from "./error.js"
@@ -6,18 +6,13 @@ import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { Instructions } from "../instructions/index.js"
import { InstructionState } from "./instruction-state.js"
import { SessionProviderContext } from "./provider-context.js"
import { InstructionStateTable, SessionMessageTable } from "./sql.js"
import { 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,
target?: SessionProviderContext.Provenance,
) {
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
@@ -26,17 +21,6 @@ export const latestCompaction = Effect.fnUntraced(function* (
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))
@@ -47,11 +31,6 @@ export const latestCompaction = Effect.fnUntraced(function* (
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({
@@ -61,12 +40,8 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
),
)
const messageEntries = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
target?: SessionProviderContext.Provenance,
) {
const compaction = yield* latestCompaction(db, sessionID, target)
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* latestCompaction(db, sessionID)
const rows = yield* db
.select()
.from(SessionMessageTable)
@@ -79,57 +54,24 @@ const messageEntries = Effect.fnUntraced(function* (
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const entries = yield* Effect.forEach(rows, (row) =>
return 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)
)
})
})
/** 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 load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return (yield* messageEntries(db, sessionID)).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, target)
const messages = yield* messageEntries(db, sessionID)
return {
initial: yield* InstructionState.initial(db, sessionID, instructions),
entries: messages,
@@ -143,13 +85,12 @@ 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, target)
const messages = yield* messageEntries(db, sessionID)
// 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,7 +413,6 @@ 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
@@ -428,7 +427,6 @@ 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 },
}),
+1 -26
View File
@@ -15,7 +15,6 @@ 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"
@@ -75,8 +74,6 @@ 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"]
/**
@@ -96,13 +93,8 @@ 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
@@ -111,12 +103,7 @@ export const baseTranscript = (input: {
]
.filter((part) => part.length > 0)
.map(SystemPart.make),
messages: toLLMMessages(
input.messages,
input.model.ref,
providerMetadataKey,
SessionProviderContext.provenance(input.model),
),
messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey),
}
}
@@ -358,18 +345,6 @@ 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,7 +14,6 @@ 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"
@@ -692,8 +691,6 @@ 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)
}),
@@ -1,67 +0,0 @@
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)
+3 -31
View File
@@ -1,7 +1,7 @@
export * as SessionRunnerLLM from "./llm.js"
import { Message } from "@opencode-ai/ai"
import { and, asc, desc, eq, gt, sql } from "drizzle-orm"
import { and, desc, eq, sql } from "drizzle-orm"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
@@ -11,7 +11,6 @@ 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"
@@ -114,12 +113,7 @@ 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,
SessionProviderContext.provenance(model),
)
const history = yield* SessionHistory.preview(db, session.id, selected.instructions)
return {
session: selected.session,
agent: selected.agent,
@@ -227,7 +221,6 @@ 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,
@@ -320,28 +313,7 @@ const layer = Layer.effect(
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID,
) {
// 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)
for (const message of yield* store.context(sessionID)) {
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,7 +3,6 @@ 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"])
@@ -222,12 +221,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
]
}
function toLLMMessage(
message: SessionMessage.Info,
model: Model.Ref,
providerMetadataKey: string,
target?: SessionProviderContext.Provenance,
): Message[] {
function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMetadataKey: string): Message[] {
switch (message.type) {
case "agent-switched":
case "model-switched":
@@ -280,12 +274,6 @@ function toLLMMessage(
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,
@@ -312,5 +300,4 @@ export const toLLMMessages = (
messages: readonly SessionMessage.Info[],
model: Model.Ref,
providerMetadataKey: string = model.providerID,
target?: SessionProviderContext.Provenance,
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey, target))
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
-1
View File
@@ -53,7 +53,6 @@ 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,
+6 -2
View File
@@ -273,9 +273,13 @@ const layer = Layer.effect(
}),
)
// A tool is hidden from the model only when no resource could get past `deny`. Each rule's resource
// pattern matches itself as a literal, so the patterns for this action are a complete set of probes:
// a later broader rule overrides a probe exactly when it also covers every resource the probe covers.
// The extra "*" probe stands for resources no narrow rule covers, which fall back to the default `ask`.
const whollyDisabled = (action: string, rules: Permission.Ruleset) => {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny"
const probes = rules.filter((rule) => Wildcard.match(action, rule.action)).map((rule) => rule.resource)
return [...probes, "*"].every((resource) => Permission.evaluate(action, resource, rules).effect === "deny")
}
const formatSchemaIssue = SchemaIssue.makeFormatterDefault()
@@ -1,323 +0,0 @@
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,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import {
AIError,
CompactionPart,
HttpContext,
LLMEvent,
LLMRequest,
@@ -41,7 +40,6 @@ 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"
@@ -1437,93 +1435,6 @@ 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"
+49
View File
@@ -706,6 +706,55 @@ describe("Tool", () => {
}),
)
it.effect("hides tools whose narrower trailing rules cannot get past deny", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { bash: make() }, { codemode: false })
const names = (permissions: Permission.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
// trailing narrow deny rules leave every call denied
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
{ action: "bash", resource: "git *", effect: "deny" },
]),
).toEqual([])
// without a catch-all, uncovered resources fall back to ask
expect(yield* names([{ action: "bash", resource: "rm*", effect: "deny" }])).toEqual(["bash", "execute"])
// a narrow ask superseded by the same narrow deny admits nothing
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
{ action: "bash", resource: "rm*", effect: "ask" },
{ action: "bash", resource: "rm*", effect: "deny" },
]),
).toEqual([])
// a trailing narrow ask or allow still admits some calls
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
{ action: "bash", resource: "rm*", effect: "ask" },
]),
).toEqual(["bash"])
// a narrow allow that a later broader deny supersedes admits nothing
expect(
yield* names([
{ action: "bash", resource: "git *", effect: "allow" },
{ action: "bash", resource: "*", effect: "deny" },
]),
).toEqual(["execute"])
// a later narrower deny does not swallow the broader allow before it
expect(
yield* names([
{ action: "bash", resource: "*", effect: "deny" },
{ action: "bash", resource: "git *", effect: "allow" },
{ action: "bash", resource: "git push*", effect: "deny" },
]),
).toEqual(["bash", "execute"])
}),
)
it.effect("keeps permission options isolated between registrations", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
-52
View File
@@ -18221,20 +18221,11 @@
"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"],
@@ -18457,9 +18448,6 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {
@@ -18783,46 +18771,6 @@
"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,7 +587,6 @@ 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,7 +1,6 @@
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"
@@ -255,7 +254,6 @@ 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> {}
@@ -1,24 +0,0 @@
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,25 +48,3 @@ 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()
})
+13 -1
View File
@@ -2522,12 +2522,24 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme()
const [seconds, setSeconds] = createSignal(0)
createEffect(() => {
const at = props.retry?.at
if (at === undefined) return
const update = () => setSeconds(Math.max(0, Math.ceil((at - Date.now()) / 1_000)))
if (update() === 0) return
const timer = setInterval(() => {
if (update() === 0) clearInterval(timer)
}, 1_000)
onCleanup(() => clearInterval(timer))
})
return (
<Show when={props.retry}>
{(retry) => (
<box paddingLeft={3}>
<text fg={theme.text.feedback.warning.default}>
Retry attempt {retry().attempt} scheduled: {retry().error.message}
{seconds() > 0 ? `Retrying in ${seconds()}s` : "Retry due"} · attempt {retry().attempt} ·{" "}
{retry().error.message}
</text>
</box>
)}
+92
View File
@@ -1529,3 +1529,95 @@ test("server plugin failures share one notice and use source names before an ID
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
expect(setup.captureCharFrame()).toContain("Open plugins")
})
test.each([44, 100])(
"retry countdown updates and clears with the retry lifecycle at width %s",
async (width) => {
await using state = await tmpdir()
const session = {
id: "ses_countdown",
projectID: "proj_test",
location: { directory },
title: "Retry countdown",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
const model = { id: "model", providerID: "provider" }
const error = { type: "provider.transport" as const, message: "Provider unavailable" }
await using setup = await createAppFixture({
width,
state: state.path,
args: { sessionID: session.id },
config: { animations: false, tabs: { enabled: false } },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_countdown",
type: "assistant",
agent: "build",
model,
content: [],
error,
retry: { attempt: 2, at: Date.now() + 2_500, error },
time: { created: 1 },
},
],
cursor: {},
})
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
return json({ data: [] })
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Retrying in 3s"))
expect(setup.captureCharFrame()).toContain("attempt 2")
expect(setup.captureCharFrame()).toContain("Provider unavailable")
expect(setup.captureCharFrame()).not.toContain("Error:")
await setup.waitForFrame((frame) => frame.includes("Retrying in 2s"), { maxPasses: 200 })
await setup.waitForFrame((frame) => frame.includes("Retrying in 1s"), { maxPasses: 200 })
await setup.waitForFrame((frame) => frame.includes("Retry due"), { maxPasses: 200 })
expect(setup.captureCharFrame()).not.toContain("in 0s")
setup.events.emit({
id: "evt_countdown_rescheduled",
created: 2,
type: "session.retry.scheduled",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 3, at: Date.now() + 10_500, error },
})
await setup.waitForFrame((frame) => frame.includes("Retrying in 11s") && frame.includes("attempt 3"))
setup.events.emit({
id: "evt_countdown_started",
created: 3,
type: "session.step.started",
durable: { aggregateID: session.id, seq: 2, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", agent: "build", model },
})
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
setup.events.emit({
id: "evt_countdown_expired",
created: 4,
type: "session.retry.scheduled",
durable: { aggregateID: session.id, seq: 3, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 4, at: Date.now() - 1_000, error },
})
await setup.waitForFrame((frame) => frame.includes("Retry due") && frame.includes("attempt 4"))
expect(setup.captureCharFrame()).not.toContain("in -")
setup.events.emit({
id: "evt_countdown_interrupted",
created: 5,
type: "session.execution.interrupted",
durable: { aggregateID: session.id, seq: 4, version: 1 },
data: { sessionID: session.id, reason: "shutdown" },
})
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
},
15_000,
)
-52
View File
@@ -18221,20 +18221,11 @@
"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"],
@@ -18457,9 +18448,6 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {
@@ -18783,46 +18771,6 @@
"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,20 +18221,11 @@
"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"],
@@ -18457,9 +18448,6 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {
@@ -18783,46 +18771,6 @@
"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": {