refactor(core): simplify v2 system context epochs

This commit is contained in:
Kit Langton 2026-06-04 19:49:19 -04:00
commit 00c4114911
30 changed files with 997 additions and 828 deletions

View file

@ -1,17 +0,0 @@
CREATE TABLE `session_context_epoch` (
`session_id` text PRIMARY KEY,
`baseline` text NOT NULL,
`checkpoint` text NOT NULL,
`baseline_seq` integer NOT NULL,
`replacement_seq` integer,
`revision` integer DEFAULT 0 NOT NULL,
CONSTRAINT `fk_session_context_epoch_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
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
);

View file

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

View file

@ -1,7 +1,7 @@
{
"version": "7",
"dialect": "sqlite",
"id": "6fe30aa9-5772-49a6-b688-d022dbd28903",
"id": "65b52d0f-2bbe-483f-a7ec-9d2a7fa29f57",
"prevIds": [
"fc92fa34-8074-44c3-88f0-a5417f7fd92d"
],
@ -58,10 +58,6 @@
"name": "session_context_epoch",
"entityType": "tables"
},
{
"name": "session_context_message",
"entityType": "tables"
},
{
"name": "session_input",
"entityType": "tables"
@ -808,7 +804,7 @@
"autoincrement": false,
"default": null,
"generated": null,
"name": "checkpoint",
"name": "snapshot",
"entityType": "columns",
"table": "session_context_epoch"
},
@ -842,36 +838,6 @@
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_context_message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "seq",
"entityType": "columns",
"table": "session_context_message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "parts",
"entityType": "columns",
"table": "session_context_message"
},
{
"type": "integer",
"notNull": false,
@ -1552,21 +1518,6 @@
"entityType": "fks",
"table": "session_context_epoch"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_context_message_session_id_session_id_fk",
"entityType": "fks",
"table": "session_context_message"
},
{
"columns": [
"session_id"
@ -1662,16 +1613,6 @@
"entityType": "pks",
"table": "project_directory"
},
{
"columns": [
"session_id",
"seq"
],
"nameExplicit": false,
"name": "session_context_message_pk",
"entityType": "pks",
"table": "session_context_message"
},
{
"columns": [
"session_id",

View file

@ -32,6 +32,6 @@ export const migrations = (
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260604184448_add_session_context_epoch"),
import("./migration/20260604234609_add_session_context_snapshot"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -2,29 +2,20 @@ import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604184448_add_session_context_epoch",
id: "20260604234609_add_session_context_snapshot",
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,
\`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
\`replacement_seq\` integer,
\`revision\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
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
);
`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -45,6 +45,8 @@ export type Payload<D extends Definition = Definition> = {
readonly version?: number
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
/** Internal replay marker for projectors that own non-replicated operational state. */
readonly replay?: boolean
}
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
@ -137,6 +139,8 @@ export interface PublishOptions {
readonly id?: ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void>
}
export interface Interface {
@ -151,6 +155,7 @@ export interface Interface {
readonly aggregateID: string
readonly after?: Cursor
}) => Stream.Stream<CursorEvent>
readonly sequence: (aggregateID: string) => Effect.Effect<number>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
@ -215,6 +220,7 @@ export const layerWith = (options?: LayerOptions) =>
readonly ownerID?: string
readonly strictOwner?: boolean
},
commit?: (seq: number) => Effect.Effect<void>,
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
@ -330,6 +336,10 @@ export const layerWith = (options?: LayerOptions) =>
for (const projector of list) {
yield* projector({ ...event, seq } as Payload)
}
if (commit) yield* commit(seq)
const encoded = syncRegistry
.get(versionedType(definition.type, sync.version))!
.encode(event.data)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
@ -375,11 +385,15 @@ export const layerWith = (options?: LayerOptions) =>
})
}
function publishEvent<D extends Definition>(event: Payload<D>) {
function publishEvent<D extends Definition>(event: Payload<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined
if (!durable && options?.commit)
return yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: "Local commit hooks require a synchronized event" }),
)
if (durable) {
const committed = yield* commitSyncEvent(event as Payload)
const committed = yield* commitSyncEvent(event as Payload, undefined, options?.commit)
if (committed) {
event = { ...event, seq: committed.seq }
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
@ -431,7 +445,7 @@ export const layerWith = (options?: LayerOptions) =>
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>)
} as Payload<D>, options)
})
}
@ -451,6 +465,7 @@ export const layerWith = (options?: LayerOptions) =>
type: definition.type,
version: definition.sync.version,
data: definition.decode(event.data),
replay: true,
} as Payload
const committed = yield* commitSyncEvent(payload, {
seq: event.seq,
@ -519,6 +534,14 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
}
const sequence = (aggregateID: string) =>
db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie, Effect.map((row) => row?.seq ?? -1))
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
@ -646,6 +669,7 @@ export const layerWith = (options?: LayerOptions) =>
subscribe,
all: streamAll,
aggregateEvents: streamEvents,
sequence,
sync,
listen,
beforeCommit,

View file

@ -1,11 +1,11 @@
export * as SessionSystemContext from "./session-system-context"
import { Context, DateTime, Effect, Layer } from "effect"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Location } from "./location"
import { SystemContext } from "./system-context"
export interface Interface {
readonly load: () => Effect.Effect<SystemContext.Snapshot>
readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionSystemContext") {}
@ -22,32 +22,25 @@ export const layer = Layer.effect(
` Platform: ${process.platform}`,
"</env>",
].join("\n")
const context = SystemContext.struct({
environment: SystemContext.value({
const context = SystemContext.combine([
SystemContext.make({
key: SystemContext.Key.make("core/environment"),
load: Effect.succeed({
baseline: ["Here is some useful information about the environment you are running in:", environment].join(
"\n",
),
update: ["The environment you are running in is now:", environment].join("\n"),
}),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(environment),
baseline: (environment) =>
["Here is some useful information about the environment you are running in:", environment].join("\n"),
update: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"),
}),
date: SystemContext.value({
SystemContext.make({
key: SystemContext.Key.make("core/date"),
load: DateTime.nowAsDate.pipe(
Effect.map((date) => ({
baseline: `Today's date: ${date.toDateString()}`,
update: `Today's date is now: ${date.toDateString()}`,
})),
),
codec: Schema.toCodecJson(Schema.String),
load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
baseline: (date) => `Today's date: ${date}`,
update: (_previous, date) => `Today's date is now: ${date}`,
}),
})
])
return Service.of({
load: Effect.fn("SessionSystemContext.load")(function* () {
return yield* SystemContext.load(context)
}),
})
return Service.of({ load: () => Effect.succeed(context) })
}),
)

View file

@ -1,18 +1,17 @@
export * as SessionContextEpoch from "./context-epoch"
import { and, eq, isNull, lt, sql } from "drizzle-orm"
import { and, eq, isNull, lt, or, 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 { SessionMessageID } from "./message-id"
import { SessionSchema } from "./schema"
import { SessionContextEpochTable, SessionContextMessageTable } from "./sql"
import { SessionContextEpochTable } 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,
@ -20,48 +19,33 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
context: SessionSystemContext.Interface,
sessionID: SessionSchema.ID,
) {
const snapshot = yield* context.load()
const stored = yield* find(db, sessionID)
const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" })
if (!stored) {
const initialized = SystemContext.initialize(snapshot)
const event = yield* events.publish(SessionEvent.ContextInitialized, {
sessionID,
timestamp: yield* DateTime.now,
baseline: initialized.baseline,
checkpoint: initialized.checkpoint,
})
if (event.seq === undefined) return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
return { baseline: initialized.baseline, baselineSeq: event.seq }
}
if (stored.replacement_seq !== null) {
if (SystemContext.replacementBlocked(snapshot, stored.checkpoint))
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
const initialized = SystemContext.initialize(snapshot)
const event = yield* events.publish(SessionEvent.ContextReplaced, {
sessionID,
timestamp: yield* DateTime.now,
expectedRevision: stored.revision,
baseline: initialized.baseline,
checkpoint: initialized.checkpoint,
})
if (event.seq === undefined) return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
return { baseline: initialized.baseline, baselineSeq: event.seq }
const generation = yield* SystemContext.initialize(value)
const baselineSeq = yield* initialize(db, events, sessionID, generation)
return { baseline: generation.baseline, baselineSeq }
}
const refreshed = SystemContext.refresh(snapshot, stored.checkpoint)
if (sameCheckpoint(refreshed.checkpoint, stored.checkpoint))
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie)
const result =
stored.replacement_seq === null ? yield* SystemContext.reconcile(value, snapshot) : yield* SystemContext.replace(value, snapshot)
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked")
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
yield* events.publish(SessionEvent.ContextUpdated, {
sessionID,
timestamp: yield* DateTime.now,
expectedRevision: stored.revision,
parts: refreshed.changes,
checkpoint: refreshed.checkpoint,
})
if (result._tag === "Replaced") {
const replacementSeq = stored.replacement_seq ?? (yield* events.sequence(sessionID))
yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation)
return { baseline: result.generation.baseline, baselineSeq: replacementSeq }
}
yield* events.publish(
SessionEvent.ContextUpdated,
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
{ commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) },
)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
})
export const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.from(SessionContextEpochTable)
@ -70,110 +54,6 @@ export const find = Effect.fn("SessionContextEpoch.find")(function* (db: Databas
.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_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.baseline_seq > seq) return yield* Effect.void
if (stored.replacement_seq !== null && seq >= stored.replacement_seq)
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.baseline_seq > seq) return yield* Effect.void
if (stored.baseline_seq === seq && sameBaseline(stored.baseline, event.data.baseline)) return yield* Effect.void
if (stored.replacement_seq === null) {
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_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,
@ -185,10 +65,97 @@ export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacem
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
isNull(SessionContextEpochTable.replacement_seq),
lt(SessionContextEpochTable.baseline_seq, seq),
or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)),
),
)
.run()
.pipe(Effect.orDie)
})
const initialize = Effect.fnUntraced(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
generation: SystemContext.Generation,
) {
return yield* db
.transaction(
() =>
Effect.gen(function* () {
const baselineSeq = yield* events.sequence(sessionID)
yield* db
.insert(SessionContextEpochTable)
.values({
session_id: sessionID,
baseline: generation.baseline,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
revision: 0,
})
.run()
.pipe(Effect.orDie)
return baselineSeq
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const replace = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
expectedRevision: number,
baselineSeq: number,
generation: SystemContext.Generation,
) {
yield* db
.transaction(
() =>
Effect.gen(function* () {
const updated = yield* db
.update(SessionContextEpochTable)
.set({
baseline: generation.baseline,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
replacement_seq: null,
revision: expectedRevision + 1,
})
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
eq(SessionContextEpochTable.revision, expectedRevision),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Session context epoch revision mismatch")
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const advance = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
expectedRevision: number,
snapshot: SystemContext.Snapshot,
) {
const updated = yield* db
.update(SessionContextEpochTable)
.set({ snapshot, revision: expectedRevision + 1 })
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
eq(SessionContextEpochTable.revision, expectedRevision),
isNull(SessionContextEpochTable.replacement_seq),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Session context epoch revision mismatch")
})

View file

@ -1,22 +1,18 @@
import { and, asc, desc, eq, gt, gte, or } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionContextMessageTable, SessionMessageTable } from "./sql"
import type { SystemContext } from "../system-context"
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
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> }
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.seq))
@ -28,7 +24,8 @@ const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessi
const messageRows = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
compaction: typeof SessionMessageTable.$inferSelect | undefined,
compaction: { readonly seq: number } | undefined,
baselineSeq?: number,
) {
return yield* db
.select()
@ -36,7 +33,17 @@ const messageRows = Effect.fnUntraced(function* (
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction ? or(gte(SessionMessageTable.seq, compaction.seq)) : undefined,
compaction
? or(
gte(SessionMessageTable.seq, compaction.seq),
baselineSeq === undefined
? undefined
: and(eq(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
)
: undefined,
baselineSeq === undefined
? undefined
: or(ne(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
),
)
.orderBy(asc(SessionMessageTable.seq))
@ -56,8 +63,20 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
)
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const [epoch, compaction] = yield* Effect.all(
[
db
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
latestCompaction(db, sessionID),
],
{ concurrency: "unbounded" },
)
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)),
yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq),
decodeMessageRow,
)
})
@ -67,44 +86,7 @@ export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function*
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
const compaction = yield* latestCompaction(db, sessionID)
const messages = yield* messageRows(db, sessionID, compaction)
const updates = yield* db
.select()
.from(SessionContextMessageTable)
.where(and(eq(SessionContextMessageTable.session_id, sessionID), gt(SessionContextMessageTable.seq, baselineSeq)))
.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 }),
)
return yield* Effect.forEach(yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq), decodeMessageRow)
})
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,7 +10,6 @@ import { SessionSchema } from "./schema"
import { Location } from "../location"
import { RelativePath } from "../schema"
import { SessionMessageID } from "./message-id"
import { SystemContext } from "../system-context"
export { FileAttachment }
@ -120,41 +119,17 @@ 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,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
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,
@ -480,9 +455,7 @@ const DurableDefinitions = [
Prompted,
PromptLifecycle.Admitted,
PromptLifecycle.Promoted,
ContextInitialized,
ContextUpdated,
ContextReplaced,
Synthetic,
Shell.Started,
Shell.Ended,

View file

@ -159,9 +159,15 @@ 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.context.updated": (event) =>
adapter.appendMessage(
new SessionMessage.System({
id: event.data.messageID,
type: "system",
text: event.data.text,
time: { created: event.data.timestamp },
}),
),
"session.next.synthetic": (event) => {
return adapter.appendMessage(
new SessionMessage.Synthetic({

View file

@ -51,6 +51,12 @@ export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Syntheti
type: Schema.Literal("synthetic"),
}) {}
export class System extends Schema.Class<System>("Session.Message.System")({
...Base,
type: Schema.Literal("system"),
text: SessionEvent.ContextUpdated.data.fields.text,
}) {}
export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
...Base,
type: Schema.Literal("shell"),
@ -170,7 +176,7 @@ export class Compaction extends Schema.Class<Compaction>("Session.Message.Compac
...Base,
}) {}
export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction])
export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, System, Shell, Assistant, Compaction])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Message" })

View file

@ -420,17 +420,9 @@ 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)
if (!event.replay || event.seq === undefined) return run(db, event)
return run(db, event).pipe(Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)))
})
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))

View file

@ -154,7 +154,7 @@ export const layer = Layer.effect(
const context = yield* getRunnerContext(session.id, system.baselineSeq)
const request = LLM.request({
model,
system: system.baseline.map((part) => SystemPart.make(part.text)),
system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [],
messages: toLLMMessages(context, model),
tools: yield* tools.definitions(),
})

View file

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

View file

@ -168,22 +168,9 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
.$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: text().notNull(),
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
baseline_seq: integer().notNull(),
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] })],
)

View file

@ -16,7 +16,7 @@ export interface Interface {
readonly runnerContext: (
sessionID: SessionSchema.ID,
baselineSeq: number,
) => Effect.Effect<SessionContext.RunnerMessage[], MessageDecodeError>
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>

View file

@ -1,73 +1,84 @@
export * as SystemContext from "./system-context"
import { Effect, Schema } from "effect"
import { Hash } from "./util/hash"
import { Effect, Option, Schema } from "effect"
/**
* Models privileged system context as independently refreshable typed sources.
*
* `Source<A>` describes how to observe, compare, and render one value. `make`
* closes over `A`, producing an opaque `SystemContext` that composes uniformly
* with contexts built from other value types. Interpreters observe the composed
* context once, then produce a durable structured
* `Snapshot` alongside the exact model-visible baseline or update text.
*
* Returning `unavailable` means observation failed temporarily. It differs from
* removing a source from the context: refresh preserves the admitted snapshot,
* and replacement waits rather than silently constructing an incomplete baseline.
*
* @module
*/
/** Stable namespaced identity for one independently refreshable context source. */
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe(
Schema.brand("SystemContext.Key"),
)
export type Key = typeof Key.Type
/** Indicates that a source could not be observed without treating it as removed. */
export const unavailable = Symbol.for("@opencode/SystemContext.Unavailable")
export type Unavailable = typeof unavailable
export interface Value {
/** Full component text rendered into a new epoch baseline. */
readonly baseline: string
/** Absolute current-state text emitted when this component changes. */
readonly update: string
}
export interface Component<out E = never, out R = never> {
/** Defines one typed source before its value type is hidden by `make`. */
export interface Source<A> {
readonly key: Key
readonly load: Effect.Effect<Value | Unavailable, E, R>
readonly codec: Schema.Codec<A, Schema.Json, never, never>
readonly load: Effect.Effect<A | Unavailable>
readonly baseline: (current: A) => string
readonly update: (previous: A, current: A) => string
readonly removed?: (previous: A) => string
}
export interface SystemContext<out E = never, out R = never> {
readonly components: ReadonlyArray<Component<E, R>>
const ContextTypeId: unique symbol = Symbol.for("@opencode/SystemContext")
/** Opaque carrier for composable system context sources. */
export interface SystemContext {
readonly [ContextTypeId]: ReadonlyArray<PackedSource>
}
export interface AvailableEntry extends Value {
readonly _tag: "Available"
readonly key: Key
readonly hash: string
}
export interface UnavailableEntry {
readonly _tag: "Unavailable"
readonly key: Key
}
export type Entry = AvailableEntry | UnavailableEntry
export interface Snapshot {
readonly entries: ReadonlyArray<Entry>
}
export interface Part {
readonly key: Key
readonly text: string
}
export const PartSchema = Schema.Struct({
key: Key,
text: Schema.String,
/** Durable comparison state for one admitted source. */
export const SourceSnapshot = Schema.Struct({
value: Schema.Json,
removed: Schema.optional(Schema.NonEmptyString),
})
export const PartsSchema = Schema.Array(PartSchema)
export const CheckpointSchema = Schema.Record(Key, Schema.String)
export type SourceSnapshot = typeof SourceSnapshot.Type
export type Checkpoint = Readonly<Record<string, string>>
/** Durable structured comparison state for one active context generation. */
export const Snapshot = Schema.Record(Key, SourceSnapshot)
export type Snapshot = Readonly<Record<string, SourceSnapshot>>
export interface Initialized {
readonly baseline: ReadonlyArray<Part>
readonly checkpoint: Checkpoint
export interface Generation {
readonly baseline: string
readonly snapshot: Snapshot
}
export interface Refreshed {
readonly changes: ReadonlyArray<Part>
readonly checkpoint: Checkpoint
export interface Updated {
readonly _tag: "Updated"
readonly text: string
readonly snapshot: Snapshot
}
export interface Replaced {
readonly _tag: "Replaced"
readonly generation: Generation
}
export interface ReplacementBlocked {
readonly _tag: "ReplacementBlocked"
}
export type ReplacementResult = Replaced | ReplacementBlocked
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult
export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("SystemContext.DuplicateKeyError", {
key: Key,
}) {
@ -76,78 +87,219 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
}
}
export const value = <E, R>(component: Component<E, R>): Component<E, R> => component
export function struct<E, R>(components: Readonly<Record<string, Component<E, R>>>): SystemContext<E, R> {
const values = Object.values(components)
assertUniqueKeys(values)
return { components: values }
interface PackedSource {
readonly key: Key
readonly load: Effect.Effect<Loaded | Unavailable>
}
export const load = <E, R>(context: SystemContext<E, R>) =>
Effect.sync(() => assertUniqueKeys(context.components)).pipe(
Effect.andThen(
Effect.forEach(context.components, (component) =>
component.load.pipe(
Effect.map(
(result): Entry =>
result === unavailable
? { _tag: "Unavailable", key: component.key }
: { _tag: "Available", key: component.key, ...result, hash: Hash.sha256(result.update) },
),
interface Loaded {
readonly baseline: () => Rendered
readonly compare: (previous: Schema.Json) => Compared
}
interface Rendered {
readonly text: string
readonly snapshot: SourceSnapshot
}
type Compared =
| { readonly _tag: "Incompatible" }
| { readonly _tag: "Unchanged" }
| { readonly _tag: "Updated"; readonly render: () => Rendered }
interface AvailableEntry extends Loaded {
readonly _tag: "Available"
readonly key: Key
}
interface UnavailableEntry {
readonly _tag: "Unavailable"
readonly key: Key
}
type Entry = AvailableEntry | UnavailableEntry
/** The identity context. */
export const empty = context([])
/** Closes a typed source into a context that composes with differently typed sources. */
export function make<A>(source: Source<A>): SystemContext {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const equivalent = Schema.toEquivalence(source.codec)
return context([
{
key: source.key,
load: source.load.pipe(
Effect.map((value) => {
if (isUnavailable(value)) return value
const snapshot = (): SourceSnapshot => ({
value: encode(value),
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
})
return {
baseline: (): Rendered => ({
text: requireText(source.key, "baseline", source.baseline(value)),
snapshot: snapshot(),
}),
compare: (previous): Compared =>
Option.match(decode(previous), {
onNone: (): Compared => ({ _tag: "Incompatible" }),
onSome: (decoded): Compared =>
equivalent(decoded, value)
? { _tag: "Unchanged" }
: {
_tag: "Updated",
render: () => ({
text: requireText(source.key, "update", source.update(decoded, value)),
snapshot: snapshot(),
}),
},
}),
}
}),
),
},
])
}
/** Combines contexts in order and rejects duplicate source keys immediately. */
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
const sources = values.flatMap((value) => value[ContextTypeId])
assertUniqueKeys(sources)
return context(sources)
}
const observe = (value: SystemContext) =>
Effect.forEach(
value[ContextTypeId],
(source) =>
source.load.pipe(
Effect.map(
(result): Entry =>
result === unavailable
? { _tag: "Unavailable", key: source.key }
: { _tag: "Available", key: source.key, ...result },
),
),
),
Effect.map((entries): Snapshot => ({ entries })),
{ concurrency: "unbounded" },
)
export function initialize(snapshot: Snapshot): Initialized {
/** Creates the immutable baseline and durable snapshot for a new generation. */
export function initialize(value: SystemContext): Effect.Effect<Generation> {
return observe(value).pipe(Effect.map(initializeObservation))
}
function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available")
const rendered = available.map((entry) => [entry.key, entry.baseline()] as const)
return {
baseline: snapshot.entries.flatMap((entry) =>
entry._tag === "Available" ? [{ key: entry.key, text: entry.baseline }] : [],
),
checkpoint: nextCheckpoint(snapshot, {}),
baseline: render(rendered.map(([, result]) => result.text)),
snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])),
}
}
export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed {
const keys = new Set(snapshot.entries.map((entry) => entry.key))
return {
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) =>
keys.has(Key.make(key)) ? [] : [{ key: Key.make(key), text: `System context component removed: ${key}` }],
),
],
checkpoint: nextCheckpoint(snapshot, previous),
}
}
export const replacementBlocked = (snapshot: Snapshot, previous: Checkpoint) =>
snapshot.entries.some((entry) => entry._tag === "Unavailable" && getCheckpoint(previous, entry.key) !== undefined)
function nextCheckpoint(snapshot: Snapshot, previous: Checkpoint) {
return Object.fromEntries(
snapshot.entries.flatMap((entry) => {
if (entry._tag === "Available") return [[entry.key, entry.hash]]
const hash = getCheckpoint(previous, entry.key)
return hash === undefined ? [] : [[entry.key, hash]]
/** Reconciles current source values with one active generation. */
export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect<ReconcileResult> {
return observe(value).pipe(
Effect.map((entries): ReconcileResult => {
const result = reconcileObservation(entries, previous)
if (result._tag === "Unchanged" || result._tag === "Updated") return result
return replaceObservation(entries, previous)
}),
)
}
function getCheckpoint(checkpoint: Checkpoint, key: Key) {
return Object.hasOwn(checkpoint, key) ? checkpoint[key] : undefined
function reconcileObservation(
entries: ReadonlyArray<Entry>,
previous: Snapshot,
): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } {
const keys = new Set(entries.map((entry) => entry.key))
const comparisons = new Map<Key, Compared>()
for (const entry of entries) {
if (entry._tag === "Unavailable") continue
const stored = getSnapshot(previous, entry.key)
if (!stored) continue
const compared = entry.compare(stored.value)
if (compared._tag === "Incompatible") return { _tag: "Replace" }
comparisons.set(entry.key, compared)
}
for (const key of Object.keys(previous).sort()) {
if (keys.has(Key.make(key))) continue
if (previous[key].removed === undefined) return { _tag: "Replace" }
}
const snapshot: Record<string, SourceSnapshot> = {}
const updates: string[] = []
for (const entry of entries) {
const stored = getSnapshot(previous, entry.key)
if (entry._tag === "Unavailable") {
if (stored) snapshot[entry.key] = stored
continue
}
if (!stored) {
const rendered = entry.baseline()
updates.push(rendered.text)
snapshot[entry.key] = rendered.snapshot
continue
}
const compared = comparisons.get(entry.key)
if (!compared || compared._tag === "Incompatible")
throw new Error(`Missing comparison for system context source ${entry.key}`)
if (compared._tag === "Unchanged") {
snapshot[entry.key] = stored
continue
}
const rendered = compared.render()
updates.push(rendered.text)
snapshot[entry.key] = rendered.snapshot
}
for (const key of Object.keys(previous).sort()) {
if (keys.has(Key.make(key))) continue
const removed = previous[key].removed
if (removed === undefined) throw new Error(`Missing removal rendering for system context source ${key}`)
updates.push(removed)
}
if (updates.length === 0) return { _tag: "Unchanged" }
return { _tag: "Updated", text: render(updates), snapshot }
}
function assertUniqueKeys(components: ReadonlyArray<Component<unknown, unknown>>) {
/** Creates a complete replacement generation or blocks while admitted context is unavailable. */
export function replace(value: SystemContext, previous: Snapshot): Effect.Effect<ReplacementResult> {
return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous)))
}
function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
return { _tag: "ReplacementBlocked" }
return { _tag: "Replaced", generation: initializeObservation(entries) }
}
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
return { [ContextTypeId]: sources }
}
function render(parts: ReadonlyArray<string>) {
return parts.join("\n\n")
}
function getSnapshot(snapshot: Snapshot, key: Key) {
return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined
}
function isUnavailable(value: unknown): value is Unavailable {
return value === unavailable
}
function requireText(key: Key, kind: string, text: string) {
if (text.length === 0) throw new Error(`System context source ${key} rendered an empty ${kind}`)
return text
}
function assertUniqueKeys(sources: ReadonlyArray<PackedSource>) {
const keys = new Set<Key>()
for (const component of components) {
if (keys.has(component.key)) throw new DuplicateKeyError({ key: component.key })
keys.add(component.key)
for (const source of sources) {
if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key })
keys.add(source.key)
}
}

View file

@ -190,6 +190,53 @@ describe("EventV2", () => {
}),
)
it.effect("commits local operational state inside a new synchronized event transaction", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<string>()
yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector")))
yield* events.publish(
SyncMessage,
{ id: "one", text: "hello" },
{ commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) },
)
expect(received).toEqual(["projector", "commit:0"])
}),
)
it.effect("rolls back the synchronized event and projector when the local commit fails", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const aggregateID = EventV2.ID.create()
yield* db.run("CREATE TABLE IF NOT EXISTS event_commit_probe (value text NOT NULL)")
yield* db.run("DELETE FROM event_commit_probe")
yield* events.project(SyncMessage, () =>
db.run("INSERT INTO event_commit_probe (value) VALUES ('projected')").pipe(Effect.orDie, Effect.asVoid),
)
const exit = yield* events
.publish(SyncMessage, { id: aggregateID, text: "hello" }, { commit: () => Effect.die("commit failed") })
.pipe(Effect.exit)
expect(String(exit)).toContain("commit failed")
expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([])
expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
expect(yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all()).toEqual([])
}),
)
it.effect("rejects local commit hooks on live-only events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit)
expect(String(exit)).toContain("Local commit hooks require a synchronized event")
}),
)
it.effect("runs projectors before publishing to streams", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Message, Model } from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { ModelV2 } from "@opencode-ai/core/model"
@ -8,15 +8,15 @@ import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode-
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"
import { DateTime, Effect } from "effect"
import { it } from "./lib/effect"
const created = DateTime.makeUnsafe(0)
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
describe("toLLMMessages", () => {
test("maps every top-level V2 Session message type", () => {
it.effect("maps every top-level V2 Session message type", () => Effect.sync(() => {
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
const messages = toLLMMessages(
@ -85,31 +85,22 @@ describe("toLLMMessages", () => {
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
])
})
}))
test("maps hidden Session context updates into chronological system messages", () => {
it.effect("maps durable Session system messages into chronological system messages", () => Effect.sync(() => {
expect(
toLLMMessages(
[
{
type: "system-context",
parts: [
{ key: SystemContext.Key.make("test/context"), text: "Updated context" },
{ key: SystemContext.Key.make("test/other"), text: "Other context" },
],
},
new SessionMessage.System({ id: id("system"), type: "system", text: "Updated context\n\nOther context", time: { created } }),
],
model,
),
).toEqual([
Message.system([
{ type: "text", text: "Updated context" },
{ type: "text", text: "Other context" },
]),
Message.system("Updated context\n\nOther context"),
])
})
}))
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () => Effect.sync(() => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
@ -264,9 +255,9 @@ describe("toLLMMessages", () => {
},
},
])
})
}))
test("restores OpenAI encrypted reasoning metadata", () => {
it.effect("restores OpenAI encrypted reasoning metadata", () => Effect.sync(() => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
@ -295,9 +286,9 @@ describe("toLLMMessages", () => {
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
})
}))
test("drops provider-native continuation metadata after a model switch", () => {
it.effect("drops provider-native continuation metadata after a model switch", () => Effect.sync(() => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
@ -395,5 +386,5 @@ describe("toLLMMessages", () => {
providerMetadata: undefined,
},
])
})
}))
})

View file

@ -21,10 +21,9 @@ 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"
import { Effect, Layer, Schema } from "effect"
import path from "node:path"
import { testEffect } from "./lib/effect"
@ -62,17 +61,16 @@ 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"),
},
],
}),
Effect.succeed(
SystemContext.make({
key: SystemContext.Key.make("test/context"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed("Recorded context"),
baseline: String,
update: (_previous, current) => current,
removed: () => "Recorded context removed",
}),
),
}),
)
const runner = SessionRunnerLLM.defaultLayer.pipe(
@ -167,7 +165,6 @@ 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

@ -32,11 +32,10 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
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 { SessionContextEpochTable, 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"
@ -150,21 +149,22 @@ const systemContext = Layer.succeed(
SessionSystemContext.Service,
SessionSystemContext.Service.of({
load: () =>
Effect.sync(() => ({
entries: systemRemoved
? []
: systemUnavailable
? [{ _tag: "Unavailable" as const, key: systemContextKey }]
Effect.succeed(
SystemContext.combine(
systemRemoved
? []
: [
{
_tag: "Available" as const,
SystemContext.make({
key: systemContextKey,
baseline: systemBaseline,
update: systemBaseline,
hash: Hash.sha256(systemBaseline),
},
codec: Schema.toCodecJson(Schema.String),
load: Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
baseline: String,
update: (_previous, current) => current,
removed: () => "System context source removed: test/context",
}),
],
})),
),
),
}),
)
const runner = SessionRunnerLLM.layer.pipe(
@ -568,16 +568,8 @@ describe("SessionRunnerLLM", () => {
])
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)
expect(yield* session.messages({ sessionID })).toHaveLength(3)
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 })
@ -587,11 +579,11 @@ describe("SessionRunnerLLM", () => {
.pipe(Effect.orDie),
).toHaveLength(1)
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(2)
expect(yield* session.messages({ sessionID })).toHaveLength(3)
}),
)
it.effect("admits removed context as a hidden chronological tombstone", () =>
it.effect("admits removed context as a chronological System message", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -606,13 +598,13 @@ describe("SessionRunnerLLM", () => {
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" },
{ type: "text", text: "System context source removed: test/context" },
])
expect(yield* session.messages({ sessionID })).toHaveLength(2)
expect(yield* session.messages({ sessionID })).toHaveLength(3)
}),
)
it.effect("replaces the baseline lazily after a model switch and drops prior hidden updates", () =>
it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -641,27 +633,16 @@ describe("SessionRunnerLLM", () => {
])
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)
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
"user",
"user",
"model-switched",
"user",
])
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(4)
expect(yield* session.messages({ sessionID })).toHaveLength(5)
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)
}),
)
@ -693,15 +674,39 @@ describe("SessionRunnerLLM", () => {
["Initial context"],
["Replacement context"],
])
}),
)
it.effect("advances a pending replacement to the latest invalidation boundary", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
timestamp: DateTime.makeUnsafe(2),
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
})
const latest = yield* events.sequence(sessionID)
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.next.context.replaced.1"))
.all()
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toHaveLength(1)
).toEqual({ replacementSeq: latest })
}),
)
@ -792,26 +797,41 @@ describe("SessionRunnerLLM", () => {
["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("preserves effective System updates while compaction replacement is blocked", () =>
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.Compaction.Started, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
timestamp: DateTime.makeUnsafe(2),
text: "summary",
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
expect(requests.at(-1)?.messages.some((message) => message.role === "system" && message.content[0]?.type === "text" && message.content[0].text === "Changed context")).toBe(true)
}),
)

View file

@ -30,41 +30,48 @@ describe("SessionSystemContext", () => {
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SessionSystemContext.Service
const initialized = SystemContext.initialize(yield* context.load())
const initialized = yield* SystemContext.initialize(yield* context.load())
expect(initialized.baseline).toEqual([
{
key: SystemContext.Key.make("core/environment"),
text: [
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
].join("\n"),
},
{ key: SystemContext.Key.make("core/date"), text: `Today's date: ${localDate(timestamp)}` },
])
expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
it.effect("refreshes the date without repeating unchanged environment context", () =>
it.effect("reconciles the date without repeating unchanged environment context", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SessionSystemContext.Service
const initialized = SystemContext.initialize(yield* context.load())
const initialized = yield* SystemContext.initialize(yield* context.load())
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
const refreshed = SystemContext.refresh(yield* context.load(), initialized.checkpoint)
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)
expect(refreshed.changes).toEqual([
{
key: SystemContext.Key.make("core/date"),
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
},
])
expect(refreshed).toMatchObject({
_tag: "Updated",
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
})
}),
)
it.effect("does not update again within the same local calendar day", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SessionSystemContext.Service
const initialized = yield* SystemContext.initialize(yield* context.load())
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)).toEqual({ _tag: "Unchanged" })
}),
)
})

View file

@ -1,213 +1,300 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Schema } from "effect"
import { SystemContext } from "@opencode-ai/core/system-context"
import { Hash } from "@opencode-ai/core/util/hash"
import { it } from "./lib/effect"
const key = SystemContext.Key.make
const stringContext = (input: {
key: string
value: string | SystemContext.Unavailable
baseline?: (value: string) => string
update?: (previous: string, current: string) => string
removed?: (value: string) => string
}) =>
SystemContext.make({
key: key(input.key),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(input.value),
baseline: input.baseline ?? String,
update: input.update ?? ((_previous, current) => current),
removed: input.removed,
})
describe("SystemContext", () => {
test("loads one coherent sample and initializes a deterministic baseline", async () => {
let loads = 0
const context = SystemContext.struct({
date: SystemContext.value({
it.effect("stores the canonical JSON encoding of the loaded value", () =>
Effect.gen(function* () {
const context = SystemContext.make({
key: key("core/date"),
codec: Schema.toCodecJson(Schema.DateFromString),
load: Effect.succeed(new Date("2026-06-03T12:00:00.000Z")),
baseline: (date) => date.toISOString(),
update: (_previous, date) => date.toISOString(),
removed: () => "Date removed",
})
expect((yield* SystemContext.initialize(context)).snapshot["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
}),
)
it.effect("loads once and initializes a baseline with a structured snapshot", () =>
Effect.gen(function* () {
let loads = 0
const context = SystemContext.combine([
SystemContext.make({
key: key("core/date"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.sync(() => {
loads++
return "2026-06-03"
}),
baseline: (date) => `Today's date is ${date}.`,
update: (previous, current) => `The date changed from ${previous} to ${current}.`,
removed: () => "The date was removed.",
}),
stringContext({ key: "core/location", value: "/repo", baseline: (value) => `Directory: ${value}` }),
])
expect(yield* SystemContext.initialize(context)).toEqual({
baseline: "Today's date is 2026-06-03.\n\nDirectory: /repo",
snapshot: {
"core/date": { value: "2026-06-03", removed: "The date was removed." },
"core/location": { value: "/repo" },
},
})
expect(loads).toBe(1)
}),
)
it.effect("renders updates only after a structured value changes", () =>
Effect.gen(function* () {
const previous = {
"core/date": { value: "2026-06-03", removed: "The date was removed." },
"core/location": { value: "/repo", removed: "Removed: /repo" },
}
const changed = SystemContext.combine([
stringContext({
key: "core/date",
value: "2026-06-04",
update: (before, current) => `The date changed from ${before} to ${current}.`,
removed: () => "The date was removed.",
}),
stringContext({ key: "core/location", value: "/repo" }),
])
expect(yield* SystemContext.reconcile(changed, previous)).toEqual({
_tag: "Updated",
text: "The date changed from 2026-06-03 to 2026-06-04.",
snapshot: {
"core/date": { value: "2026-06-04", removed: "The date was removed." },
"core/location": { value: "/repo", removed: "Removed: /repo" },
},
})
expect(
yield* SystemContext.reconcile(
SystemContext.combine([
stringContext({ key: "core/date", value: "2026-06-03", removed: () => "The date was removed." }),
stringContext({ key: "core/location", value: "/repo" }),
]),
previous,
),
).toEqual({ _tag: "Unchanged" })
}),
)
it.effect("uses the baseline for a newly added source", () =>
Effect.gen(function* () {
const context = stringContext({
key: "core/skills",
value: "effect",
baseline: (skill) => `Available skill: ${skill}`,
})
expect(yield* SystemContext.reconcile(context, {})).toEqual({
_tag: "Updated",
text: "Available skill: effect",
snapshot: { "core/skills": { value: "effect" } },
})
}),
)
it.effect("retains admitted snapshots while a source is temporarily unavailable", () =>
Effect.gen(function* () {
const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } }
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "Replaced" })
}),
)
it.effect("omits unavailable sources from an initial baseline", () =>
Effect.gen(function* () {
expect(yield* SystemContext.initialize(stringContext({ key: "core/remote", value: SystemContext.unavailable }))).toEqual({
baseline: "",
snapshot: {},
})
}),
)
it.effect("emits the previously stored removal message", () =>
Effect.gen(function* () {
expect(
yield* SystemContext.reconcile(SystemContext.empty, {
"core/instructions": { value: "contents", removed: "Instructions removed; stop applying them." },
}),
).toEqual({
_tag: "Updated",
text: "Instructions removed; stop applying them.",
snapshot: {},
})
}),
)
it.effect("requests replacement when a source without removal text disappears", () =>
Effect.gen(function* () {
expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toMatchObject({
_tag: "Replaced",
})
}),
)
it.effect("renders multiple removals in stable key order", () =>
Effect.gen(function* () {
expect(
yield* SystemContext.reconcile(SystemContext.empty, {
"core/z": { value: "z", removed: "Removed z" },
"core/a": { value: "a", removed: "Removed a" },
}),
).toMatchObject({ _tag: "Updated", text: "Removed a\n\nRemoved z" })
}),
)
it.effect("rejects empty model-visible renderings", () =>
Effect.gen(function* () {
const exit = yield* SystemContext.initialize(
stringContext({ key: "core/empty", value: "value", baseline: () => "" }),
).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("rendered an empty baseline")
}),
)
it.effect("requests replacement when a stored value no longer decodes", () =>
Effect.gen(function* () {
expect(
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
"core/date": { value: 42, removed: "Date removed" },
}),
).toMatchObject({ _tag: "Replaced" })
}),
)
it.effect("replaces from one coherent source observation", () =>
Effect.gen(function* () {
let loads = 0
const context = SystemContext.make({
key: key("core/date"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.sync(() => {
loads++
return { baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }
return "2026-06-04"
}),
}),
location: SystemContext.value({
key: key("core/location"),
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
}),
})
baseline: String,
update: (_previous, current) => current,
})
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context)))
expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
_tag: "Replaced",
generation: { baseline: "2026-06-04" },
})
expect(loads).toBe(1)
}),
)
expect(loads).toBe(1)
expect(initialized).toEqual({
baseline: [
{ key: key("core/date"), text: "Today's date is 2026-06-03." },
{ key: key("core/location"), text: "Working directory: /repo" },
],
checkpoint: {
"core/date": Hash.sha256("The current date is 2026-06-03."),
"core/location": Hash.sha256("The working directory is /repo."),
},
})
})
test("emits changed and newly registered components in declaration order", async () => {
const context = SystemContext.struct({
date: SystemContext.value({
key: key("core/date"),
load: Effect.succeed({ baseline: "Today's date is 2026-06-04.", update: "The current date is 2026-06-04." }),
}),
location: SystemContext.value({
key: key("core/location"),
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
}),
skills: SystemContext.value({
key: key("core/skills"),
load: Effect.succeed({ baseline: "Available skills: effect", update: "Available skills: effect" }),
}),
})
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), {
"core/date": Hash.sha256("The current date is 2026-06-03."),
"core/location": Hash.sha256("The working directory is /repo."),
})
expect(refreshed).toEqual({
changes: [
{ key: key("core/date"), text: "The current date is 2026-06-04." },
{ key: key("core/skills"), text: "Available skills: effect" },
],
checkpoint: {
"core/date": Hash.sha256("The current date is 2026-06-04."),
"core/location": Hash.sha256("The working directory is /repo."),
"core/skills": Hash.sha256("Available skills: effect"),
},
})
})
test("omits unavailable initial context and admits it after its first successful load", async () => {
let available = false
const context = SystemContext.struct({
remote: SystemContext.value({
key: key("core/remote-instructions"),
load: Effect.sync(() =>
available
? { baseline: "Remote instructions: available", update: "Remote instructions are now available." }
: SystemContext.unavailable,
),
}),
})
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context)))
available = true
const refreshed = SystemContext.refresh(
await Effect.runPromise(SystemContext.load(context)),
initialized.checkpoint,
)
expect(initialized).toEqual({ baseline: [], checkpoint: {} })
expect(refreshed.changes).toEqual([
{ key: key("core/remote-instructions"), text: "Remote instructions are now available." },
])
})
test("retains an existing checkpoint while context is unavailable", async () => {
const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") }
const context = SystemContext.struct({
remote: SystemContext.value({
key: key("core/remote-instructions"),
load: Effect.succeed(SystemContext.unavailable),
}),
})
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
expect(refreshed).toEqual({ changes: [], checkpoint: previous })
})
test("blocks replacement while admitted context is unavailable", async () => {
const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") }
const snapshot = await Effect.runPromise(
SystemContext.load(
SystemContext.struct({
remote: SystemContext.value({
key: key("core/remote-instructions"),
load: Effect.succeed(SystemContext.unavailable),
}),
it.effect("does not render discarded updates while replacing", () =>
Effect.gen(function* () {
let updates = 0
const context = SystemContext.combine([
stringContext({
key: "core/date",
value: "2026-06-04",
update: () => {
updates++
return "updated"
},
}),
),
)
stringContext({ key: "core/location", value: "/repo" }),
])
expect(SystemContext.replacementBlocked(snapshot, previous)).toBe(true)
expect(SystemContext.replacementBlocked(snapshot, {})).toBe(false)
})
expect(
yield* SystemContext.reconcile(context, {
"core/date": { value: "2026-06-03" },
"core/location": { value: 42 },
}),
).toMatchObject({ _tag: "Replaced" })
expect(updates).toBe(0)
}),
)
test("emits tombstones and drops checkpoints for removed components", async () => {
const context = SystemContext.struct({
date: SystemContext.value({
key: key("core/date"),
load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }),
}),
})
it.effect("blocks an incompatible replacement while another admitted source is unavailable", () =>
Effect.gen(function* () {
const previous = {
"core/date": { value: 42, removed: "Date removed" },
"core/remote": { value: "instructions", removed: "Instructions removed" },
}
const context = SystemContext.combine([
stringContext({ key: "core/date", value: "2026-06-04" }),
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
])
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), {
"core/date": Hash.sha256("The current date is 2026-06-03."),
"plugin/removed": Hash.sha256("Removed plugin context"),
})
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
}),
)
expect(refreshed).toEqual({
changes: [{ key: key("plugin/removed"), text: "System context component removed: plugin/removed" }],
checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") },
})
})
it.effect("rejects duplicate source keys", () =>
Effect.sync(() => {
expect(() =>
SystemContext.combine([
stringContext({ key: "core/date", value: "one" }),
stringContext({ key: "core/date", value: "two" }),
]),
).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
}),
)
test("ignores inherited checkpoint properties", async () => {
const context = SystemContext.struct({
date: SystemContext.value({
key: key("core/date"),
load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }),
}),
})
const previous = Object.create({
"core/date": Hash.sha256("The current date is 2026-06-03."),
}) as SystemContext.Checkpoint
it.effect("combines contexts in order", () =>
Effect.gen(function* () {
expect(
(yield* SystemContext.initialize(
SystemContext.combine([
stringContext({ key: "core/date", value: "date" }),
stringContext({ key: "core/location", value: "location" }),
]),
)).baseline,
).toBe("date\n\nlocation")
}),
)
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
it.effect("requires namespaced source keys", () =>
Effect.sync(() => {
const decodeKey = Schema.decodeUnknownSync(SystemContext.Key)
expect(refreshed.changes).toEqual([{ key: key("core/date"), text: "The current date is 2026-06-03." }])
expect(Object.hasOwn(refreshed.checkpoint, "core/date")).toBe(true)
})
expect(decodeKey("core/date")).toBe(key("core/date"))
expect(() => decodeKey("date")).toThrow()
}),
)
test("preserves unexpected loader failures", async () => {
const context = SystemContext.struct({
broken: SystemContext.value({
key: key("plugin/broken"),
load: Effect.fail("broken loader"),
}),
})
it.effect("requires namespaced durable snapshot keys", () =>
Effect.sync(() => {
const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot)
await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBe("broken loader")
})
test("rejects duplicate component keys", () => {
expect(() =>
SystemContext.struct({
one: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "one", update: "one" }) }),
two: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "two", update: "two" }) }),
}),
).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
})
test("rejects duplicate component keys at the interpreter boundary", async () => {
const component = SystemContext.value({
key: key("core/date"),
load: Effect.succeed({ baseline: "date", update: "date" }),
})
const context: SystemContext.SystemContext = { components: [component, component] }
await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBeInstanceOf(SystemContext.DuplicateKeyError)
})
test("requires namespaced component keys", () => {
const decode = Schema.decodeUnknownSync(SystemContext.Key)
expect(decode("core/date")).toBe(key("core/date"))
expect(() => decode("date")).toThrow()
expect(() => decode("core/")).toThrow()
})
test("requires namespaced checkpoint keys", () => {
const decode = Schema.decodeUnknownSync(SystemContext.CheckpointSchema)
const valid = JSON.parse('{"core/date":"hash"}')
const invalid = JSON.parse('{"date":"hash"}')
expect(decode(valid)).toEqual(valid)
expect(() => decode(invalid)).toThrow()
})
expect(Object.keys(decodeSnapshot({ "core/date": { value: "date" } }))).toEqual(["core/date"])
expect(() => decodeSnapshot({ date: { value: "date" } })).toThrow()
expect(() => decodeSnapshot({ "core/date": { value: "date", removed: "" } })).toThrow()
}),
)
})

View file

@ -175,6 +175,16 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
})
})
break
case "session.next.context.updated":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.properties.messageID,
type: "system",
text: event.properties.text,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.synthetic":
update(event.properties.sessionID, (draft) => {
prepend(draft, {

View file

@ -104,6 +104,9 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
<Match when={message.type === "synthetic"}>
<></>
</Match>
<Match when={message.type === "system"}>
<></>
</Match>
<Match when={message.type === "shell"}>
<ShellMessage message={message as SessionMessageShell} />
</Match>