feat(core): persist v2 session context epochs (#30789)

This commit is contained in:
Kit Langton 2026-06-04 23:26:43 -04:00 committed by GitHub
parent c47cb28781
commit 1af8dafd3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 4886 additions and 546 deletions

View File

@ -8,12 +8,15 @@ OpenCode sessions preserve durable conversational history while assembling the r
The structured collection of contextual facts presented to the model as initial instructions and chronological updates. The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
_Avoid_: System prompt _Avoid_: System prompt
**Context Component**: **Context Source**:
One independently loaded fact within the **System Context**, represented by a stable key and one effectfully loaded baseline/update rendering. One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
_Avoid_: Prompt fragment _Avoid_: Prompt fragment
**System Context Registry**:
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
**Mid-Conversation System Message**: **Mid-Conversation System Message**:
A durable chronological instruction that tells the model the newly effective state of a changed **Context Component**. A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
_Avoid_: System update, system notification, raw text diff _Avoid_: System update, system notification, raw text diff
**Context Epoch**: **Context Epoch**:
@ -23,48 +26,57 @@ The span during which one initially rendered **System Context** remains immutabl
The full **System Context** rendered at the start of a **Context Epoch**. The full **System Context** rendered at the start of a **Context Epoch**.
_Avoid_: Live system prompt _Avoid_: Live system prompt
**Context Checkpoint**: **Context Snapshot**:
The durable model-hidden comparison state used to detect which **Context Components** changed since context was last admitted to a provider turn. The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
**Unavailable Context**: **Unavailable Context**:
An expected temporary inability to load a **Context Component** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded. An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
**Safe Provider-Turn Boundary**: **Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
## Relationships ## Relationships
- A **System Context** contains one or more **Context Components**. - A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
- A changed **Context Component** may produce one **Mid-Conversation System Message** containing its newly effective state. - The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
- A **Mid-Conversation System Message** persists its originating **Context Component** key and the exact rendered text sent to the model. - A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
- A **Context Checkpoint** advances atomically with the corresponding durable **Mid-Conversation System Message**. - A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
- A **Context Checkpoint** stores one rendered-content hash per stable **Context Component** key so core and plugin-defined components can evolve independently. - The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
- Changes from multiple **Context Components** admitted at one safe boundary combine into one **Mid-Conversation System Message**. - A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes. - Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. - At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- The first provider turn renders the latest **Baseline System Context** and initializes its **Context Checkpoint** without emitting a redundant **Mid-Conversation System Message**. - The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Checkpoint**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. - Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- A **Context Checkpoint** is an evolvable component map; a newly registered core or plugin-defined **Context Component** absent from an existing checkpoint emits its current state once at the next **Safe Provider-Turn Boundary**. - Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
- **Context Component** keys are stable and namespaced; duplicate keys fail assembly. Built-in components preserve declaration order and plugin-defined components append in lexicographic key order so rendered context is deterministic. - A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
- Each **Context Component** loader returns its model-visible baseline string and absolute current-state update string from one coherent sample; the update string is hashed for change detection. - **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Component** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction files discovered while reading join the effective instructions returned by the instruction service and are admitted durably at the next **Safe Provider-Turn Boundary**. - Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- A discovered nested project instruction remains active for the session while it stays in the same location and is folded into later **Baseline System Contexts** after compaction.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- Plugin-defined **Context Components** register through a scoped replayable registry so plugin hot reload adds and removes components predictably. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable model-projection history but are hidden from normal user-facing transcript surfaces. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Component** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**. - A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves deterministic keyed top-level component strings rather than eagerly joining all text; request assembly lowers them into canonical LLM system parts. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. - Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When an effective instruction file changes, its **Mid-Conversation System Message** includes the complete current contents and supersedes the prior version from that source; when it is removed, the message states that it no longer applies. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
## Example dialogue ## Example dialogue
@ -73,5 +85,4 @@ The point immediately before a provider call, after durable input promotion and
## Flagged ambiguities ## Flagged ambiguities
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Components**, or narrow its semantics. - Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.
- A location change likely starts a new **Context Epoch** so location-dependent instructions and discovery can be rebuilt cleanly, but implementation should verify whether an append-only update is sufficient and meaningfully preserves cache.

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
);

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,21 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260605003541_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,
\`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
);
`)
})
},
} satisfies DatabaseMigration.Migration

View File

@ -45,6 +45,8 @@ export type Payload<D extends Definition = Definition> = {
readonly version?: number readonly version?: number
readonly location?: Location.Ref readonly location?: Location.Ref
readonly metadata?: Record<string, unknown> 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> export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
@ -137,6 +139,8 @@ export interface PublishOptions {
readonly id?: ID readonly id?: ID
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref 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 { export interface Interface {
@ -215,6 +219,7 @@ export const layerWith = (options?: LayerOptions) =>
readonly ownerID?: string readonly ownerID?: string
readonly strictOwner?: boolean readonly strictOwner?: boolean
}, },
commit?: (seq: number) => Effect.Effect<void>,
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = registry.get(event.type) const definition = registry.get(event.type)
@ -330,6 +335,7 @@ export const layerWith = (options?: LayerOptions) =>
for (const projector of list) { for (const projector of list) {
yield* projector({ ...event, seq } as Payload) yield* projector({ ...event, seq } as Payload)
} }
if (commit) yield* commit(seq)
yield* db yield* db
.insert(EventSequenceTable) .insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]) .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
@ -375,11 +381,18 @@ export const layerWith = (options?: LayerOptions) =>
}) })
} }
function publishEvent<D extends Definition>(event: Payload<D>) { function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () { return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined const durable = registry.get(event.type)?.sync !== undefined
if (!durable && commit)
return yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: "Local commit hooks require a synchronized event",
}),
)
if (durable) { if (durable) {
const committed = yield* commitSyncEvent(event as Payload) const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
if (committed) { if (committed) {
event = { ...event, seq: committed.seq } event = { ...event, seq: committed.seq }
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
@ -424,14 +437,17 @@ export const layerWith = (options?: LayerOptions) =>
(serviceLocation (serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined) : undefined)
return yield* publishEvent({ return yield* publishEvent(
id: options?.id ?? ID.create(), {
...(options?.metadata ? { metadata: options.metadata } : {}), id: options?.id ?? ID.create(),
type: definition.type, ...(options?.metadata ? { metadata: options.metadata } : {}),
...(definition.sync === undefined ? {} : { version: definition.sync.version }), type: definition.type,
...(location ? { location } : {}), ...(definition.sync === undefined ? {} : { version: definition.sync.version }),
data, ...(location ? { location } : {}),
} as Payload<D>) data,
} as Payload<D>,
options?.commit,
)
}) })
} }
@ -451,6 +467,7 @@ export const layerWith = (options?: LayerOptions) =>
type: definition.type, type: definition.type,
version: definition.sync.version, version: definition.sync.version,
data: definition.decode(event.data), data: definition.decode(event.data),
replay: true,
} as Payload } as Payload
const committed = yield* commitSyncEvent(payload, { const committed = yield* commitSyncEvent(payload, {
seq: event.seq, seq: event.seq,

View File

@ -0,0 +1,91 @@
export * as InstructionContext from "./instruction-context"
import { Array, Effect, Layer, Schema } from "effect"
import { isAbsolute, join, relative, sep } from "path"
import { FSUtil } from "./fs-util"
import { Flag } from "./flag/flag"
import { Global } from "./global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { SystemContext } from "./system-context"
import { SystemContextRegistry } from "./system-context-registry"
class File extends Schema.Class<File>("InstructionContext.File")({
path: AbsolutePath,
content: Schema.String,
}) {}
const Files = Schema.Array(File)
const key = SystemContext.Key.make("core/instructions")
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
SystemContext.make({
key,
codec: Schema.toCodecJson(Files),
load: Effect.succeed(value),
baseline: render,
update: (_previous, current) => `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
removed: () => "Previously loaded instructions no longer apply.",
})
const observe = Effect.fn("InstructionContext.observe")(function* () {
const start = FSUtil.resolve(location.directory)
const stop = FSUtil.resolve(location.project.directory)
const fromProject = relative(stop, start)
const insideProject =
fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
const discovered = new Set(
(Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject
? []
: yield* fs.up({
targets: ["AGENTS.md"],
start,
stop,
})
).map(FSUtil.resolve),
)
const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered])
const files = yield* Effect.forEach(
paths,
(path) =>
fs
.readFileStringSafe(path)
.pipe(
Effect.map((content) =>
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
),
),
{ concurrency: "unbounded" },
)
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
return SystemContext.unavailable
return files.filter((file): file is File => file !== undefined)
})
yield* registry.contribute({
key,
load: observe().pipe(
Effect.map((files) =>
files === SystemContext.unavailable
? source(files)
: files.length === 0
? SystemContext.empty
: source(files),
),
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
),
})
}),
)
function render(files: ReadonlyArray<File>) {
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
}

View File

@ -40,12 +40,14 @@ import { RequestExecutor } from "@opencode-ai/llm/route"
import * as SessionRunnerLLM from "./session/runner/llm" import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model" import { SessionRunnerModel } from "./session/runner/model"
import { SessionRunCoordinator } from "./session/run-coordinator" import { SessionRunCoordinator } from "./session/run-coordinator"
import { SystemContextBuiltIns } from "./system-context-builtins"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", { export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => { lookup: (ref: Location.Ref) => {
const location = Location.layer(ref) const location = Location.layer(ref)
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer)) const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
const systemContext = SystemContextBuiltIns.locationLayer
const services = Layer.mergeAll( const services = Layer.mergeAll(
location, location,
Policy.locationLayer, Policy.locationLayer,
@ -60,6 +62,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Watcher.locationLayer, Watcher.locationLayer,
Pty.locationLayer, Pty.locationLayer,
SkillV2.locationLayer, SkillV2.locationLayer,
systemContext,
permissionsAndTools, permissionsAndTools,
LocationMutation.locationLayer.pipe(Layer.orDie), LocationMutation.locationLayer.pipe(Layer.orDie),
).pipe(Layer.provideMerge(location)) ).pipe(Layer.provideMerge(location))

View File

@ -1,54 +0,0 @@
export * as SessionSystemContext from "./session-system-context"
import { Context, DateTime, Effect, Layer } from "effect"
import { Location } from "./location"
import { SystemContext } from "./system-context"
export interface Interface {
readonly load: () => Effect.Effect<SystemContext.Snapshot>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionSystemContext") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const environment = [
"<env>",
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
"</env>",
].join("\n")
const context = SystemContext.struct({
environment: SystemContext.value({
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"),
}),
}),
date: SystemContext.value({
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()}`,
})),
),
}),
})
return Service.of({
load: Effect.fn("SessionSystemContext.load")(function* () {
return yield* SystemContext.load(context)
}),
})
}),
)
export const locationLayer = layer

View File

@ -0,0 +1,242 @@
export * as SessionContextEpoch from "./context-epoch"
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 { Location } from "../location"
import { SystemContext } from "../system-context"
import { SystemContextRegistry } from "../system-context-registry"
import { SessionEvent } from "./event"
import { SessionInput } from "./input"
import { SessionMessageID } from "./message-id"
import { SessionSchema } from "./schema"
import { SessionContextEpochTable, SessionTable } from "./sql"
type DatabaseService = Database.Interface["db"]
class RevisionMismatch extends Error {}
class LocationMismatch extends Error {}
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
attempt().pipe(
Effect.catchDefect((defect) =>
defect instanceof RevisionMismatch
? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt)))
: Effect.die(defect),
),
)
interface Prepared {
readonly baseline: string
readonly baselineSeq: number
}
export function initialize(
db: DatabaseService,
context: SystemContextRegistry.Interface,
sessionID: SessionSchema.ID,
location: Location.Ref,
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location)).pipe(
Effect.withSpan("SessionContextEpoch.initialize"),
)
}
export function prepare(
db: DatabaseService,
events: EventV2.Interface,
context: SystemContextRegistry.Interface,
sessionID: SessionSchema.ID,
location: Location.Ref,
): Effect.Effect<Prepared, SystemContext.InitializationBlocked> {
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location)).pipe(
Effect.withSpan("SessionContextEpoch.prepare"),
)
}
const prepareOnce = Effect.fnUntraced(function* (
db: DatabaseService,
events: EventV2.Interface,
context: SystemContextRegistry.Interface,
sessionID: SessionSchema.ID,
location: Location.Ref,
) {
const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" })
if (!stored) {
const generation = yield* SystemContext.initialize(value)
const baselineSeq = yield* insert(db, sessionID, location, generation)
return { baseline: generation.baseline, baselineSeq }
}
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 }
if (result._tag === "ReplacementReady") {
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, 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 }
})
const initializeOnce = Effect.fnUntraced(function* (
db: DatabaseService,
context: SystemContextRegistry.Interface,
sessionID: SessionSchema.ID,
location: Location.Ref,
) {
if (yield* exists(db, sessionID)) return
const generation = yield* context.load().pipe(Effect.flatMap(SystemContext.initialize))
const baselineSeq = yield* insert(db, sessionID, location, generation)
return { baseline: generation.baseline, baselineSeq }
})
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return (
(yield* db
.select({ sessionID: SessionContextEpochTable.session_id })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)) !== undefined
)
})
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 requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
seq: number,
) {
return yield* db
.update(SessionContextEpochTable)
.set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
lt(SessionContextEpochTable.baseline_seq, seq),
or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)),
),
)
.run()
.pipe(Effect.orDie)
})
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
yield* db.delete(SessionContextEpochTable).where(eq(SessionContextEpochTable.session_id, sessionID)).run().pipe(Effect.orDie)
})
const insert = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
location: Location.Ref,
generation: SystemContext.Generation,
) {
return yield* db
.transaction(
() =>
Effect.gen(function* () {
const placed = yield* db
.select({ sessionID: SessionTable.id })
.from(SessionTable)
.where(
and(
eq(SessionTable.id, sessionID),
eq(SessionTable.directory, location.directory),
location.workspaceID === undefined
? isNull(SessionTable.workspace_id)
: eq(SessionTable.workspace_id, location.workspaceID),
),
)
.get()
.pipe(Effect.orDie)
if (!placed) return yield* Effect.die(new LocationMismatch())
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
yield* db
.insert(SessionContextEpochTable)
.values({
session_id: sessionID,
baseline: generation.baseline,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
revision: 0,
})
.onConflictDoNothing()
.returning({ sessionID: SessionContextEpochTable.session_id })
.get()
.pipe(
Effect.orDie,
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
)
return baselineSeq
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const replace = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
expectedRevision: number,
baselineSeq: number,
generation: SystemContext.Generation,
) {
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(new RevisionMismatch())
})
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(new RevisionMismatch())
})

View File

@ -1,47 +1,92 @@
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 { Effect, Schema } from "effect"
import { Database } from "../database/database" import { Database } from "../database/database"
import { MessageDecodeError } from "./error" import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { SessionMessageTable } from "./sql" import { SessionContextEpochTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"] type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Message) const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* db return yield* db
.select() .select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable) .from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.seq)) .orderBy(desc(SessionMessageTable.seq))
.limit(1) .limit(1)
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
const rows = yield* db })
const messageRows = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
compaction: { readonly seq: number } | undefined,
baselineSeq?: number,
) {
return yield* db
.select() .select()
.from(SessionMessageTable) .from(SessionMessageTable)
.where( .where(
and( and(
eq(SessionMessageTable.session_id, sessionID), 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)) .orderBy(asc(SessionMessageTable.seq))
.all() .all()
.pipe(Effect.orDie) .pipe(Effect.orDie)
return yield* Effect.forEach(rows, (row) => })
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError( const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
() => decode({ ...row.data, id: row.id, type: row.type }).pipe(
new MessageDecodeError({ Effect.mapError(
sessionID: SessionSchema.ID.make(row.session_id), () =>
messageID: SessionMessage.ID.make(row.id), 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) {
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, compaction, epoch?.baselineSeq), decodeMessageRow)
})
export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq),
decodeMessageRow,
)
}) })
export * as SessionContext from "./context" export * as SessionContext from "./context"

View File

@ -119,6 +119,17 @@ export namespace PromptLifecycle {
export type Promoted = typeof Promoted.Type export type Promoted = typeof Promoted.Type
} }
export const ContextUpdated = EventV2.define({
type: "session.next.context.updated",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type ContextUpdated = typeof ContextUpdated.Type
export const Synthetic = EventV2.define({ export const Synthetic = EventV2.define({
type: "session.next.synthetic", type: "session.next.synthetic",
...options, ...options,
@ -444,6 +455,7 @@ const DurableDefinitions = [
Prompted, Prompted,
PromptLifecycle.Admitted, PromptLifecycle.Admitted,
PromptLifecycle.Promoted, PromptLifecycle.Promoted,
ContextUpdated,
Synthetic, Synthetic,
Shell.Started, Shell.Started,
Shell.Ended, Shell.Ended,

View File

@ -159,6 +159,15 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}, },
"session.next.prompt.admitted": () => Effect.void, "session.next.prompt.admitted": () => Effect.void,
"session.next.prompt.promoted": () => Effect.void, "session.next.prompt.promoted": () => 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) => { "session.next.synthetic": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
new SessionMessage.Synthetic({ new SessionMessage.Synthetic({

View File

@ -51,6 +51,12 @@ export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Syntheti
type: Schema.Literal("synthetic"), 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")({ export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
...Base, ...Base,
type: Schema.Literal("shell"), type: Schema.Literal("shell"),
@ -170,7 +176,16 @@ export class Compaction extends Schema.Class<Compaction>("Session.Message.Compac
...Base, ...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")) .pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Message" }) .annotate({ identifier: "Session.Message" })

View File

@ -11,6 +11,7 @@ import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater" import { SessionMessageUpdater } from "./message-updater"
import { SessionInput } from "./input" import { SessionInput } from "./input"
import { WorkspaceV2 } from "../workspace" import { WorkspaceV2 } from "../workspace"
import { SessionContextEpoch } from "./context-epoch"
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql" import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema" import type { DeepMutable } from "../schema"
@ -259,17 +260,20 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie), .pipe(Effect.orDie),
) )
yield* events.project(SessionEvent.Moved, (event) => yield* events.project(SessionEvent.Moved, (event) =>
db Effect.gen(function* () {
.update(SessionTable) yield* db
.set({ .update(SessionTable)
directory: event.data.location.directory, .set({
path: event.data.subdirectory, directory: event.data.location.directory,
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, path: event.data.subdirectory,
time_updated: DateTime.toEpochMillis(event.data.timestamp), workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
}) time_updated: DateTime.toEpochMillis(event.data.timestamp),
.where(eq(SessionTable.id, event.data.sessionID)) })
.run() .where(eq(SessionTable.id, event.data.sessionID))
.pipe(Effect.orDie), .run()
.pipe(Effect.orDie)
yield* SessionContextEpoch.reset(db, event.data.sessionID)
}),
) )
yield* events.project(SessionV1.Event.Deleted, (event) => yield* events.project(SessionV1.Event.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
@ -352,12 +356,18 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie, Effect.andThen(run(db, event))), .pipe(Effect.orDie, Effect.andThen(run(db, event))),
) )
yield* events.project(SessionEvent.ModelSwitched, (event) => yield* events.project(SessionEvent.ModelSwitched, (event) =>
db Effect.gen(function* () {
.update(SessionTable) yield* db
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .update(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID)) .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.run() .where(eq(SessionTable.id, event.data.sessionID))
.pipe(Effect.orDie, Effect.andThen(run(db, event))), .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) => yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
@ -413,6 +423,12 @@ export const layer = Layer.effectDiscard(
) )
}), }),
) )
yield* events.project(SessionEvent.ContextUpdated, (event) => {
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.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
@ -432,7 +448,12 @@ export const layer = Layer.effectDiscard(
// yield* events.project(SessionEvent.Retried, (event) => run(db, event)) // yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Started, (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.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

@ -5,6 +5,7 @@ import { Context, Effect, Schema } from "effect"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import type { MessageDecodeError } from "../error" import type { MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model" import { SessionRunnerModel } from "./model"
import type { SystemContext } from "../../system-context"
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()( export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
"SessionRunner.StepLimitExceededError", "SessionRunner.StepLimitExceededError",
@ -14,7 +15,12 @@ export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExc
}, },
) {} ) {}
export type RunError = LLMError | SessionRunnerModel.Error | MessageDecodeError | StepLimitExceededError export type RunError =
| LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| StepLimitExceededError
| SystemContext.InitializationBlocked
/** Runs one local continuation from already-recorded Session history. */ /** Runs one local continuation from already-recorded Session history. */
export interface Interface { export interface Interface {

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 { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
@ -14,6 +14,8 @@ import { SessionRunnerModel } from "./model"
import { Database } from "../../database/database" import { Database } from "../../database/database"
import { SessionInput } from "../input" import { SessionInput } from "../input"
import { QuestionV2 } from "../../question" import { QuestionV2 } from "../../question"
import { SystemContextRegistry } from "../../system-context-registry"
import { SessionContextEpoch } from "../context-epoch"
/** /**
* Runs one durable coding-agent Session until it settles. * Runs one durable coding-agent Session until it settles.
@ -34,8 +36,8 @@ import { QuestionV2 } from "../../question"
* - [x] Resolve the selected model through the location-scoped runner environment. * - [x] Resolve the selected model through the location-scoped runner environment.
* - [ ] Load the selected agent and effective permissions. * - [ ] Load the selected agent and effective permissions.
* - [ ] Build provider/model-specific base instructions and environment facts. * - [ ] Build provider/model-specific base instructions and environment facts.
* - [ ] Load configured project instructions such as `AGENTS.md`, remote instructions, and * - [x] Load global and upward project `AGENTS.md` instructions.
* nearby nested instructions discovered while files are read. * - [ ] Load configured and remote instructions plus nearby nested instructions discovered while files are read.
* - [ ] List available skills in the system prompt and expose a tool for loading skill bodies. * - [ ] List available skills in the system prompt and expose a tool for loading skill bodies.
* - [ ] Resolve referenced files, directories, agents, repositories, MCP resources, and media. * - [ ] Resolve referenced files, directories, agents, repositories, MCP resources, and media.
* - [ ] Apply steering reminders, plugin transforms, and structured-output policy. * - [ ] Apply steering reminders, plugin transforms, and structured-output policy.
@ -85,6 +87,7 @@ export const layer = Layer.effect(
const tools = yield* ToolRegistry.Service const tools = yield* ToolRegistry.Service
const models = yield* SessionRunnerModel.Service const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const systemContext = yield* SystemContextRegistry.Service
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
@ -95,7 +98,6 @@ export const layer = Layer.effect(
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) { const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
return yield* store.context(sessionID) return yield* store.context(sessionID)
}) })
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* ( const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
) { ) {
@ -126,9 +128,11 @@ export const layer = Layer.effect(
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
const runTurn = Effect.fn("SessionRunner.runTurn")(function* ( const runTurn = Effect.fn("SessionRunner.runTurn")(function* (
session: SessionSchema.Info, sessionID: SessionSchema.ID,
promotion: "steer" | "queue" | undefined, promotion: "steer" | "queue" | undefined,
) { ) {
const session = yield* getSession(sessionID)
const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location)
const model = yield* models.resolve(session) const model = yield* models.resolve(session)
const toolFibers = yield* FiberSet.make<void, never>() const toolFibers = yield* FiberSet.make<void, never>()
let needsContinuation = false let needsContinuation = false
@ -140,9 +144,14 @@ export const layer = Layer.effect(
yield* SessionInput.promoteSteers(db, events, session.id, cutoff) yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
} }
} }
yield* failInterruptedTools(session.id) const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id, session.location))
const context = yield* getContext(session.id) const context = yield* store.runnerContext(session.id, system.baselineSeq)
const request = LLM.request({ model, messages: toLLMMessages(context, model), tools: yield* tools.definitions() }) const request = LLM.request({
model,
system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [],
messages: toLLMMessages(context, model),
tools: yield* tools.definitions(),
})
const publisher = createLLMEventPublisher(events, { const publisher = createLLMEventPublisher(events, {
sessionID: session.id, sessionID: session.id,
agent: session.agent ?? "build", agent: session.agent ?? "build",
@ -235,16 +244,16 @@ export const layer = Layer.effect(
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force?: boolean readonly force?: boolean
}) { }) {
const session = yield* getSession(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (input.force !== true && !hasSteer && !hasQueue) return if (input.force !== true && !hasSteer && !hasQueue) return
yield* failInterruptedTools(input.sessionID)
let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let openActivity = input.force === true || hasSteer || hasQueue let openActivity = input.force === true || hasSteer || hasQueue
while (openActivity) { while (openActivity) {
let needsContinuation = true let needsContinuation = true
for (let step = 0; step < MAX_STEPS; step++) { for (let step = 0; step < MAX_STEPS; step++) {
needsContinuation = yield* runTurn(session, promotion) needsContinuation = yield* runTurn(input.sessionID, promotion)
promotion = "steer" promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
if (!needsContinuation) break if (!needsContinuation) break

View File

@ -111,6 +111,8 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
] ]
case "synthetic": case "synthetic":
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })] return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
case "system":
return [Message.system(message.text)]
case "shell": case "shell":
return [ return [
Message.make({ Message.make({

View File

@ -11,6 +11,7 @@ import type { SessionSchema } from "./schema"
import type { MessageID, PartID, SessionV1 } from "../v1/session" import type { MessageID, PartID, SessionV1 } from "../v1/session"
import { WorkspaceV2 } from "../workspace" import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql" import { Timestamps } from "../database/schema.sql"
import type { SystemContext } from "../system-context"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID"> type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@ -161,3 +162,15 @@ export const SessionInputTable = sqliteTable(
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq), 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().notNull(),
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
baseline_seq: integer().notNull(),
replacement_seq: integer(),
revision: integer().notNull().default(0),
})

View File

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

View File

@ -0,0 +1,47 @@
export * as SystemContextBuiltIns from "./system-context-builtins"
import { DateTime, Effect, Layer, Schema } from "effect"
import { InstructionContext } from "./instruction-context"
import { Location } from "./location"
import { SystemContext } from "./system-context"
import { SystemContextRegistry } from "./system-context-registry"
const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const environment = [
"<env>",
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
"</env>",
].join("\n")
const context = SystemContext.combine([
SystemContext.make({
key: SystemContext.Key.make("core/environment"),
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"),
}),
SystemContext.make({
key: SystemContext.Key.make("core/date"),
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}`,
}),
])
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
}),
)
export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe(
Layer.provideMerge(SystemContextRegistry.layer),
)
export const locationLayer = layer

View File

@ -0,0 +1,46 @@
export * as SystemContextRegistry from "./system-context-registry"
import { Context, Effect, Layer, Ref, Scope } from "effect"
import { SystemContext } from "./system-context"
export interface Contribution {
readonly key: SystemContext.Key
readonly load: Effect.Effect<SystemContext.SystemContext>
}
export interface Interface {
readonly contribute: (contribution: Contribution) => Effect.Effect<void, never, Scope.Scope>
readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SystemContextRegistry") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([])
return Service.of({
contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) {
yield* Effect.acquireRelease(
Ref.modify(contributions, (current) => {
if (current.some((item) => item.key === contribution.key)) return [false, current]
return [true, [...current, contribution]]
}).pipe(
Effect.flatMap((added) =>
added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`),
),
Effect.as(contribution),
),
(entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)),
)
}),
load: Effect.fn("SystemContextRegistry.load")(function* () {
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
return SystemContext.combine(
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
)
}),
})
}),
)

View File

@ -1,66 +1,89 @@
export * as SystemContext from "./system-context" export * as SystemContext from "./system-context"
import { Effect, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { Hash } from "./util/hash"
/**
* 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( 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"), Schema.brand("SystemContext.Key"),
) )
export type Key = typeof Key.Type 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 const unavailable = Symbol.for("@opencode/SystemContext.Unavailable")
export type Unavailable = typeof unavailable export type Unavailable = typeof unavailable
export interface Value { /** Defines one typed source before its value type is hidden by `make`. */
/** Full component text rendered into a new epoch baseline. */ export interface Source<A> {
readonly key: Key
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
}
const ContextTypeId: unique symbol = Symbol.for("@opencode/SystemContext")
/** Opaque carrier for composable system context sources. */
export interface SystemContext {
readonly [ContextTypeId]: ReadonlyArray<PackedSource>
}
/** Durable comparison state for one admitted source. */
export const SourceSnapshot = Schema.Struct({
value: Schema.Json,
removed: Schema.optional(Schema.NonEmptyString),
})
export type SourceSnapshot = typeof SourceSnapshot.Type
/** 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 Generation {
readonly baseline: string readonly baseline: string
/** Absolute current-state text emitted when this component changes. */ readonly snapshot: Snapshot
readonly update: string
} }
export interface Component<out E = never, out R = never> { export interface Updated {
readonly key: Key readonly _tag: "Updated"
readonly load: Effect.Effect<Value | Unavailable, E, R>
}
export interface SystemContext<out E = never, out R = never> {
readonly components: ReadonlyArray<Component<E, R>>
}
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 readonly text: string
readonly snapshot: Snapshot
} }
export type Checkpoint = Readonly<Record<string, string>> export interface ReplacementReady {
readonly _tag: "ReplacementReady"
export interface Initialized { readonly generation: Generation
readonly baseline: ReadonlyArray<Part>
readonly checkpoint: Checkpoint
} }
export interface Refreshed { export interface ReplacementBlocked {
readonly changes: ReadonlyArray<Part> readonly _tag: "ReplacementBlocked"
readonly checkpoint: Checkpoint
} }
export type ReplacementResult = ReplacementReady | ReplacementBlocked
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
"SystemContext.InitializationBlocked",
{ keys: Schema.Array(Key) },
) {}
export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("SystemContext.DuplicateKeyError", { export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("SystemContext.DuplicateKeyError", {
key: Key, key: Key,
}) { }) {
@ -69,73 +92,225 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
} }
} }
export const value = <E, R>(component: Component<E, R>): Component<E, R> => component interface PackedSource {
readonly key: Key
export function struct<E, R>(components: Readonly<Record<string, Component<E, R>>>): SystemContext<E, R> { readonly load: Effect.Effect<Loaded | Unavailable>
const values = Object.values(components)
assertUniqueKeys(values)
return { components: values }
} }
export const load = <E, R>(context: SystemContext<E, R>) => interface Loaded {
Effect.sync(() => assertUniqueKeys(context.components)).pipe( readonly baseline: () => Rendered
Effect.andThen( readonly compare: (previous: Schema.Json) => Compared
Effect.forEach(context.components, (component) => }
component.load.pipe(
Effect.map( interface Rendered {
(result): Entry => readonly text: string
result === unavailable readonly snapshot: SourceSnapshot
? { _tag: "Unavailable", key: component.key } }
: { _tag: "Available", key: component.key, ...result, hash: Hash.sha256(result.update) },
), 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 },
), ),
), ),
), { concurrency: "unbounded" },
Effect.map((entries): Snapshot => ({ entries })),
) )
export function initialize(snapshot: Snapshot): Initialized { /** Creates the immutable baseline and durable snapshot for a new generation. */
return { export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
baseline: snapshot.entries.flatMap((entry) => return observe(value).pipe(
entry._tag === "Available" ? [{ key: entry.key, text: entry.baseline }] : [], Effect.flatMap((entries) => {
), const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
checkpoint: nextCheckpoint(snapshot, {}), if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable })
} return Effect.succeed(initializeObservation(entries))
}
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 }]
: [],
),
checkpoint: nextCheckpoint(snapshot, previous),
}
}
export function render(parts: ReadonlyArray<Part>) {
return parts.map((part) => part.text).join("\n\n")
}
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]]
}), }),
) )
} }
function getCheckpoint(checkpoint: Checkpoint, key: Key) { function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
return Object.hasOwn(checkpoint, key) ? checkpoint[key] : undefined const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available")
} const rendered = available.map((entry) => [entry.key, entry.baseline()] as const)
return {
function assertUniqueKeys(components: ReadonlyArray<Component<unknown, unknown>>) { baseline: render(rendered.map(([, result]) => result.text)),
const keys = new Set<Key>() snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])),
for (const component of components) { }
if (keys.has(component.key)) throw new DuplicateKeyError({ key: component.key }) }
keys.add(component.key)
/** 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 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 }
}
/** 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: "ReplacementReady", 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 source of sources) {
if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key })
keys.add(source.key)
} }
} }

View File

@ -7,6 +7,7 @@ import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { eq, inArray, sql } from "drizzle-orm" import { eq, inArray, sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration" import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import { migrations } from "@opencode-ai/core/database/migration.gen"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage" import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths" import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order" import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
@ -63,7 +64,10 @@ describe("DatabaseMigration", () => {
expect( expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
).toEqual({ 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 count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect( expect(
yield* db.all( 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_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`,

View File

@ -190,6 +190,56 @@ 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>()
const aggregateID = EventV2.ID.create()
yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector")))
yield* events.publish(
SyncMessage,
{ id: aggregateID, 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", () => it.effect("runs projectors before publishing to streams", () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service

View File

@ -0,0 +1,299 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { InstructionContext } from "@opencode-ai/core/instruction-context"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
describe("InstructionContext", () => {
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const directory = path.join(project, "packages", "core")
const outside = path.join(tmp.path, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(outside, "outside")
await fs.writeFile(globalFile, "global")
await fs.writeFile(projectFile, "project")
await fs.writeFile(packageFile, "package")
})
const load = SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(FSUtil.defaultLayer),
Effect.provide(Global.layerWith({ config: global })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
),
)
const initialized = yield* SystemContext.initialize(yield* load)
expect(initialized.baseline).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${packageFile}\npackage`,
`Instructions from: ${projectFile}\nproject`,
].join("\n\n"),
)
expect(initialized.baseline).not.toContain("outside")
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toMatchObject({
_tag: "Updated",
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
})
yield* Effect.promise(() => fs.rm(packageFile))
const partial = yield* SystemContext.reconcile(yield* load, initialized.snapshot)
expect(partial).toEqual({
_tag: "Updated",
text: [
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${projectFile}\nproject`,
].join("\n\n"),
snapshot: expect.any(Object),
})
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({
_tag: "Updated",
text: "Previously loaded instructions no longer apply.",
snapshot: {},
})
}),
),
),
)
it.live("keeps an empty AGENTS.md as available context", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.path, "AGENTS.md")
yield* Effect.promise(() => fs.writeFile(file, ""))
const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(FSUtil.defaultLayer),
Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
),
)
expect((yield* SystemContext.initialize(context)).baseline).toBe(`Instructions from: ${file}\n`)
}),
),
),
)
it.effect("preserves admitted instructions while observation is unavailable", () =>
Effect.gen(function* () {
const failingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(failingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
),
)
expect(
yield* SystemContext.reconcile(context, {
"core/instructions": {
value: [{ path: "/repo/AGENTS.md", content: "old" }],
removed: "Previously loaded instructions no longer apply.",
},
}),
).toEqual({ _tag: "Unchanged" })
}),
)
it.effect("preserves admitted instructions when a discovered file disappears before read", () =>
Effect.gen(function* () {
const file = AbsolutePath.make("/repo/AGENTS.md")
const racingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([file]),
readFileStringSafe: () => Effect.succeed(undefined),
}),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(racingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
),
)
expect(
yield* SystemContext.reconcile(context, {
"core/instructions": {
value: [{ path: file, content: "old" }],
removed: "Previously loaded instructions no longer apply.",
},
}),
).toEqual({ _tag: "Unchanged" })
}),
)
it.effect("canonicalizes upward discovery boundaries", () =>
Effect.gen(function* () {
let observed: { targets: string[]; start: string; stop?: string } | undefined
const observingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: (options) =>
Effect.sync(() => {
observed = options
return []
}),
}),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(observingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make("/repo/") },
{ projectDirectory: AbsolutePath.make("/repo") },
),
),
),
),
)
expect(observed).toEqual({ targets: ["AGENTS.md"], start: FSUtil.resolve("/repo"), stop: FSUtil.resolve("/repo") })
}),
)
it.effect("honors the project instruction opt-out", () =>
Effect.gen(function* () {
const previous = process.env.OPENCODE_DISABLE_PROJECT_CONFIG
let scanned = false
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(
Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(FSUtil.defaultLayer)),
),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
),
Effect.ensuring(
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG
else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = previous
}),
),
)
expect(scanned).toBe(false)
}),
)
it.effect("does not discover project instructions outside the canonical project root", () =>
Effect.gen(function* () {
let scanned = false
yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(
Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(FSUtil.defaultLayer)),
),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make("/outside") },
{ projectDirectory: AbsolutePath.make("/repo") },
),
),
),
),
)
expect(scanned).toBe(false)
}),
)
})

View File

@ -32,6 +32,12 @@ describe("toLLMMessages", () => {
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
time: { created }, time: { created },
}), }),
new SessionMessage.System({
id: id("system"),
type: "system",
text: "Updated context\n\nOther context",
time: { created },
}),
new SessionMessage.User({ new SessionMessage.User({
id: id("user"), id: id("user"),
type: "user", type: "user",
@ -67,8 +73,9 @@ describe("toLLMMessages", () => {
model, model,
) )
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"]) expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
expect(messages[0]).toEqual( expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
expect(messages[1]).toEqual(
Message.make({ Message.make({
id: id("user"), id: id("user"),
role: "user", role: "user",
@ -79,7 +86,7 @@ describe("toLLMMessages", () => {
metadata: { agents: [{ name: "build" }], references: [reference] }, metadata: { agents: [{ name: "build" }], references: [reference] },
}), }),
) )
expect(messages.slice(1).map((message) => message.content)).toEqual([ expect(messages.slice(2).map((message) => message.content)).toEqual([
[{ type: "text", text: "Synthetic context" }], [{ type: "text", text: "Synthetic context" }],
[{ type: "text", text: "Shell command: pwd\n\n/project" }], [{ type: "text", text: "Shell command: pwd\n\n/project" }],
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }], [{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],

View File

@ -19,6 +19,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
@ -55,6 +56,7 @@ const model = OpenAIChat.route
}) })
.model({ id: "gpt-4o-mini" }) .model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContext = SystemContextRegistry.layer
const runner = SessionRunnerLLM.defaultLayer.pipe( const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(database), Layer.provide(database),
Layer.provide(store), Layer.provide(store),
@ -62,6 +64,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(client), Layer.provide(client),
Layer.provide(registry), Layer.provide(registry),
Layer.provide(models), Layer.provide(models),
Layer.provide(systemContext),
) )
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect( const execution = Layer.effect(
@ -88,6 +91,7 @@ const it = testEffect(
permission, permission,
registry, registry,
models, models,
systemContext,
runner, runner,
coordinator, coordinator,
execution, execution,

View File

@ -32,11 +32,18 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { NativeTool } from "@opencode-ai/core/tool/native" 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 { SessionStore } from "@opencode-ai/core/session/store"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect" import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@ -54,6 +61,7 @@ let streamStarted: Deferred.Deferred<void> | undefined
let streamFailure: LLMError | undefined let streamFailure: LLMError | undefined
let toolExecutionGate: Deferred.Deferred<void> | undefined let toolExecutionGate: Deferred.Deferred<void> | undefined
let toolExecutionsStarted: Deferred.Deferred<void> | undefined let toolExecutionsStarted: Deferred.Deferred<void> | undefined
let toolExecutionsReady = 5
let activeToolExecutions = 0 let activeToolExecutions = 0
let maxActiveToolExecutions = 0 let maxActiveToolExecutions = 0
const client = Layer.succeed( const client = Layer.succeed(
@ -82,6 +90,7 @@ const client = Layer.succeed(
}), }),
) )
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
const authorizations: ToolRegistry.AuthorizeInput[] = [] const authorizations: ToolRegistry.AuthorizeInput[] = []
const executions: string[] = [] const executions: string[] = []
const permission = Layer.succeed( const permission = Layer.succeed(
@ -115,7 +124,7 @@ const echo = Layer.effectDiscard(
executions.push(text) executions.push(text)
activeToolExecutions++ activeToolExecutions++
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
if (activeToolExecutions === 5 && toolExecutionsStarted) { if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
yield* Deferred.succeed(toolExecutionsStarted, undefined) yield* Deferred.succeed(toolExecutionsStarted, undefined)
} }
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
@ -134,7 +143,43 @@ const echo = Layer.effectDiscard(
}), }),
), ),
).pipe(Layer.provide(registry)) ).pipe(Layer.provide(registry))
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) const models = SessionRunnerModel.layerWith((session) =>
Effect.succeed(session.model?.id === "replacement" ? replacementModel : model),
)
const systemContextKey = SystemContext.Key.make("test/context")
let systemBaseline = "Initial context"
let systemRemoved = false
let systemUnavailable = false
let systemLoadHook = Effect.void
const systemContext = Layer.effectDiscard(
SystemContextRegistry.Service.pipe(
Effect.flatMap((registry) =>
registry.contribute({
key: systemContextKey,
load: Effect.sync(() =>
SystemContext.combine(
systemRemoved
? []
: [
SystemContext.make({
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
load: systemLoadHook.pipe(
Effect.andThen(
Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
),
),
baseline: String,
update: (_previous, current) => current,
removed: () => "System context source removed: test/context",
}),
],
),
),
}),
),
),
).pipe(Layer.provideMerge(SystemContextRegistry.layer))
const runner = SessionRunnerLLM.layer.pipe( const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(database), Layer.provide(database),
Layer.provide(store), Layer.provide(store),
@ -142,6 +187,7 @@ const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(client), Layer.provide(client),
Layer.provide(registry), Layer.provide(registry),
Layer.provide(models), Layer.provide(models),
Layer.provide(systemContext),
) )
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect( const execution = Layer.effect(
@ -170,6 +216,7 @@ const it = testEffect(
registry, registry,
echo, echo,
models, models,
systemContext,
runner, runner,
coordinator, coordinator,
execution, execution,
@ -200,6 +247,10 @@ const insertSession = (id: SessionV2.ID) =>
const setup = Effect.gen(function* () { const setup = Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
response = [] response = []
systemBaseline = "Initial context"
systemRemoved = false
systemUnavailable = false
systemLoadHook = Effect.void
responses = undefined responses = undefined
streamFailure = undefined streamFailure = undefined
responseStream = undefined responseStream = undefined
@ -207,6 +258,7 @@ const setup = Effect.gen(function* () {
streamStarted = undefined streamStarted = undefined
toolExecutionGate = undefined toolExecutionGate = undefined
toolExecutionsStarted = undefined toolExecutionsStarted = undefined
toolExecutionsReady = 5
activeToolExecutions = 0 activeToolExecutions = 0
maxActiveToolExecutions = 0 maxActiveToolExecutions = 0
yield* db yield* db
@ -511,6 +563,411 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("retries the first provider turn after system context becomes available", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
const messageID = SessionMessage.ID.create()
systemUnavailable = true
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked)
expect(requests).toHaveLength(0)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
expect(
yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get(),
).toBeUndefined()
systemUnavailable = false
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) })
yield* (yield* SessionRunCoordinator.Service).awaitIdle(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
}),
)
it.effect("requires a complete new baseline after a Session moves", () =>
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 })
requests.length = 0
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: { directory: AbsolutePath.make("/moved") },
})
expect(
yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get(),
).toBeUndefined()
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked)
expect(requests).toHaveLength(1)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
}),
)
it.effect("does not create a source Location epoch after a concurrent Session move", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
let moved = false
systemLoadHook = Effect.suspend(() => {
if (moved) return Effect.void
moved = true
return events
.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: { directory: AbsolutePath.make("/moved") },
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
expect(
yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get(),
).toBeUndefined()
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
}),
)
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(3)
const { db } = yield* Database.Service
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(3)
}),
)
it.effect("admits removed context as a chronological System message", () =>
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 source removed: test/context" },
])
expect(yield* session.messages({ sessionID })).toHaveLength(3)
}),
)
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
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,
messageID: SessionMessage.ID.create(),
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"])
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
"user",
"user",
"model-switched",
"user",
])
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(5)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false })
yield* session.resume(sessionID)
}),
)
it.effect("defers replacement while admitted context is temporarily unavailable", () =>
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.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
systemUnavailable = false
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"],
])
}),
)
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,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(2),
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
})
const latest = yield* SessionInput.latestSeq(db, sessionID)
expect(
yield* db
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toEqual({ replacementSeq: latest })
}),
)
it.effect("retries epoch preparation until observation-time invalidations settle", () =>
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 })
response = []
yield* session.resume(sessionID)
requests.length = 0
systemBaseline = "Changed context"
let invalidations = 0
systemLoadHook = Effect.suspend(() => {
if (invalidations === 4) return Effect.void
invalidations++
return events
.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(invalidations),
model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") },
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(invalidations).toBe(4)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"])
}),
)
it.effect("replays retained context projections while replacement is pending", () =>
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,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
yield* replaySessionProjection(sessionID)
systemBaseline = "Replacement context"
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(["Replacement context"])
}),
)
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,
messageID: SessionMessage.ID.create(),
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"],
])
yield* replaySessionProjection(sessionID)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
}),
)
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,
messageID: SessionMessage.ID.create(),
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)
}),
)
it.effect("projects reasoning and tool events without executing or continuing tools", () => it.effect("projects reasoning and tool events without executing or continuing tools", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
@ -667,6 +1124,50 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("reloads a model switch before a tool-driven continuation turn", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false })
requests.length = 0
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
toolExecutionsReady = 1
const run = yield* Effect.forkChild(session.resume(sessionID))
yield* Deferred.await(toolExecutionsStarted)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemBaseline = "Replacement context"
yield* Deferred.succeed(toolExecutionGate, undefined)
yield* Fiber.join(run)
expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Replacement context"],
])
}),
)
it.effect("restores durable reasoning provider metadata in a second-turn request", () => it.effect("restores durable reasoning provider metadata in a second-turn request", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup

View File

@ -1,70 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionSystemContext } from "@opencode-ai/core/session-system-context"
import { SystemContext } from "@opencode-ai/core/system-context"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const directory = AbsolutePath.make("/repo/packages/core")
const projectDirectory = AbsolutePath.make("/repo")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
const it = testEffect(
SessionSystemContext.locationLayer.pipe(
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory }, { projectDirectory, vcs: { type: "git", store: AbsolutePath.make("/repo/.git") } }),
),
),
),
),
)
describe("SessionSystemContext", () => {
it.effect("loads location-scoped environment and host-local date context", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SessionSystemContext.Service
const initialized = 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)}` },
])
}),
)
it.effect("refreshes 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())
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
const refreshed = SystemContext.refresh(yield* context.load(), initialized.checkpoint)
expect(refreshed.changes).toEqual([
{
key: SystemContext.Key.make("core/date"),
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
},
])
}),
)
})

View File

@ -0,0 +1,127 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Location } from "@opencode-ai/core/location"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context-builtins"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const it = testEffect(
SystemContextBuiltIns.locationLayer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.layerWith({ config: "/global" })),
Layer.provide(locationLayer),
),
)
const instructionFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([instructionFile]),
readFileStringSafe: (path) => Effect.succeed(path === instructionFile ? "Be precise." : undefined),
}),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
const itWithInstructions = testEffect(
SystemContextBuiltIns.locationLayer.pipe(
Layer.provide(instructionFS),
Layer.provide(Global.layerWith({ config: "/global" })),
Layer.provide(locationLayer),
),
)
describe("SystemContextBuiltIns", () => {
it.effect("loads location-scoped environment and host-local date context", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())
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("reconciles the date without repeating unchanged environment context", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)
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* SystemContextRegistry.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" })
}),
)
itWithInstructions.effect("composes ambient instructions after built-in context", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
expect((yield* SystemContext.initialize(yield* context.load())).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)}`,
"",
`Instructions from: ${instructionFile}\nBe precise.`,
].join("\n"),
)
}),
)
})

View File

@ -0,0 +1,113 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Schema, Scope } from "effect"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
import { testEffect } from "./lib/effect"
const contribution = (key: string, text: string, sourceKey = key) => ({
key: SystemContext.Key.make(key),
load: Effect.succeed(
SystemContext.make({
key: SystemContext.Key.make(sourceKey),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(text),
baseline: String,
update: (_previous, current) => current,
}),
),
})
const it = testEffect(SystemContextRegistry.layer)
describe("SystemContextRegistry", () => {
it.effect("loads empty system context when there are no contributions", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} })
}),
)
it.effect("loads scoped contributions in stable key order", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/second", "second"))
yield* registry.contribute(contribution("test/first", "first"))
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
}),
)
it.effect("re-evaluates contribution producers on each load", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
let loads = 0
yield* registry.contribute({
key: SystemContext.Key.make("test/dynamic"),
load: Effect.sync(() => {
loads++
return SystemContext.empty
}),
})
yield* registry.load()
yield* registry.load()
expect(loads).toBe(2)
}),
)
it.effect("propagates contribution producer failures", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
const failure = new Error("contribution failed")
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
const exit = yield* registry.load().pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(failure)
}),
)
it.effect("rejects duplicate source keys from separate contributions", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/first", "first", "test/duplicate"))
yield* registry.contribute(contribution("test/second", "second", "test/duplicate"))
const exit = yield* registry.load().pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.DuplicateKeyError)
expect(Cause.squash(exit.cause)).toMatchObject({ key: SystemContext.Key.make("test/duplicate") })
}
}),
)
it.effect("rejects duplicate contribution keys", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/duplicate", "first"))
const exit = yield* registry.contribute(contribution("test/duplicate", "second", "test/other")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context contribution key")
}),
)
it.effect("removes a contribution when its owning scope closes", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
const scope = yield* Scope.make()
yield* registry.contribute(contribution("test/scoped", "scoped")).pipe(Scope.provide(scope))
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")
yield* Scope.close(scope, Exit.void)
expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} })
}),
)
})

View File

@ -1,188 +1,307 @@
import { describe, expect, test } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect" import { Cause, Effect, Exit, Schema } from "effect"
import { SystemContext } from "@opencode-ai/core/system-context" 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 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", () => { describe("SystemContext", () => {
test("loads one coherent sample and initializes a deterministic baseline", async () => { it.effect("stores the canonical JSON encoding of the loaded value", () =>
let loads = 0 Effect.gen(function* () {
const context = SystemContext.struct({ const context = SystemContext.make({
date: SystemContext.value({
key: key("core/date"), 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: "ReplacementReady" })
}),
)
it.effect("blocks initialization while a source is unavailable", () =>
Effect.gen(function* () {
const exit = yield* SystemContext.initialize(
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Cause.squash(exit.cause)).toEqual(
new SystemContext.InitializationBlocked({ keys: [key("core/remote")] }),
)
}),
)
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: "ReplacementReady",
})
}),
)
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: "ReplacementReady" })
}),
)
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(() => { load: Effect.sync(() => {
loads++ loads++
return { baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." } return "2026-06-04"
}), }),
}), baseline: String,
location: SystemContext.value({ update: (_previous, current) => current,
key: key("core/location"), })
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
}),
})
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context))) expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
_tag: "ReplacementReady",
generation: { baseline: "2026-06-04" },
})
expect(loads).toBe(1)
}),
)
expect(loads).toBe(1) it.effect("does not render discarded updates while replacing", () =>
expect(initialized).toEqual({ Effect.gen(function* () {
baseline: [ let updates = 0
{ key: key("core/date"), text: "Today's date is 2026-06-03." }, const context = SystemContext.combine([
{ key: key("core/location"), text: "Working directory: /repo" }, stringContext({
], key: "core/date",
checkpoint: { value: "2026-06-04",
"core/date": Hash.sha256("The current date is 2026-06-03."), update: () => {
"core/location": Hash.sha256("The working directory is /repo."), updates++
}, return "updated"
}) },
}) }),
stringContext({ key: "core/location", value: "/repo" }),
])
test("emits changed and newly registered components in declaration order", async () => { expect(
const context = SystemContext.struct({ yield* SystemContext.reconcile(context, {
date: SystemContext.value({ "core/date": { value: "2026-06-03" },
key: key("core/date"), "core/location": { value: 42 },
load: Effect.succeed({ baseline: "Today's date is 2026-06-04.", update: "The current date is 2026-06-04." }), }),
}), ).toMatchObject({ _tag: "ReplacementReady" })
location: SystemContext.value({ expect(updates).toBe(0)
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)), { it.effect("blocks an incompatible replacement while another admitted source is unavailable", () =>
"core/date": Hash.sha256("The current date is 2026-06-03."), Effect.gen(function* () {
"core/location": Hash.sha256("The working directory is /repo."), 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 }),
])
expect(refreshed).toEqual({ expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
changes: [ expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
{ 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"),
},
})
expect(SystemContext.render(refreshed.changes)).toBe("The current date is 2026-06-04.\n\nAvailable skills: effect")
})
test("omits unavailable initial context and admits it after its first successful load", async () => { it.effect("rejects duplicate source keys", () =>
let available = false Effect.sync(() => {
const context = SystemContext.struct({ expect(() =>
remote: SystemContext.value({ SystemContext.combine([
key: key("core/remote-instructions"), stringContext({ key: "core/date", value: "one" }),
load: Effect.sync(() => stringContext({ key: "core/date", value: "two" }),
available ]),
? { baseline: "Remote instructions: available", update: "Remote instructions are now available." } ).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
: SystemContext.unavailable, }),
), )
}),
})
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context))) it.effect("combines contexts in order", () =>
available = true Effect.gen(function* () {
const refreshed = SystemContext.refresh( expect(
await Effect.runPromise(SystemContext.load(context)), (yield* SystemContext.initialize(
initialized.checkpoint, SystemContext.combine([
) stringContext({ key: "core/date", value: "date" }),
stringContext({ key: "core/location", value: "location" }),
]),
)).baseline,
).toBe("date\n\nlocation")
}),
)
expect(initialized).toEqual({ baseline: [], checkpoint: {} }) it.effect("requires namespaced source keys", () =>
expect(refreshed.changes).toEqual([ Effect.sync(() => {
{ key: key("core/remote-instructions"), text: "Remote instructions are now available." }, const decodeKey = Schema.decodeUnknownSync(SystemContext.Key)
])
})
test("retains an existing checkpoint while context is unavailable", async () => { expect(decodeKey("core/date")).toBe(key("core/date"))
const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") } expect(() => decodeKey("date")).toThrow()
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) it.effect("requires namespaced durable snapshot keys", () =>
Effect.sync(() => {
const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot)
expect(refreshed).toEqual({ changes: [], checkpoint: previous }) 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()
test("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." }),
}),
})
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(refreshed).toEqual({
changes: [],
checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") },
})
})
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
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
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)
})
test("preserves unexpected loader failures", async () => {
const context = SystemContext.struct({
broken: SystemContext.value({
key: key("plugin/broken"),
load: Effect.fail("broken loader"),
}),
})
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()
})
}) })

View File

@ -351,38 +351,29 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
} }
const endsInLocalToolUse = (message: LLMRequest["messages"][number]) => { const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: 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 previous = messages[index - 1] const previous = messages[index - 1]
const next = messages[index + 1] const next = messages[index + 1]
if (!previous) return (
return yield* invalid( previous !== undefined &&
"Anthropic Messages chronological system updates cannot be the first message; use LLMRequest.system", previous.role !== "system" &&
) (previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) &&
if (previous.role === "system") next?.role !== "system" &&
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive") (next === undefined || next.role === "assistant")
if (endsInLocalToolUse(previous)) )
return yield* invalid( }
"Anthropic Messages chronological system updates cannot appear between a local tool call and its tool result",
) const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => {
if (previous.role !== "user" && previous.role !== "tool" && !endsInServerToolUse(previous)) const pending = new Set<string>()
return yield* invalid( for (const message of messages.slice(0, index)) {
"Anthropic Messages chronological system updates must follow a user message, tool result, or assistant server tool use", for (const part of message.content) {
) if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true)
if (next?.role === "system") pending.add(part.id)
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive") if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id)
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 pending.size > 0
) }
})
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* ( const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
message: LLMRequest["messages"][number], message: LLMRequest["messages"][number],
@ -407,8 +398,9 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
for (const [index, message] of request.messages.entries()) { for (const [index, message] of request.messages.entries()) {
if (message.role === "system") { if (message.role === "system") {
if (supportsNativeSystemUpdates(request)) { if (splitsLocalToolResults(request.messages, index))
yield* validateNativeSystemUpdate(request.messages, index) return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints)) messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue continue
} }

View File

@ -125,26 +125,65 @@ 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* () { Effect.gen(function* () {
const placementError = (messages: Parameters<typeof LLM.request>[0]["messages"]) => expect(
LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip) (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* 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.system("First.")])).message).toContain("cannot be the first message") it.effect("rejects a system update between a local tool call and its result", () =>
expect( Effect.gen(function* () {
(yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message, const error = yield* LLMClient.prepare(
).toContain("cannot be consecutive") LLM.request({
expect( model: opus48,
(yield* placementError([Message.assistant("Plain."), Message.system("After plain assistant.")])).message, messages: [
).toContain("must follow a user message, tool result, or assistant server tool use") Message.user("Use the tool."),
expect( Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
(yield* placementError([ Message.system("Too early."),
Message.user("Use the tool."), Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), ],
Message.system("Too early."), cache: "none",
Message.tool({ id: "call_1", name: "lookup", result: "Done." }), }),
])).message, ).pipe(Effect.flip)
).toContain("cannot appear between a local tool call and its tool result")
expect(error.message).toContain("system updates cannot split a local tool call from its tool result")
}), }),
) )

View File

@ -123,7 +123,11 @@ describeRecordedGoldenScenarios([
prefix: "gemini", prefix: "gemini",
model: gemini, model: gemini,
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
scenarios: [{ id: "text", maxTokens: 80 }, "tool-call", { id: "image", maxTokens: 160 }], scenarios: [
{ id: "text", maxTokens: 80 },
"tool-call",
{ id: "image", maxTokens: 160 },
],
}, },
{ {
name: "xAI Grok 3 Mini", name: "xAI Grok 3 Mini",

View File

@ -1,5 +1,5 @@
import { expect } from "bun:test" import { expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema } from "effect"
import { import {
LLM, LLM,
LLMEvent, LLMEvent,

View File

@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
import { Schema } from "effect" import { Schema } from "effect"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses" import * as OpenAIResponses from "../src/protocols/openai-responses"
import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema" import {
ContentPart,
LLMEvent,
LLMRequest,
Model,
ModelID,
ProviderID,
Usage,
} from "../src/schema"
import { ProviderShared } from "../src/protocols/shared" import { ProviderShared } from "../src/protocols/shared"
const model = new Model({ const model = new Model({
@ -43,17 +51,6 @@ describe("llm schema", () => {
expect(decoded.model.route.id).toBe("openai-responses") expect(decoded.model.route.id).toBe("openai-responses")
}) })
test("decodes chronological system messages", () => {
const decoded = decodeLLMRequest({
model,
system: [],
messages: [{ role: "system", content: [{ type: "text", text: "Operator update." }] }],
tools: [],
})
expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] })
})
test("rejects invalid event type", () => { test("rejects invalid event type", () => {
expect(() => decodeLLMEvent({ type: "bogus" })).toThrow() expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
}) })

View File

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

View File

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

View File

@ -261,6 +261,55 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
} }
}) })
test("sync v2 projects live context updates with their message ID", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_context_1",
type: "session.next.context.updated",
properties: {
sessionID: "session-1",
messageID: "msg_context_1",
timestamp: 1,
text: "Updated context",
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({
id: "msg_context_1",
type: "system",
text: "Updated context",
})
} finally {
app.renderer.destroy()
}
})
test("sync v2 preserves live events while snapshot hydration is in flight", async () => { test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
const events = createEventSource() const events = createEventSource()
const response = Promise.withResolvers<Response>() const response = Promise.withResolvers<Response>()

View File

@ -21,6 +21,7 @@ export type Event =
| EventSessionNextPrompted | EventSessionNextPrompted
| EventSessionNextPromptAdmitted | EventSessionNextPromptAdmitted
| EventSessionNextPromptPromoted | EventSessionNextPromptPromoted
| EventSessionNextContextUpdated
| EventSessionNextSynthetic | EventSessionNextSynthetic
| EventSessionNextShellStarted | EventSessionNextShellStarted
| EventSessionNextShellEnded | EventSessionNextShellEnded
@ -867,6 +868,16 @@ export type GlobalEvent = {
timeCreated: number timeCreated: number
} }
} }
| {
id: string
type: "session.next.context.updated"
properties: {
timestamp: number
sessionID: string
messageID: string
text: string
}
}
| { | {
id: string id: string
type: "session.next.synthetic" type: "session.next.synthetic"
@ -1615,6 +1626,7 @@ export type GlobalEvent = {
| SyncEventSessionNextPrompted | SyncEventSessionNextPrompted
| SyncEventSessionNextPromptAdmitted | SyncEventSessionNextPromptAdmitted
| SyncEventSessionNextPromptPromoted | SyncEventSessionNextPromptPromoted
| SyncEventSessionNextContextUpdated
| SyncEventSessionNextSynthetic | SyncEventSessionNextSynthetic
| SyncEventSessionNextShellStarted | SyncEventSessionNextShellStarted
| SyncEventSessionNextShellEnded | SyncEventSessionNextShellEnded
@ -3259,6 +3271,23 @@ export type SyncEventSessionNextPromptPromoted = {
} }
} }
export type SyncEventSessionNextContextUpdated = {
type: "sync"
id: string
syncEvent: {
type: "session.next.context.updated.1"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
messageID: string
text: string
}
}
}
export type SyncEventSessionNextSynthetic = { export type SyncEventSessionNextSynthetic = {
type: "sync" type: "sync"
id: string id: string
@ -3822,6 +3851,18 @@ export type SessionMessageSynthetic = {
type: "synthetic" type: "synthetic"
} }
export type SessionMessageSystem = {
id: string
metadata?: {
[key: string]: unknown
}
time: {
created: number
}
type: "system"
text: string
}
export type SessionMessageShell = { export type SessionMessageShell = {
id: string id: string
metadata?: { metadata?: {
@ -3980,6 +4021,7 @@ export type SessionMessage =
| SessionMessageModelSwitched | SessionMessageModelSwitched
| SessionMessageUser | SessionMessageUser
| SessionMessageSynthetic | SessionMessageSynthetic
| SessionMessageSystem
| SessionMessageShell | SessionMessageShell
| SessionMessageAssistant | SessionMessageAssistant
| SessionMessageCompaction | SessionMessageCompaction
@ -4339,6 +4381,17 @@ export type EventSessionNextPromptPromoted = {
} }
} }
export type EventSessionNextContextUpdated = {
id: string
type: "session.next.context.updated"
properties: {
timestamp: number
sessionID: string
messageID: string
text: string
}
}
export type EventSessionNextSynthetic = { export type EventSessionNextSynthetic = {
id: string id: string
type: "session.next.synthetic" type: "session.next.synthetic"

View File

@ -12026,6 +12026,9 @@
{ {
"$ref": "#/components/schemas/EventSessionNextPromptPromoted" "$ref": "#/components/schemas/EventSessionNextPromptPromoted"
}, },
{
"$ref": "#/components/schemas/EventSessionNextContextUpdated"
},
{ {
"$ref": "#/components/schemas/EventSessionNextSynthetic" "$ref": "#/components/schemas/EventSessionNextSynthetic"
}, },
@ -14621,6 +14624,42 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "additionalProperties": false
}, },
{
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^evt_"
},
"type": {
"type": "string",
"enum": ["session.next.context.updated"]
},
"properties": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"messageID": {
"type": "string",
"pattern": "^msg_"
},
"text": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "messageID", "text"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{ {
"type": "object", "type": "object",
"properties": { "properties": {
@ -17142,6 +17181,9 @@
{ {
"$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted"
}, },
{
"$ref": "#/components/schemas/SyncEventSessionNextContextUpdated"
},
{ {
"$ref": "#/components/schemas/SyncEventSessionNextSynthetic" "$ref": "#/components/schemas/SyncEventSessionNextSynthetic"
}, },
@ -21870,6 +21912,63 @@
"required": ["type", "id", "syncEvent"], "required": ["type", "id", "syncEvent"],
"additionalProperties": false "additionalProperties": false
}, },
"SyncEventSessionNextContextUpdated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["sync"]
},
"id": {
"type": "string",
"pattern": "^evt_"
},
"syncEvent": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["session.next.context.updated.1"]
},
"id": {
"type": "string",
"pattern": "^evt_"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"messageID": {
"type": "string",
"pattern": "^msg_"
},
"text": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "messageID", "text"],
"additionalProperties": false
}
},
"required": ["type", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
}
},
"required": ["type", "id", "syncEvent"],
"additionalProperties": false
},
"SyncEventSessionNextSynthetic": { "SyncEventSessionNextSynthetic": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -23634,6 +23733,37 @@
"required": ["id", "time", "sessionID", "text", "type"], "required": ["id", "time", "sessionID", "text", "type"],
"additionalProperties": false "additionalProperties": false
}, },
"SessionMessageSystem": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["system"]
},
"text": {
"type": "string"
}
},
"required": ["id", "time", "type", "text"],
"additionalProperties": false
},
"SessionMessageShell": { "SessionMessageShell": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -24071,6 +24201,9 @@
{ {
"$ref": "#/components/schemas/SessionMessageSynthetic" "$ref": "#/components/schemas/SessionMessageSynthetic"
}, },
{
"$ref": "#/components/schemas/SessionMessageSystem"
},
{ {
"$ref": "#/components/schemas/SessionMessageShell" "$ref": "#/components/schemas/SessionMessageShell"
}, },
@ -25174,6 +25307,42 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "additionalProperties": false
}, },
"EventSessionNextContextUpdated": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^evt_"
},
"type": {
"type": "string",
"enum": ["session.next.context.updated"]
},
"properties": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"messageID": {
"type": "string",
"pattern": "^msg_"
},
"text": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "messageID", "text"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionNextSynthetic": { "EventSessionNextSynthetic": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -686,3 +686,85 @@ Compatibility:
- Foreground V2 bash execution is unchanged. - Foreground V2 bash execution is unchanged.
- Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics. - Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics.
## 2026-06-04: Add Durable Session Context Snapshots
Affected schema:
- Add `session_context_epoch` for one active immutable baseline string, structured JSON snapshot, and baseline sequence per Session.
Change:
- Lazily initialize one durable Context Epoch snapshot at the first safe provider-turn boundary.
- Lower its exact baseline string 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.
- Compare later observations against an overwriteable codec-encoded structured snapshot rather than rendered-text hashes.
- Expose admitted chronological context as first-class `system` Session messages while keeping the active baseline in bounded context state.
Compatibility:
- The unpublished Context Epoch schema is consolidated into one database migration; baseline and structured snapshots are operational state rather than synchronized event history.
- 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 containing a durable System-message ID and only exact combined model-visible text.
- Add `session_context_epoch.revision` for transactional structured-snapshot advancement.
- Add the first-class `system` Session message projection for chronological context updates.
Change:
- Reconcile Location-scoped Context Sources at each safe provider-turn boundary using one coherent observation.
- Keep the stored baseline immutable while admitting changed source renderings as chronological `Message.system(...)` history.
- Advance the overwriteable structured snapshot atomically with the rendered System-message event.
- Emit the previously stored model-meaningful removal rendering when a source is removed.
- 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:
- The synchronized event log retains only text actually shown to the model, not internal structured snapshots.
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
- Replacement epochs after compaction or model switches, skills guidance, and plugin-defined context remain follow-up slices.
## 2026-06-04: Replace Session Context Epochs Lazily
Affected schema:
- Add nullable `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 overwrite the fresh immutable baseline and structured snapshot lazily at the next safe provider-turn boundary.
- Exclude chronological System messages from earlier epochs when assembling active provider history.
Compatibility:
- Baseline replacement is bounded operational state and does not add permanent synchronized events.
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
- Compaction execution, skills guidance, and plugin-defined context remain follow-up slices.
## 2026-06-05: Register Ambient System Context Producers
Affected schema:
- No database schema changes.
Change:
- Replace the Session-specific context loader with a Location-scoped registry of stable-keyed scoped context producers.
- Register environment/date and ambient instruction producers independently, then evaluate producers concurrently in stable contribution-key order.
- Directly discover and read global plus upward project `AGENTS.md` files at each safe provider-turn boundary.
- Preserve admitted instructions across transient scan/read failures and block first-epoch initialization while any context source is unavailable.
- Retry Context Epoch preparation until stable after optimistic revision mismatches.
- Clear the active Context Epoch when a Session moves so the destination initializes a complete baseline before promoting more input.
- Fence Context Epoch initialization against the authoritative Session Location so a concurrent old-Location runner cannot recreate stale privileged context after a move.
- Canonicalize ambient instruction traversal boundaries, honor `OPENCODE_DISABLE_PROJECT_CONFIG`, and make non-empty aggregate updates explicitly supersede previously loaded instructions.
Compatibility:
- Watcher-backed per-file `Refreshable` instruction observations, configured sources, nested discovery, and plugin-defined context remain follow-up slices.

View File

@ -37,6 +37,66 @@ The local runner issues one explicit `llm.stream(request)` per provider turn, pr
Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted.
## Context Epochs
V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch owns one immutable baseline plus a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, and ambient global/upward-project `AGENTS.md` files are the initial registered sources.
The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later provider turns, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically.
```text
Client Runner System Context Registry Context Epoch Store Session History LLM
│ │ │ │ │ │
├─ Admit prompt ─────────────────────────────────────────────────────────────────────────────────────────────▶ │
│ │ │ │ │ │
│ ├─ Observe initial context ────────────▶ │ │ │
│ │ │ │ │ │
│ ◀─ Complete baseline or unavailable ───┤ │ │ │
│ │ │ │ │ │
│ ├─ Initialize missing epoch ───────────────────────────────────────▶ │ │
│ │ │ │ │ │
│ ├─ Promote eligible input ─────────────────────────────────────────────────────────────────▶ │
│ │ │ │ │ │
│ ├─ Reconcile at safe boundary ─────────▶ │ │ │
│ │ │ │ │ │
│ ◀─ Unchanged or chronological update ──┤ │ │ │
│ │ │ │ │ │
│ ├─ Advance snapshot atomically with update ────────────────────────▶ │ │
│ │ │ │ │ │
│ ├─ Baseline + chronological history ─────────────────────────────────────────────────────────────────────────▶
```
Model switches and completed compactions request lazy baseline replacement. A Session move clears the epoch so the destination Location must initialize a complete baseline before promoting more input. Epoch creation is fenced against the authoritative Session Location, preventing an old-Location runner from recreating stale privileged context after a concurrent move.
```text
Session Epoch
│ │
├─ initialize complete baseline ──▶
│ │
│ ├─────────────────────────────────╮
│ │ reconcile chronological update │
│ ◀─────────────────────────────────╯
│ │
├─ request replacement ───────────▶
│ │
│ ├─────────────────────────────────────╮
│ │ replace after complete observation │
│ ◀─────────────────────────────────────╯
│ │
├─ clear after Location move ─────▶
```
Ambient project discovery canonicalizes and contains traversal within the project root and honors `OPENCODE_DISABLE_PROJECT_CONFIG`. An unavailable observation preserves the previously admitted value. A confirmed partial instruction removal emits the complete remaining aggregate with explicit supersession text; removing the final instruction emits a revocation message.
Current Context Epoch follow-ups:
- Add configured, remote, and nested instruction sources with explicit precedence and removal semantics.
- Add durable post-crash activity recovery for promoted or provider-dispatched work.
- Integrate actual automatic/context-pressure compaction with epoch replacement.
- Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth.
- Consider watcher-backed per-file caching only if measurements show direct safe-boundary observation is too expensive.
- Expose plugin-defined Context Sources only after plugin reload and scoped cleanup semantics are designed.
- Add clustered Session execution ownership and stale-runtime fencing.
Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider. Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider.
Inbox delivery is explicit: Inbox delivery is explicit: