feat(core): persist v2 session context epochs

This commit is contained in:
Kit Langton 2026-06-04 14:38:31 -04:00
parent 64dc6d39ab
commit 83916f667d
39 changed files with 9303 additions and 80 deletions

View file

@ -0,0 +1,7 @@
CREATE TABLE `session_context_epoch` (
`session_id` text PRIMARY KEY,
`baseline` text NOT NULL,
`checkpoint` text NOT NULL,
`baseline_seq` integer NOT NULL,
CONSTRAINT `fk_session_context_epoch_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
CREATE TABLE `session_context_message` (
`session_id` text NOT NULL,
`seq` integer NOT NULL,
`parts` text NOT NULL,
CONSTRAINT `session_context_message_pk` PRIMARY KEY(`session_id`, `seq`),
CONSTRAINT `fk_session_context_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
CREATE INDEX `session_context_message_session_seq_idx` ON `session_context_message` (`session_id`,`seq`);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
ALTER TABLE `session_context_epoch` ADD `replacement_pending` integer DEFAULT false NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer;

File diff suppressed because it is too large Load diff

View file

@ -32,5 +32,9 @@ export const migrations = (
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260604180746_add_session_context_epoch"),
import("./migration/20260604181329_add_session_context_updates"),
import("./migration/20260604181706_add_session_context_replacement"),
import("./migration/20260604181807_add_session_context_replacement_sequence"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,19 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604180746_add_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`checkpoint\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,23 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604181329_add_session_context_updates",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_context_message\` (
\`session_id\` text NOT NULL,
\`seq\` integer NOT NULL,
\`parts\` text NOT NULL,
CONSTRAINT \`session_context_message_pk\` PRIMARY KEY(\`session_id\`, \`seq\`),
CONSTRAINT \`fk_session_context_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`revision\` integer DEFAULT 0 NOT NULL;`)
yield* tx.run(
`CREATE INDEX \`session_context_message_session_seq_idx\` ON \`session_context_message\` (\`session_id\`,\`seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604181706_add_session_context_replacement",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`replacement_pending\` integer DEFAULT false NOT NULL;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604181807_add_session_context_replacement_sequence",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`replacement_seq\` integer;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -40,6 +40,7 @@ import { RequestExecutor } from "@opencode-ai/llm/route"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionRunCoordinator } from "./session/run-coordinator"
import { SessionSystemContext } from "./session-system-context"
import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
@ -60,6 +61,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Watcher.locationLayer,
Pty.locationLayer,
SkillV2.locationLayer,
SessionSystemContext.locationLayer,
permissionsAndTools,
LocationMutation.locationLayer.pipe(Layer.orDie),
).pipe(Layer.provideMerge(location))

View file

@ -0,0 +1,184 @@
export * as SessionContextEpoch from "./context-epoch"
import { and, eq, sql } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import type { Database } from "../database/database"
import { EventV2 } from "../event"
import { SessionSystemContext } from "../session-system-context"
import { SystemContext } from "../system-context"
import { SessionEvent } from "./event"
import { SessionSchema } from "./schema"
import { SessionContextEpochTable, SessionContextMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const sameBaseline = Schema.toEquivalence(SystemContext.PartsSchema)
const sameCheckpoint = Schema.toEquivalence(SystemContext.CheckpointSchema)
export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
db: DatabaseService,
events: EventV2.Interface,
context: SessionSystemContext.Interface,
sessionID: SessionSchema.ID,
) {
const snapshot = yield* context.load()
const stored = yield* find(db, sessionID)
if (!stored) {
const initialized = SystemContext.initialize(snapshot)
yield* events.publish(SessionEvent.ContextInitialized, {
sessionID,
timestamp: yield* DateTime.now,
baseline: initialized.baseline,
checkpoint: initialized.checkpoint,
})
return initialized.baseline
}
if (stored.replacement_pending) {
const initialized = SystemContext.initialize(snapshot)
yield* events.publish(SessionEvent.ContextReplaced, {
sessionID,
timestamp: yield* DateTime.now,
expectedRevision: stored.revision,
baseline: initialized.baseline,
checkpoint: initialized.checkpoint,
})
return initialized.baseline
}
const refreshed = SystemContext.refresh(snapshot, stored.checkpoint)
if (sameCheckpoint(refreshed.checkpoint, stored.checkpoint)) return stored.baseline
yield* events.publish(SessionEvent.ContextUpdated, {
sessionID,
timestamp: yield* DateTime.now,
expectedRevision: stored.revision,
parts: refreshed.changes,
checkpoint: refreshed.checkpoint,
})
return stored.baseline
})
export const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
})
export const projectInitialized = Effect.fn("SessionContextEpoch.projectInitialized")(function* (
db: DatabaseService,
event: SessionEvent.ContextInitialized,
seq: number,
) {
const stored = yield* find(db, event.data.sessionID)
if (stored) {
if (stored.baseline_seq > seq) return yield* Effect.void
if (stored.baseline_seq !== seq || !sameBaseline(stored.baseline, event.data.baseline))
return yield* Effect.die("Session context epoch initialization conflicts with stored baseline")
return yield* Effect.void
}
return yield* db
.insert(SessionContextEpochTable)
.values({
session_id: event.data.sessionID,
baseline: event.data.baseline,
checkpoint: event.data.checkpoint,
baseline_seq: seq,
replacement_pending: false,
replacement_seq: null,
revision: 0,
})
.run()
.pipe(Effect.orDie)
})
export const projectUpdated = Effect.fn("SessionContextEpoch.projectUpdated")(function* (
db: DatabaseService,
event: SessionEvent.ContextUpdated,
seq: number,
) {
const stored = yield* find(db, event.data.sessionID)
if (!stored) return yield* Effect.die("Session context epoch is not initialized")
if (stored.replacement_pending) return yield* Effect.die("Session context epoch replacement is pending")
if (stored.revision > event.data.expectedRevision) {
if (event.data.parts.length === 0) return yield* Effect.void
const projected = yield* db
.select({ parts: SessionContextMessageTable.parts })
.from(SessionContextMessageTable)
.where(
and(eq(SessionContextMessageTable.session_id, event.data.sessionID), eq(SessionContextMessageTable.seq, seq)),
)
.get()
.pipe(Effect.orDie)
if (projected && sameBaseline(projected.parts, event.data.parts)) return yield* Effect.void
return yield* Effect.die("Session context update conflicts with stored projection")
}
const updated = yield* db
.update(SessionContextEpochTable)
.set({ checkpoint: event.data.checkpoint, revision: event.data.expectedRevision + 1 })
.where(
and(
eq(SessionContextEpochTable.session_id, event.data.sessionID),
eq(SessionContextEpochTable.revision, event.data.expectedRevision),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Session context epoch revision mismatch")
if (event.data.parts.length === 0) return yield* Effect.void
return yield* db
.insert(SessionContextMessageTable)
.values({ session_id: event.data.sessionID, seq, parts: event.data.parts })
.run()
.pipe(Effect.orDie)
})
export const projectReplaced = Effect.fn("SessionContextEpoch.projectReplaced")(function* (
db: DatabaseService,
event: SessionEvent.ContextReplaced,
seq: number,
) {
const stored = yield* find(db, event.data.sessionID)
if (!stored) return yield* Effect.die("Session context epoch is not initialized")
if (!stored.replacement_pending) {
if (stored.baseline_seq === seq && sameBaseline(stored.baseline, event.data.baseline)) return yield* Effect.void
return yield* Effect.die("Session context epoch replacement was not requested")
}
const updated = yield* db
.update(SessionContextEpochTable)
.set({
baseline: event.data.baseline,
checkpoint: event.data.checkpoint,
baseline_seq: seq,
replacement_pending: false,
replacement_seq: null,
revision: event.data.expectedRevision + 1,
})
.where(
and(
eq(SessionContextEpochTable.session_id, event.data.sessionID),
eq(SessionContextEpochTable.revision, event.data.expectedRevision),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Session context epoch revision mismatch")
return yield* Effect.void
})
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
seq: number,
) {
const stored = yield* find(db, sessionID)
if (!stored || stored.baseline_seq >= seq || stored.replacement_seq === seq) return yield* Effect.void
return yield* db
.update(SessionContextEpochTable)
.set({ replacement_pending: true, replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
.where(eq(SessionContextEpochTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
})

View file

@ -4,14 +4,18 @@ import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionMessageTable } from "./sql"
import { SessionContextEpochTable, SessionContextMessageTable, SessionMessageTable } from "./sql"
import type { SystemContext } from "../system-context"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
export type RunnerMessage =
| SessionMessage.Message
| { readonly type: "system-context"; readonly parts: ReadonlyArray<SystemContext.Part> }
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* db
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
@ -19,7 +23,14 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ
.limit(1)
.get()
.pipe(Effect.orDie)
const rows = yield* db
})
const messageRows = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
compaction: typeof SessionMessageTable.$inferSelect | undefined,
) {
return yield* db
.select()
.from(SessionMessageTable)
.where(
@ -31,17 +42,79 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return yield* Effect.forEach(rows, (row) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
})
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
)
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)),
decodeMessageRow,
)
})
export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const compaction = yield* latestCompaction(db, sessionID)
const messages = yield* messageRows(db, sessionID, compaction)
const epoch = yield* db
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
const updates = yield* db
.select()
.from(SessionContextMessageTable)
.where(
and(
eq(SessionContextMessageTable.session_id, sessionID),
epoch ? gt(SessionContextMessageTable.seq, epoch.baselineSeq) : undefined,
),
)
.orderBy(asc(SessionContextMessageTable.seq))
.all()
.pipe(Effect.orDie)
return yield* Effect.forEach(
merge(
messages.map((row) => ({ type: "message" as const, seq: row.seq, row })),
updates.map((row) => ({ type: "system-context" as const, seq: row.seq, row })),
),
(item): Effect.Effect<RunnerMessage, MessageDecodeError> =>
item.type === "message"
? decodeMessageRow(item.row)
: Effect.succeed({ type: "system-context", parts: item.row.parts }),
)
})
function merge<Left extends { readonly seq: number }, Right extends { readonly seq: number }>(
left: ReadonlyArray<Left>,
right: ReadonlyArray<Right>,
): Array<Left | Right> {
const result: Array<Left | Right> = []
let leftIndex = 0
let rightIndex = 0
while (leftIndex < left.length || rightIndex < right.length) {
if (rightIndex >= right.length || (leftIndex < left.length && left[leftIndex].seq < right[rightIndex].seq)) {
result.push(left[leftIndex])
leftIndex++
continue
}
result.push(right[rightIndex])
rightIndex++
}
return result
}
export * as SessionContext from "./context"

View file

@ -10,6 +10,7 @@ import { SessionSchema } from "./schema"
import { Location } from "../location"
import { RelativePath } from "../schema"
import { SessionMessageID } from "./message-id"
import { SystemContext } from "../system-context"
export { FileAttachment }
@ -119,6 +120,41 @@ export namespace PromptLifecycle {
export type Promoted = typeof Promoted.Type
}
export const ContextInitialized = EventV2.define({
type: "session.next.context.initialized",
...options,
schema: {
...Base,
baseline: SystemContext.PartsSchema,
checkpoint: SystemContext.CheckpointSchema,
},
})
export type ContextInitialized = typeof ContextInitialized.Type
export const ContextUpdated = EventV2.define({
type: "session.next.context.updated",
...options,
schema: {
...Base,
expectedRevision: NonNegativeInt,
parts: SystemContext.PartsSchema,
checkpoint: SystemContext.CheckpointSchema,
},
})
export type ContextUpdated = typeof ContextUpdated.Type
export const ContextReplaced = EventV2.define({
type: "session.next.context.replaced",
...options,
schema: {
...Base,
expectedRevision: NonNegativeInt,
baseline: SystemContext.PartsSchema,
checkpoint: SystemContext.CheckpointSchema,
},
})
export type ContextReplaced = typeof ContextReplaced.Type
export const Synthetic = EventV2.define({
type: "session.next.synthetic",
...options,
@ -444,6 +480,9 @@ const DurableDefinitions = [
Prompted,
PromptLifecycle.Admitted,
PromptLifecycle.Promoted,
ContextInitialized,
ContextUpdated,
ContextReplaced,
Synthetic,
Shell.Started,
Shell.Ended,

View file

@ -159,6 +159,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.next.prompt.admitted": () => Effect.void,
"session.next.prompt.promoted": () => Effect.void,
"session.next.context.initialized": () => Effect.void,
"session.next.context.updated": () => Effect.void,
"session.next.context.replaced": () => Effect.void,
"session.next.synthetic": (event) => {
return adapter.appendMessage(
new SessionMessage.Synthetic({

View file

@ -11,6 +11,7 @@ import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionInput } from "./input"
import { WorkspaceV2 } from "../workspace"
import { SessionContextEpoch } from "./context-epoch"
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
@ -352,12 +353,18 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
)
yield* events.project(SessionEvent.ModelSwitched, (event) =>
db
.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
Effect.gen(function* () {
yield* db
.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* run(db, event)
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)
}),
)
yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () {
@ -413,6 +420,18 @@ export const layer = Layer.effectDiscard(
)
}),
)
yield* events.project(SessionEvent.ContextInitialized, (event) => {
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return SessionContextEpoch.projectInitialized(db, event, event.seq)
})
yield* events.project(SessionEvent.ContextUpdated, (event) => {
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return SessionContextEpoch.projectUpdated(db, event, event.seq)
})
yield* events.project(SessionEvent.ContextReplaced, (event) => {
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return SessionContextEpoch.projectReplaced(db, event, event.seq)
})
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
@ -432,7 +451,12 @@ export const layer = Layer.effectDiscard(
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return run(db, event).pipe(
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
)
})
}),
)

View file

@ -1,4 +1,4 @@
import { LLM, LLMClient, LLMError, LLMEvent } from "@opencode-ai/llm"
import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
@ -14,6 +14,8 @@ import { SessionRunnerModel } from "./model"
import { Database } from "../../database/database"
import { SessionInput } from "../input"
import { QuestionV2 } from "../../question"
import { SessionSystemContext } from "../../session-system-context"
import { SessionContextEpoch } from "../context-epoch"
/**
* Runs one durable coding-agent Session until it settles.
@ -85,6 +87,7 @@ export const layer = Layer.effect(
const tools = yield* ToolRegistry.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const systemContext = yield* SessionSystemContext.Service
const db = (yield* Database.Service).db
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
@ -95,6 +98,9 @@ export const layer = Layer.effect(
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
return yield* store.context(sessionID)
})
const getRunnerContext = Effect.fn("SessionRunner.getRunnerContext")(function* (sessionID: SessionSchema.ID) {
return yield* store.runnerContext(sessionID)
})
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
sessionID: SessionSchema.ID,
@ -141,8 +147,14 @@ export const layer = Layer.effect(
}
}
yield* failInterruptedTools(session.id)
const context = yield* getContext(session.id)
const request = LLM.request({ model, messages: toLLMMessages(context, model), tools: yield* tools.definitions() })
const system = yield* SessionContextEpoch.prepare(db, events, systemContext, session.id)
const context = yield* getRunnerContext(session.id)
const request = LLM.request({
model,
system: system.map((part) => SystemPart.make(part.text)),
messages: toLLMMessages(context, model),
tools: yield* tools.definitions(),
})
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: session.agent ?? "build",

View file

@ -9,6 +9,8 @@ import {
} from "@opencode-ai/llm"
import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
import { SessionContext } from "../context"
import { SystemContext } from "../../system-context"
const media = (file: FileAttachment): ContentPart => ({
type: "media",
@ -91,7 +93,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]
}
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
function toLLMMessage(message: SessionContext.RunnerMessage, model: Model): Message[] {
switch (message.type) {
case "agent-switched":
case "model-switched":
@ -131,9 +133,11 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
metadata: message.metadata,
}),
]
case "system-context":
return [Message.system(SystemContext.render(message.parts))]
}
}
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) =>
export const toLLMMessages = (messages: readonly SessionContext.RunnerMessage[], model: Model) =>
messages.flatMap((message) => toLLMMessage(message, model))

View file

@ -11,6 +11,7 @@ import type { SessionSchema } from "./schema"
import type { MessageID, PartID, SessionV1 } from "../v1/session"
import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { SystemContext } from "../system-context"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@ -161,3 +162,32 @@ export const SessionInputTable = sqliteTable(
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
],
)
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
session_id: text()
.$type<SessionSchema.ID>()
.primaryKey()
.references(() => SessionTable.id, { onDelete: "cascade" }),
baseline: text({ mode: "json" }).notNull().$type<ReadonlyArray<SystemContext.Part>>(),
checkpoint: text({ mode: "json" }).notNull().$type<SystemContext.Checkpoint>(),
baseline_seq: integer().notNull(),
replacement_pending: integer({ mode: "boolean" }).notNull().default(false),
replacement_seq: integer(),
revision: integer().notNull().default(0),
})
export const SessionContextMessageTable = sqliteTable(
"session_context_message",
{
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
seq: integer().notNull(),
parts: text({ mode: "json" }).notNull().$type<ReadonlyArray<SystemContext.Part>>(),
},
(table) => [
primaryKey({ columns: [table.session_id, table.seq] }),
index("session_context_message_session_seq_idx").on(table.session_id, table.seq),
],
)

View file

@ -13,6 +13,9 @@ import { fromRow } from "./info"
export interface Interface {
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly runnerContext: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionContext.RunnerMessage[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
@ -34,6 +37,9 @@ export const layer = Layer.effect(
context: Effect.fn("SessionStore.context")(function* (sessionID) {
return yield* SessionContext.load(db, sessionID)
}),
runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID) {
return yield* SessionContext.loadForRunner(db, sessionID)
}),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
.select()

View file

@ -49,6 +49,13 @@ export interface Part {
readonly text: string
}
export const PartSchema = Schema.Struct({
key: Key,
text: Schema.String,
})
export const PartsSchema = Schema.Array(PartSchema)
export const CheckpointSchema = Schema.Record(Schema.String, Schema.String)
export type Checkpoint = Readonly<Record<string, string>>
export interface Initialized {
@ -105,11 +112,18 @@ export function initialize(snapshot: Snapshot): Initialized {
export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed {
return {
changes: snapshot.entries.flatMap((entry) =>
entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash
? [{ key: entry.key, text: entry.update }]
: [],
),
changes: [
...snapshot.entries.flatMap((entry) =>
entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash
? [{ key: entry.key, text: entry.update }]
: [],
),
...Object.keys(previous).flatMap((key) =>
snapshot.entries.some((entry) => entry.key === key)
? []
: [{ key: Key.make(key), text: `System context component removed: ${key}` }],
),
],
checkpoint: nextCheckpoint(snapshot, previous),
}
}

View file

@ -63,15 +63,22 @@ describe("DatabaseMigration", () => {
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
).toEqual({ name: "session_input" })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 30 })
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
).toEqual({ name: "session_context_epoch" })
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_message'`),
).toEqual({ name: "session_context_message" })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 34 })
expect(
yield* db.all(
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_context_message_session_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
),
).toEqual([
{ name: "event_aggregate_seq_idx" },
{ name: "event_aggregate_type_seq_idx" },
{ name: "session_input_session_admitted_seq_idx" },
{ name: "session_context_message_session_seq_idx" },
{ name: "session_input_session_pending_delivery_seq_idx" },
{ name: "session_input_session_promoted_seq_idx" },
{ name: "session_message_session_seq_idx" },

View file

@ -13,7 +13,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Recorded context\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
},
"response": {
"status": 200,

View file

@ -9,6 +9,7 @@ import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { DateTime } from "effect"
import { SystemContext } from "@opencode-ai/core/system-context"
const created = DateTime.makeUnsafe(0)
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
@ -86,6 +87,15 @@ describe("toLLMMessages", () => {
])
})
test("maps hidden Session context updates into chronological system messages", () => {
expect(
toLLMMessages(
[{ type: "system-context", parts: [{ key: SystemContext.Key.make("test/context"), text: "Updated context" }] }],
model,
),
).toEqual([Message.system("Updated context")])
})
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
const messages = toLLMMessages(
[

View file

@ -19,6 +19,9 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionSystemContext } from "@opencode-ai/core/session-system-context"
import { SystemContext } from "@opencode-ai/core/system-context"
import { Hash } from "@opencode-ai/core/util/hash"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
@ -55,6 +58,23 @@ const model = OpenAIChat.route
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContext = Layer.succeed(
SessionSystemContext.Service,
SessionSystemContext.Service.of({
load: () =>
Effect.succeed({
entries: [
{
_tag: "Available" as const,
key: SystemContext.Key.make("test/context"),
baseline: "Recorded context",
update: "Recorded context",
hash: Hash.sha256("Recorded context"),
},
],
}),
}),
)
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(database),
Layer.provide(store),
@ -62,6 +82,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(client),
Layer.provide(registry),
Layer.provide(models),
Layer.provide(systemContext),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
@ -88,6 +109,7 @@ const it = testEffect(
permission,
registry,
models,
systemContext,
runner,
coordinator,
execution,
@ -145,6 +167,7 @@ describe("SessionRunnerLLM recorded", () => {
).toEqual([
"session.next.prompt.admitted.1",
"session.next.prompt.promoted.1",
"session.next.context.initialized.1",
"session.next.step.started.1",
"session.next.text.started.1",
"session.next.text.ended.1",

View file

@ -34,6 +34,9 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { NativeTool } from "@opencode-ai/core/tool/native"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionSystemContext } from "@opencode-ai/core/session-system-context"
import { SystemContext } from "@opencode-ai/core/system-context"
import { Hash } from "@opencode-ai/core/util/hash"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
@ -135,6 +138,28 @@ const echo = Layer.effectDiscard(
),
).pipe(Layer.provide(registry))
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContextKey = SystemContext.Key.make("test/context")
let systemBaseline = "Initial context"
let systemRemoved = false
const systemContext = Layer.succeed(
SessionSystemContext.Service,
SessionSystemContext.Service.of({
load: () =>
Effect.sync(() => ({
entries: systemRemoved
? []
: [
{
_tag: "Available" as const,
key: systemContextKey,
baseline: systemBaseline,
update: systemBaseline,
hash: Hash.sha256(systemBaseline),
},
],
})),
}),
)
const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(database),
Layer.provide(store),
@ -142,6 +167,7 @@ const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(client),
Layer.provide(registry),
Layer.provide(models),
Layer.provide(systemContext),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
@ -170,6 +196,7 @@ const it = testEffect(
registry,
echo,
models,
systemContext,
runner,
coordinator,
execution,
@ -200,6 +227,8 @@ const insertSession = (id: SessionV2.ID) =>
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
response = []
systemBaseline = "Initial context"
systemRemoved = false
responses = undefined
streamFailure = undefined
responseStream = undefined
@ -511,6 +540,173 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("reuses one durable baseline after the context producer changes", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Initial context"],
])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }])
expect(yield* session.messages({ sessionID })).toHaveLength(2)
const { db } = yield* Database.Service
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.next.context.initialized.1"))
.all()
.pipe(Effect.orDie),
).toHaveLength(1)
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.next.context.updated.1"))
.all()
.pipe(Effect.orDie),
).toHaveLength(1)
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(2)
}),
)
it.effect("admits removed context as a hidden chronological tombstone", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemRemoved = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
expect(requests[1]?.messages.at(-1)?.content).toEqual([
{ type: "text", text: "System context component removed: test/context" },
])
expect(yield* session.messages({ sessionID })).toHaveLength(2)
}),
)
it.effect("replaces the baseline lazily after a model switch and drops prior hidden updates", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemBaseline = "Replacement context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Initial context"],
["Replacement context"],
])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"])
const { db } = yield* Database.Service
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.next.context.replaced.1"))
.all()
.pipe(Effect.orDie),
).toHaveLength(1)
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(4)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false })
yield* session.resume(sessionID)
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.next.context.replaced.1"))
.all()
.pipe(Effect.orDie),
).toHaveLength(1)
}),
)
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
timestamp: DateTime.makeUnsafe(2),
text: "summary",
})
systemBaseline = "Replacement context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Replacement context"],
])
const { db } = yield* Database.Service
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.next.context.replaced.1"))
.all()
.pipe(Effect.orDie),
).toHaveLength(1)
yield* replaySessionProjection(sessionID)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.next.context.replaced.1"))
.all()
.pipe(Effect.orDie),
).toHaveLength(1)
}),
)
it.effect("projects reasoning and tool events without executing or continuing tools", () =>
Effect.gen(function* () {
yield* setup

View file

@ -112,7 +112,7 @@ describe("SystemContext", () => {
expect(refreshed).toEqual({ changes: [], checkpoint: previous })
})
test("drops checkpoints for removed components", async () => {
test("emits tombstones and drops checkpoints for removed components", async () => {
const context = SystemContext.struct({
date: SystemContext.value({
key: key("core/date"),
@ -126,7 +126,7 @@ describe("SystemContext", () => {
})
expect(refreshed).toEqual({
changes: [],
changes: [{ key: key("plugin/removed"), text: "System context component removed: plugin/removed" }],
checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") },
})
})

View file

@ -351,38 +351,17 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
}
const endsInLocalToolUse = (message: LLMRequest["messages"][number]) => {
const last = message.content.at(-1)
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted !== true
}
const validateNativeSystemUpdate = Effect.fn("AnthropicMessages.validateNativeSystemUpdate")(function* (
messages: LLMRequest["messages"],
index: number,
) {
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
const previous = messages[index - 1]
const next = messages[index + 1]
if (!previous)
return yield* invalid(
"Anthropic Messages chronological system updates cannot be the first message; use LLMRequest.system",
)
if (previous.role === "system")
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
if (endsInLocalToolUse(previous))
return yield* invalid(
"Anthropic Messages chronological system updates cannot appear between a local tool call and its tool result",
)
if (previous.role !== "user" && previous.role !== "tool" && !endsInServerToolUse(previous))
return yield* invalid(
"Anthropic Messages chronological system updates must follow a user message, tool result, or assistant server tool use",
)
if (next?.role === "system")
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
if (next && next.role !== "assistant")
return yield* invalid(
"Anthropic Messages chronological system updates must end the messages array or immediately precede an assistant message",
)
})
return (
previous !== undefined &&
previous.role !== "system" &&
(previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) &&
next?.role !== "system" &&
(next === undefined || next.role === "assistant")
)
}
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
message: LLMRequest["messages"][number],
@ -407,8 +386,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
if (supportsNativeSystemUpdates(request)) {
yield* validateNativeSystemUpdate(request.messages, index)
yield* ProviderShared.guardSystemUpdatePlacement("Anthropic Messages", request.messages[index - 1])
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
}

View file

@ -292,8 +292,9 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
) {
const messages: BedrockMessage[] = []
for (const message of request.messages) {
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
yield* ProviderShared.guardSystemUpdatePlacement("Bedrock Converse", request.messages[index - 1])
const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message)
const content = textWithCache(breakpoints, part.text, part.cache)
const previous = messages.at(-1)

View file

@ -200,8 +200,9 @@ const lowerToolCall = (part: ToolCallPart) => ({
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
for (const message of request.messages) {
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
yield* ProviderShared.guardSystemUpdatePlacement("Gemini", request.messages[index - 1])
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1)
if (previous?.role === "user")

View file

@ -252,8 +252,9 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
const system: OpenAIChatMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
for (const message of request.messages) {
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Chat", request.messages[index - 1])
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
const previous = messages.at(-1)
if (previous?.role === "user")

View file

@ -338,8 +338,9 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const input: OpenAIResponsesInputItem[] = [...system]
const store = OpenAIOptions.store(request)
for (const message of request.messages) {
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Responses", request.messages[index - 1])
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user")

View file

@ -177,6 +177,20 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate
return { type: "text" as const, text: wrapSystemUpdate(content), cache: content.at(-1)?.cache }
})
export const guardSystemUpdatePlacement = Effect.fn("ProviderShared.guardSystemUpdatePlacement")(function* (
route: string,
previous: LLMRequest["messages"][number] | undefined,
) {
if (
previous?.role === "assistant" &&
previous.content.some((part) => part.type === "tool-call" && part.providerExecuted !== true)
)
return yield* invalidRequest(
`${route} chronological system updates cannot appear between a local tool call and its tool result`,
)
return yield* Effect.void
})
/**
* Parse the streamed JSON input of a tool call. Treats an empty string as
* `"{}"` providers occasionally finish a tool call without ever emitting

View file

@ -125,22 +125,56 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("rejects invalid native chronological system update placement", () =>
it.effect("falls back for unsupported native chronological system update placement", () =>
Effect.gen(function* () {
const placementError = (messages: Parameters<typeof LLM.request>[0]["messages"]) =>
LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip)
expect((yield* placementError([Message.system("First.")])).message).toContain("cannot be the first message")
expect(
(yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message,
).toContain("cannot be consecutive")
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model: opus48,
messages: [Message.assistant("Plain."), Message.system("After plain assistant.")],
cache: "none",
}),
)).body.messages,
).toEqual([
{ role: "assistant", content: [{ type: "text", text: "Plain." }] },
{
role: "user",
content: [{ type: "text", text: "<system-update>\nAfter plain assistant.\n</system-update>" }],
},
])
expect(
(yield* placementError([Message.assistant("Plain."), Message.system("After plain assistant.")])).message,
).toContain("must follow a user message, tool result, or assistant server tool use")
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }),
)).body.messages,
).toEqual([{ role: "user", content: [{ type: "text", text: "<system-update>\nFirst.\n</system-update>" }] }])
expect(
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model: opus48,
messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")],
cache: "none",
}),
)).body.messages,
).toEqual([
{
role: "user",
content: [
{ type: "text", text: "Before." },
{ type: "text", text: "<system-update>\nOne.\n</system-update>" },
{ type: "text", text: "<system-update>\nTwo.\n</system-update>" },
],
},
])
expect(
(yield* placementError([
Message.user("Use the tool."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
{ type: "text", text: "Waiting." },
]),
Message.system("Too early."),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
])).message,

View file

@ -1,8 +1,18 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses"
import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema"
import {
ContentPart,
LLMEvent,
LLMRequest,
Message,
Model,
ModelID,
ProviderID,
ToolCallPart,
Usage,
} from "../src/schema"
import { ProviderShared } from "../src/protocols/shared"
const model = new Model({
@ -54,6 +64,17 @@ describe("llm schema", () => {
expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] })
})
test("rejects chronological system updates between a local tool call and its result", async () => {
const previous = Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
{ type: "text", text: "Waiting." },
])
await expect(Effect.runPromise(ProviderShared.guardSystemUpdatePlacement("Test", previous))).rejects.toThrow(
"Test chronological system updates cannot appear between a local tool call and its tool result",
)
})
test("rejects invalid event type", () => {
expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
})

View file

@ -686,3 +686,65 @@ Compatibility:
- Foreground V2 bash execution is unchanged.
- Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics.
## 2026-06-04: Initialize Durable Session Context Epochs
Affected schema:
- Add synchronized `session.next.context.initialized.1` Session events.
- Add `session_context_epoch` for one active immutable keyed baseline, component-hash checkpoint, and baseline sequence per Session.
Change:
- Lazily initialize one durable Context Epoch at the first safe provider-turn boundary.
- Lower its exact keyed baseline parts through `LLMRequest.system` for every provider turn in the epoch.
- Reuse the stored baseline verbatim after restart or producer changes instead of resampling privileged initial context.
- Keep ordinary Session transcript APIs unchanged.
Compatibility:
- This adds one database migration and one synchronized Session event type.
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
- Chronological context updates, replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices.
## 2026-06-04: Admit Chronological Session Context Updates
Affected schema:
- Add synchronized `session.next.context.updated.1` Session events.
- Add `session_context_epoch.revision` for transactional checkpoint advancement.
- Add `session_context_message` for hidden chronological keyed context updates ordered by Session aggregate sequence.
Change:
- Refresh Location-scoped Context Components at each safe provider-turn boundary.
- Keep the stored baseline immutable while admitting changed component values as runner-private chronological `Message.system(...)` history.
- Advance component-hash checkpoints transactionally even when a removed component produces no visible update text.
- Keep ordinary Session transcript APIs unchanged while runner history merges visible Session messages and hidden context updates by durable aggregate sequence.
- Reject chronological system updates that would split a local tool call from its result across provider protocols; use wrapped user fallback when Anthropic native system-update placement is unsupported.
Compatibility:
- This adds one database migration and one synchronized Session event type.
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
- Replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices.
## 2026-06-04: Replace Session Context Epochs Lazily
Affected schema:
- Add synchronized `session.next.context.replaced.1` Session events.
- Add `session_context_epoch.replacement_pending` and `session_context_epoch.replacement_seq` for idempotent lazy replacement requests.
Change:
- Mark the active Context Epoch for replacement after a model switch or completed compaction projection.
- Persist the triggering aggregate sequence so same-target replay cannot reopen an already-settled replacement.
- Render and persist the fresh immutable baseline lazily at the next safe provider-turn boundary.
- Exclude hidden chronological updates from earlier epochs when assembling active provider history.
Compatibility:
- This adds two additive database migrations and one synchronized Session event type.
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
- Compaction execution, project instructions, skills guidance, and plugin transforms remain follow-up slices.