From 40450415541e61414ca0ded7ca31767545459dd5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Jul 2026 09:32:35 -0400 Subject: [PATCH] refactor(core): simplify system context into a belief model (#34917) --- AGENTS.md | 3 +- packages/core/src/instruction-context.ts | 43 +-- packages/core/src/location-services.ts | 4 +- packages/core/src/mcp/guidance.ts | 49 ++- packages/core/src/reference/guidance.ts | 48 ++- packages/core/src/session.ts | 10 +- .../core/src/session/context-checkpoint.ts | 131 ++++++++ packages/core/src/session/context-epoch.ts | 174 ---------- packages/core/src/session/error.ts | 12 - packages/core/src/session/history.ts | 19 +- packages/core/src/session/projector.ts | 40 ++- packages/core/src/session/runner/index.ts | 3 +- packages/core/src/session/runner/llm.ts | 37 ++- packages/core/src/session/runner/model.ts | 4 +- packages/core/src/session/sql.ts | 4 +- packages/core/src/session/store.ts | 7 - packages/core/src/skill/guidance.ts | 46 ++- packages/core/src/system-context/builtins.ts | 24 +- packages/core/src/system-context/index.ts | 311 +++++++++--------- packages/core/src/system-context/registry.ts | 49 --- packages/core/test/config/agent.test.ts | 1 + .../core/test/instruction-context.test.ts | 33 +- packages/core/test/plugin/host.ts | 12 +- .../plugin/provider-amazon-bedrock.test.ts | 2 +- .../test/plugin/provider-anthropic.test.ts | 2 +- .../core/test/plugin/provider-azure.test.ts | 6 +- .../core/test/plugin/provider-kilo.test.ts | 2 +- .../test/plugin/provider-llmgateway.test.ts | 2 +- .../core/test/plugin/provider-nvidia.test.ts | 3 +- .../test/plugin/provider-opencode.test.ts | 2 +- .../test/plugin/provider-openrouter.test.ts | 2 +- packages/core/test/reference-guidance.test.ts | 49 ++- packages/core/test/session-projector.test.ts | 13 +- .../core/test/session-runner-recorded.test.ts | 15 +- packages/core/test/session-runner.test.ts | 145 ++++---- packages/core/test/skill/guidance.test.ts | 88 ++++- .../core/test/system-context/builtins.test.ts | 24 +- .../core/test/system-context/index.test.ts | 174 +++++++--- .../core/test/system-context/registry.test.ts | 114 ------- .../llm/src/protocols/anthropic-messages.ts | 12 +- 40 files changed, 914 insertions(+), 805 deletions(-) create mode 100644 packages/core/src/session/context-checkpoint.ts delete mode 100644 packages/core/src/session/context-epoch.ts delete mode 100644 packages/core/src/system-context/registry.ts delete mode 100644 packages/core/test/system-context/registry.test.ts diff --git a/AGENTS.md b/AGENTS.md index 649f0ae0555..703bae89123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,4 +159,5 @@ const table = sqliteTable("session", { - Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. - Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. -- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. +- Keep the System Context algebra and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Checkpoint persistence Session-owned. The runner composes all context producers explicitly in `loadSystemContext`; there is no context registry. +- The durable Applied record is what the model was last told, per source. Reconcile narrates drift as chronological System updates and never rewrites the baseline; only completed compaction rebaselines, and move or committed revert resets the checkpoint. Unavailable sources keep the model's prior belief, blocking only a session's first baseline. diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index b0cc330678b..94d9e787ced 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -1,6 +1,6 @@ export * as InstructionContext from "./instruction-context" -import { Array, Effect, Layer, Schema } from "effect" +import { Array, Context, Effect, Layer, Schema } from "effect" import { isAbsolute, join, relative, sep } from "path" import { FSUtil } from "./fs-util" import { Flag } from "./flag/flag" @@ -8,7 +8,6 @@ import { Global } from "./global" import { Location } from "./location" import { AbsolutePath } from "./schema" import { SystemContext } from "./system-context/index" -import { SystemContextRegistry } from "./system-context/registry" import { makeLocationNode } from "./effect/app-node" class File extends Schema.Class("InstructionContext.File")({ @@ -19,12 +18,18 @@ class File extends Schema.Class("InstructionContext.File")({ const Files = Schema.Array(File) const key = SystemContext.Key.make("core/instructions") -const layer = Layer.effectDiscard( +export interface Interface { + readonly load: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/InstructionContext") {} + +const layer = Layer.effect( + Service, 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 | SystemContext.Unavailable) => SystemContext.make({ @@ -71,28 +76,24 @@ const layer = Layer.effectDiscard( return files.filter((file): file is File => file !== undefined) }) - yield* registry.register({ - key, - load: observe().pipe( - Effect.map((files) => - files === SystemContext.unavailable - ? source(files) - : files.length === 0 - ? SystemContext.empty - : source(files), + return Service.of({ + 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))), ), - Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))), - Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))), - ), }) }), ) -export const node = makeLocationNode({ - name: "instruction-context", - layer, - deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node], -}) +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Location.node] }) function render(files: ReadonlyArray) { return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 0d913c10fb2..c4d177ef25c 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -36,8 +36,8 @@ import { SessionTodo } from "./session/todo" import { SkillV2 } from "./skill" import { SkillGuidance } from "./skill/guidance" import { Snapshot } from "./snapshot" +import { InstructionContext } from "./instruction-context" import { SystemContextBuiltIns } from "./system-context/builtins" -import { SystemContextRegistry } from "./system-context/registry" import { SessionInstructions } from "./session/instructions" import { BuiltInTools } from "./tool/builtins" import { McpTool } from "./tool/mcp" @@ -68,8 +68,8 @@ export const locationServices = LayerNode.group([ Pty.node, Shell.node, SkillV2.node, - SystemContextRegistry.node, SystemContextBuiltIns.node, + InstructionContext.node, LocationMutation.node, FileMutation.node, MCP.node, diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index 8334e7b0637..eed26170b06 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -14,16 +14,40 @@ const Summary = Schema.Struct({ }) type Summary = typeof Summary.Type +const entries = (servers: ReadonlyArray) => + servers.flatMap((server) => [ + ` `, + ...server.instructions.split("\n").map((line) => ` ${line}`), + " ", + ]) + const render = (servers: ReadonlyArray) => - [ - "", - ...servers.flatMap((server) => [ - ` `, - ...server.instructions.split("\n").map((line) => ` ${line}`), - " ", - ]), - "", + ["", ...entries(servers), ""].join("\n") + +const update = (previous: ReadonlyArray, current: ReadonlyArray) => { + const diff = SystemContext.diffByKey( + previous, + current, + (server) => server.server, + (before, after) => before.instructions !== after.instructions, + ) + // Additions and removals render as small deltas; anything else restates the full list. + if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) + return [ + "The available MCP server instructions have changed. This list supersedes the previous one.", + render(current), + ].join("\n") + return [ + ...(diff.added.length === 0 + ? [] + : ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]), + ...(diff.removed.length === 0 + ? [] + : [ + `Instructions for the following MCP servers are no longer available: ${diff.removed.map((server) => server.server).join(", ")}.`, + ]), ].join("\n") +} export interface Interface { readonly load: (agent: AgentV2.Selection) => Effect.Effect @@ -50,7 +74,8 @@ export const layer = Layer.effect( return ( owned.length === 0 || owned.some( - (tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", + (tool) => + PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", ) ) }) @@ -61,11 +86,7 @@ export const layer = Layer.effect( codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(visible), baseline: render, - update: (_previous, current) => - [ - "The available MCP server instructions have changed. This list supersedes the previous one.", - render(current), - ].join("\n"), + update, removed: () => "MCP server instructions are no longer available.", }) }), diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index 11fcb9ecea2..7fc4fc486cb 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -11,20 +11,48 @@ const Summary = Schema.Struct({ description: Schema.String.pipe(Schema.optional), }) +const entries = (references: ReadonlyArray) => + references.flatMap((reference) => [ + " ", + ` ${reference.name}`, + ` ${reference.path}`, + ...(reference.description === undefined ? [] : [` ${reference.description}`]), + " ", + ]) + const render = (references: ReadonlyArray) => [ "Project references provide additional directories that can be accessed when relevant.", "", - ...references.flatMap((reference) => [ - " ", - ` ${reference.name}`, - ` ${reference.path}`, - ...(reference.description === undefined ? [] : [` ${reference.description}`]), - " ", - ]), + ...entries(references), "", ].join("\n") +const update = (previous: ReadonlyArray, current: ReadonlyArray) => { + const diff = SystemContext.diffByKey( + previous, + current, + (reference) => reference.name, + (before, after) => before.path !== after.path || before.description !== after.description, + ) + // Additions and removals render as small deltas; anything else restates the full list. + if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) + return [ + "The available project references have changed. This list supersedes the previous reference list.", + render(current), + ].join("\n") + return [ + ...(diff.added.length === 0 + ? [] + : ["New project references are available in addition to those previously listed:", ...entries(diff.added)]), + ...(diff.removed.length === 0 + ? [] + : [ + `The following project references are no longer available and must not be used: ${diff.removed.map((reference) => reference.name).join(", ")}.`, + ]), + ].join("\n") +} + export interface Interface { readonly load: () => Effect.Effect } @@ -52,11 +80,7 @@ const layer = Layer.effect( codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(available), baseline: render, - update: (_previous, current) => - [ - "The available project references have changed. This list supersedes the previous reference list.", - render(current), - ].join("\n"), + update, removed: () => "Project reference guidance is no longer available. Do not use previously listed references.", }) }), diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index bbd6e2cc4b5..9af15f72082 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -108,7 +108,7 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass()("Session.PromptConflictError", { sessionID: SessionSchema.ID, @@ -466,7 +466,9 @@ const layer = Layer.effect( text: skill.content, }) if (input.resume !== false) - yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) + yield* execution + .resume(input.sessionID) + .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { yield* result.get(input.sessionID) @@ -550,7 +552,9 @@ const layer = Layer.effect( description: input.description, metadata: input.metadata, }) - yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) + yield* execution + .resume(input.sessionID) + .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }), interrupt: Effect.fn("V2Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID)), diff --git a/packages/core/src/session/context-checkpoint.ts b/packages/core/src/session/context-checkpoint.ts new file mode 100644 index 00000000000..c2036e726a2 --- /dev/null +++ b/packages/core/src/session/context-checkpoint.ts @@ -0,0 +1,131 @@ +export * as SessionContextCheckpoint from "./context-checkpoint" + +import { eq } from "drizzle-orm" +import { DateTime, Effect, Option, Schema } from "effect" +import type { Database } from "../database/database" +import { EventV2 } from "../event" +import { SystemContext } from "../system-context/index" +import { SessionEvent } from "./event" +import { SessionHistory } from "./history" +import { SessionMessage } from "./message" +import { SessionSchema } from "./schema" +import { SessionContextCheckpointTable } from "./sql" + +type DatabaseService = Database.Interface["db"] + +const decodeApplied = Schema.decodeUnknownOption(SystemContext.Applied) + +/** + * Loads or creates the session's durable context checkpoint, narrating any + * drift since the model was last told as a chronological update. Completed + * compaction rebaselines; nothing else rewrites the baseline. Runs before + * input promotion so a blocked first turn leaves pending inputs untouched. + */ +export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* ( + db: DatabaseService, + events: EventV2.Interface, + context: Effect.Effect, + sessionID: SessionSchema.ID, +) { + const [value, stored, compaction] = yield* Effect.all( + [context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)], + { concurrency: "unbounded" }, + ) + if (!stored) { + const baseline = yield* SystemContext.initialize(value) + const baselineSeq = yield* insert(db, sessionID, baseline) + return { baseline: baseline.text, baselineSeq } + } + + // The applied record is comparison state only; an undecodable one heals by + // treating every source as new, re-announcing baselines as updates. + const applied = Option.getOrElse(decodeApplied(stored.snapshot), () => ({})) + if (compaction !== undefined && compaction.seq > stored.baseline_seq) { + const baseline = yield* SystemContext.rebaseline(value, applied) + yield* rewrite(db, sessionID, compaction.seq, baseline) + return { baseline: baseline.text, baselineSeq: compaction.seq } + } + const result = yield* SystemContext.reconcile(value, applied) + if (result._tag === "Unchanged") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } + + yield* events.publish( + SessionEvent.ContextUpdated, + { sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text }, + { commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) }, + ) + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } +}) + +export const reset = Effect.fn("SessionContextCheckpoint.reset")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, +) { + yield* db + .delete(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .run() + .pipe(Effect.orDie) +}) + +const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) +}) + +const insert = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + baseline: SystemContext.Baseline, +) { + const baselineSeq = yield* EventV2.latestSequence(db, sessionID) + yield* db + .insert(SessionContextCheckpointTable) + .values({ + session_id: sessionID, + baseline: baseline.text, + snapshot: baseline.applied, + baseline_seq: baselineSeq, + }) + .run() + .pipe(Effect.orDie) + return baselineSeq +}) + +const rewrite = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + baselineSeq: number, + baseline: SystemContext.Baseline, +) { + const updated = yield* db + .update(SessionContextCheckpointTable) + .set({ + baseline: baseline.text, + snapshot: baseline.applied, + baseline_seq: baselineSeq, + }) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .returning({ sessionID: SessionContextCheckpointTable.session_id }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die("Context checkpoint not found") +}) + +const advance = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + applied: SystemContext.Applied, +) { + const updated = yield* db + .update(SessionContextCheckpointTable) + .set({ snapshot: applied }) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .returning({ sessionID: SessionContextCheckpointTable.session_id }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die("Context checkpoint not found") +}) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts deleted file mode 100644 index 65b17a86a4f..00000000000 --- a/packages/core/src/session/context-epoch.ts +++ /dev/null @@ -1,174 +0,0 @@ -export * as SessionContextEpoch from "./context-epoch" - -import { eq } from "drizzle-orm" -import { DateTime, Effect, Schema } from "effect" -import type { Database } from "../database/database" -import { EventV2 } from "../event" -import { SystemContext } from "../system-context/index" -import { ContextSnapshotDecodeError } from "./error" -import { SessionEvent } from "./event" -import { SessionHistory } from "./history" -import { SessionInput } from "./input" -import { SessionMessage } from "./message" -import { SessionSchema } from "./schema" -import { SessionContextEpochTable } from "./sql" - -type DatabaseService = Database.Interface["db"] - -interface Prepared { - readonly baseline: string - readonly baselineSeq: number -} - -export function initialize( - db: DatabaseService, - context: Effect.Effect, - sessionID: SessionSchema.ID, -): Effect.Effect { - return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize")) -} - -export function prepare( - db: DatabaseService, - events: EventV2.Interface, - context: Effect.Effect, - sessionID: SessionSchema.ID, -): Effect.Effect { - return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare")) -} - -const prepareOnce = Effect.fnUntraced(function* ( - db: DatabaseService, - events: EventV2.Interface, - context: Effect.Effect, - sessionID: SessionSchema.ID, -) { - const [value, stored, compaction] = yield* Effect.all( - [context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)], - { concurrency: "unbounded" }, - ) - if (!stored) { - const generation = yield* SystemContext.initialize(value) - const baselineSeq = yield* insert(db, sessionID, generation) - return { baseline: generation.baseline, baselineSeq } - } - - const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe( - Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })), - ) - const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined - const result = replacementSeq - ? yield* SystemContext.replace(value, snapshot) - : yield* SystemContext.reconcile(value, snapshot) - if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") { - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } - } - if (result._tag === "ReplacementReady") { - const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID)) - yield* replace(db, sessionID, baselineSeq, result.generation) - return { baseline: result.generation.baseline, baselineSeq } - } - - yield* events.publish( - SessionEvent.ContextUpdated, - { sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text }, - { commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) }, - ) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } -}) - -const initializeOnce = Effect.fnUntraced(function* ( - db: DatabaseService, - context: Effect.Effect, - sessionID: SessionSchema.ID, -) { - if (yield* exists(db, sessionID)) return - const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize)) - const baselineSeq = yield* insert(db, sessionID, 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 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, - generation: SystemContext.Generation, -) { - const baselineSeq = yield* EventV2.latestSequence(db, sessionID) - yield* db - .insert(SessionContextEpochTable) - .values({ - session_id: sessionID, - baseline: generation.baseline, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - }) - .run() - .pipe(Effect.orDie) - return baselineSeq -}) - -const replace = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - baselineSeq: number, - generation: SystemContext.Generation, -) { - const updated = yield* db - .update(SessionContextEpochTable) - .set({ - baseline: generation.baseline, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - }) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .returning({ sessionID: SessionContextEpochTable.session_id }) - .get() - .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Context Epoch not found") -}) - -const advance = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - snapshot: SystemContext.Snapshot, -) { - const updated = yield* db - .update(SessionContextEpochTable) - .set({ snapshot }) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .returning({ sessionID: SessionContextEpochTable.session_id }) - .get() - .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Context Epoch not found") -}) diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 158e46dd073..68f4fd32a43 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -10,15 +10,3 @@ export class MessageDecodeError extends Schema.TaggedErrorClass()( - "Session.ContextSnapshotDecodeError", - { - sessionID: SessionSchema.ID, - details: Schema.String, - }, -) { - override get message() { - return `Failed to decode context snapshot for session ${this.sessionID}: ${this.details}` - } -} diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index f72ff91ab86..a704a631f77 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -4,7 +4,7 @@ import { Database } from "../database/database" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" -import { SessionContextEpochTable, SessionMessageTable } from "./sql" +import { SessionContextCheckpointTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] @@ -33,6 +33,9 @@ const messageRows = Effect.fnUntraced(function* ( .where( and( eq(SessionMessageTable.session_id, sessionID), + // Keep system updates visible in the gap between a completed compaction + // and the next prepared turn's rebaseline, when their content is not yet + // folded into a new baseline. compaction ? or( gte(SessionMessageTable.seq, compaction.seq), @@ -67,9 +70,9 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ const [epoch, compaction] = yield* Effect.all( [ db - .select({ baselineSeq: SessionContextEpochTable.baseline_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) + .select({ baselineSeq: SessionContextCheckpointTable.baseline_seq }) + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) .get() .pipe(Effect.orDie), latestCompaction(db, sessionID), @@ -79,14 +82,6 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow) }) -export const loadForRunner = Effect.fn("SessionHistory.loadForRunner")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - baselineSeq: number, -) { - return (yield* entriesForRunner(db, sessionID, baselineSeq)).map((entry) => entry.message) -}) - export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index ce6ebcd4ec5..8f4cc4e5df8 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -12,8 +12,15 @@ import { SessionMessage } from "./message" import { SessionMessageUpdater } from "./message-updater" import { SessionInput } from "./input" import { WorkspaceV2 } from "../workspace" -import { SessionContextEpoch } from "./context-epoch" -import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql" +import { SessionContextCheckpoint } from "./context-checkpoint" +import { + MessageTable, + PartTable, + SessionContextCheckpointTable, + SessionInputTable, + SessionMessageTable, + SessionTable, +} from "./sql" import type { DeepMutable } from "../schema" import { Slug } from "../util/slug" @@ -156,12 +163,16 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where( - and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.messageID)), + and( + eq(SessionMessageTable.session_id, event.data.parentID), + eq(SessionMessageTable.id, event.data.messageID), + ), ) .get() .pipe(Effect.orDie) : undefined - if (event.data.messageID && !boundary) return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`) + if (event.data.messageID && !boundary) + return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`) const copied = yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) @@ -206,6 +217,23 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) if (!stored) return yield* Effect.die(new SessionAlreadyProjected()) + // The fork inherits the parent's transcript, so it inherits the context + // checkpoint that transcript was built against: copied message seqs keep + // folding at the same baseline horizon. + const checkpoint = yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, event.data.parentID)) + .get() + .pipe(Effect.orDie) + if (checkpoint) { + yield* db + .insert(SessionContextCheckpointTable) + .values({ ...checkpoint, session_id: event.data.sessionID }) + .run() + .pipe(Effect.orDie) + } + const usage = emptyUsage() let cursor = -1 while (true) { @@ -452,7 +480,7 @@ const layer = Layer.effectDiscard( .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) - yield* SessionContextEpoch.reset(db, event.data.sessionID) + yield* SessionContextCheckpoint.reset(db, event.data.sessionID) }), ) yield* events.project(SessionV1.Event.Deleted, (event) => @@ -666,7 +694,7 @@ const layer = Layer.effectDiscard( .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) - yield* SessionContextEpoch.reset(db, event.data.sessionID) + yield* SessionContextCheckpoint.reset(db, event.data.sessionID) }), ) }), diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 634075dd91b..11141c34473 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -3,7 +3,7 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" import { Context, Effect } from "effect" import { SessionSchema } from "../schema" -import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" +import type { MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" import type { SystemContext } from "../../system-context/index" import type { ToolOutputStore } from "../../tool-output-store" @@ -12,7 +12,6 @@ export type RunError = | LLMError | SessionRunnerModel.Error | MessageDecodeError - | ContextSnapshotDecodeError | SystemContext.InitializationBlocked | ToolOutputStore.Error diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 181ba5a9727..e87a7573b9c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -18,13 +18,14 @@ import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" import { QuestionV2 } from "../../question" import { SystemContext } from "../../system-context/index" -import { SystemContextRegistry } from "../../system-context/registry" +import { SystemContextBuiltIns } from "../../system-context/builtins" +import { InstructionContext } from "../../instruction-context" import { SkillGuidance } from "../../skill/guidance" import { ReferenceGuidance } from "../../reference/guidance" import { McpGuidance } from "../../mcp/guidance" import { ToolRegistry } from "../../tool/registry" import { ToolOutputStore } from "../../tool-output-store" -import { SessionContextEpoch } from "../context-epoch" +import { SessionContextCheckpoint } from "../context-checkpoint" import { SessionCompaction } from "../compaction" import { SessionEvent } from "../event" import { SessionHistory } from "../history" @@ -102,7 +103,8 @@ const layer = Layer.effect( const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service const location = yield* Location.Service - const systemContext = yield* SystemContextRegistry.Service + const builtins = yield* SystemContextBuiltIns.Service + const instructions = yield* InstructionContext.Service const skillGuidance = yield* SkillGuidance.Service const referenceGuidance = yield* ReferenceGuidance.Service const mcpGuidance = yield* McpGuidance.Service @@ -170,9 +172,16 @@ const layer = Layer.effect( new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step }) const loadSystemContext = (agent: AgentV2.Selection) => - Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent)], { - concurrency: "unbounded", - }).pipe(Effect.map(SystemContext.combine)) + Effect.all( + [ + builtins.load(), + instructions.load(), + skillGuidance.load(agent), + referenceGuidance.load(), + mcpGuidance.load(agent), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.map(SystemContext.combine)) const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, @@ -184,7 +193,9 @@ const layer = Layer.effect( if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) return yield* Effect.interrupt const agent = yield* agents.select(session.agent) - const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id) + // Establish what the model knows before admitting what the user said, so + // a blocked first turn leaves pending inputs untouched. + const checkpoint = yield* SessionContextCheckpoint.prepare(db, events, loadSystemContext(agent), session.id) const toolFibers = yield* FiberSet.make() let needsContinuation = false let currentStep = step @@ -198,10 +209,8 @@ const layer = Layer.effect( } if (promoted > 0) currentStep = 1 } - const system = - initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id)) const model = yield* models.resolve(session) - const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) + const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq) const context = entries.map((entry) => entry.message) const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps const toolMaterialization = isLastStep @@ -211,7 +220,10 @@ const layer = Layer.effect( const request = LLM.request({ model, providerOptions: { openai: { promptCacheKey } }, - system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline] + system: [ + agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), + checkpoint.baseline, + ] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], @@ -462,7 +474,8 @@ export const node = makeLocationNode({ SessionRunnerModel.node, SessionStore.node, Location.node, - SystemContextRegistry.node, + SystemContextBuiltIns.node, + InstructionContext.node, SkillGuidance.node, ReferenceGuidance.node, McpGuidance.node, diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 54602046168..4583695a319 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -103,7 +103,9 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { }) } -const providerOptions = (model: ModelV2.Info) => { +const providerOptions = ( + model: ModelV2.Info, +): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => { if (Object.keys(model.request.settings).length === 0) return undefined if (model.api.type !== "aisdk") return undefined if (model.api.package === "@ai-sdk/openai") return { openai: model.request.settings } diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 264a1d2cca0..5ff659be220 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -165,12 +165,12 @@ export const SessionInputTable = sqliteTable( ], ) -export const SessionContextEpochTable = sqliteTable("session_context_epoch", { +export const SessionContextCheckpointTable = sqliteTable("session_context_epoch", { session_id: text() .$type() .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), baseline: text().notNull(), - snapshot: text({ mode: "json" }).notNull().$type(), + snapshot: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), }) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 273444d25c9..c3ffeedc3ca 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -14,10 +14,6 @@ import { fromRow } from "./info" export interface Interface { readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly context: (sessionID: SessionSchema.ID) => Effect.Effect - readonly runnerContext: ( - sessionID: SessionSchema.ID, - baselineSeq: number, - ) => Effect.Effect readonly message: ( messageID: SessionMessage.ID, ) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined> @@ -39,9 +35,6 @@ const layer = Layer.effect( context: Effect.fn("SessionStore.context")(function* (sessionID) { return yield* SessionHistory.load(db, sessionID) }), - runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) { - return yield* SessionHistory.loadForRunner(db, sessionID, baselineSeq) - }), message: Effect.fn("SessionStore.message")(function* (messageID) { const row = yield* db .select() diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index f2ec8157443..a95a1ab0b88 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -13,23 +13,47 @@ const Summary = Schema.Struct({ }) type Summary = typeof Summary.Type +const entries = (skills: ReadonlyArray) => + skills.flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + " ", + ]) + const render = (skills: ReadonlyArray) => [ "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", ...(skills.length === 0 ? ["No skills are currently available."] + : ["", ...entries(skills), ""]), + ].join("\n") + +const update = (previous: ReadonlyArray, current: ReadonlyArray) => { + const diff = SystemContext.diffByKey( + previous, + current, + (skill) => skill.name, + (before, after) => before.description !== after.description, + ) + // Additions and removals render as small deltas; anything else restates the full list. + if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) + return [ + "The available skills have changed. This list supersedes the previous available skills list.", + render(current), + ].join("\n") + return [ + ...(diff.added.length === 0 + ? [] + : ["New skills are available in addition to those previously listed:", ...entries(diff.added)]), + ...(diff.removed.length === 0 + ? [] : [ - "", - ...skills.flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - " ", - ]), - "", + `The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`, ]), ].join("\n") +} export interface Interface { readonly load: (agent: AgentV2.Selection) => Effect.Effect @@ -61,11 +85,7 @@ const layer = Layer.effect( codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(available), baseline: render, - update: (_previous, current) => - [ - "The available skills have changed. This list supersedes the previous available skills list.", - render(current), - ].join("\n"), + update, removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.", }) }), diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index b8b50577ccc..1cf470fb4a3 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -1,18 +1,20 @@ export * as SystemContextBuiltIns from "./builtins" import { makeLocationNode } from "../effect/app-node" -import { DateTime, Effect, Layer, Schema } from "effect" +import { Context, DateTime, Effect, Layer, Schema } from "effect" import { Location } from "../location" import { SystemContext } from "./index" -import { InstructionContext } from "../instruction-context" -import { SystemContextRegistry } from "./registry" -import { FSUtil } from "../fs-util" -import { Global } from "../global" -const builtIns = Layer.effectDiscard( +export interface Interface { + readonly load: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SystemContextBuiltIns") {} + +const layer = Layer.effect( + Service, Effect.gen(function* () { const location = yield* Location.Service - const registry = yield* SystemContextRegistry.Service const environment = [ "", ` Working directory: ${location.directory}`, @@ -39,12 +41,8 @@ const builtIns = Layer.effectDiscard( }), ]) - yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) }) + return Service.of({ load: () => Effect.succeed(context) }) }), ) -export const node = makeLocationNode({ - name: "system-context-builtins", - layer: builtIns, - deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node], -}) +export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) diff --git a/packages/core/src/system-context/index.ts b/packages/core/src/system-context/index.ts index c0a583c08c3..ce6aaae4542 100644 --- a/packages/core/src/system-context/index.ts +++ b/packages/core/src/system-context/index.ts @@ -7,13 +7,18 @@ import { Effect, Option, Schema } from "effect" * * `Source` 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. + * with contexts built from other value types. + * + * The durable `Applied` record tracks what the model was last told, per source: + * it is the model's current belief. Interpreters uphold one invariant — + * `reconcile` never rewrites the baseline; it only narrates drift as update + * text. Only `rebaseline` (compaction) and `initialize` (first turn) produce + * baseline 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. + * removing a source from the context: the model's prior belief stands. + * `reconcile` retains the applied value silently, and `rebaseline` restates the + * belief by rendering the last-applied value instead of a live observation. * * @module */ @@ -45,39 +50,30 @@ export interface SystemContext { readonly [ContextTypeId]: ReadonlyArray } -/** Durable comparison state for one admitted source. */ -export const SourceSnapshot = Schema.Struct({ +/** The value last applied to the model for one admitted source. */ +export const AppliedSource = Schema.Struct({ value: Schema.Json, removed: Schema.optional(Schema.NonEmptyString), }) -export type SourceSnapshot = typeof SourceSnapshot.Type +export type AppliedSource = typeof AppliedSource.Type -/** Durable structured comparison state for one active context generation. */ -export const Snapshot = Schema.Record(Key, SourceSnapshot) -export type Snapshot = Readonly> +/** Durable record of what the model currently believes, per source. */ +export const Applied = Schema.Record(Key, AppliedSource) +export type Applied = Readonly> -export interface Generation { - readonly baseline: string - readonly snapshot: Snapshot +/** A rendered baseline together with the applied values it was rendered from. */ +export interface Baseline { + readonly text: string + readonly applied: Applied } export interface Updated { readonly _tag: "Updated" readonly text: string - readonly snapshot: Snapshot + readonly applied: Applied } -export interface ReplacementReady { - readonly _tag: "ReplacementReady" - readonly generation: Generation -} - -export interface ReplacementBlocked { - readonly _tag: "ReplacementBlocked" -} - -export type ReplacementResult = ReplacementReady | ReplacementBlocked -export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult +export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated export class InitializationBlocked extends Schema.TaggedErrorClass()( "SystemContext.InitializationBlocked", @@ -98,36 +94,24 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass + readonly load: Effect.Effect + /** Restates the model's belief from a last-applied value when the source cannot be observed. */ + readonly recall: (stored: AppliedSource) => string | undefined } -interface Loaded { - readonly baseline: () => Rendered - readonly compare: (previous: Schema.Json) => Compared +interface Observed { + readonly applied: AppliedSource + readonly baseline: () => string + /** `undefined` means unchanged. An undecodable previous value re-renders the baseline (treat-as-new). */ + readonly update: (previous: AppliedSource) => string | undefined } -interface Rendered { - readonly text: string - readonly snapshot: SourceSnapshot -} - -type Compared = - | { readonly _tag: "Incompatible" } - | { readonly _tag: "Unchanged" } - | { readonly _tag: "Updated"; readonly render: () => Rendered } - -interface AvailableEntry extends Loaded { - readonly _tag: "Available" +interface Entry { readonly key: Key + readonly recall: PackedSource["recall"] + readonly observed: Observed | Unavailable } -interface UnavailableEntry { - readonly _tag: "Unavailable" - readonly key: Key -} - -type Entry = AvailableEntry | UnavailableEntry - /** The identity context. */ export const empty = context([]) @@ -136,42 +120,65 @@ export function make(source: Source): SystemContext { const decode = Schema.decodeUnknownOption(source.codec) const encode = Schema.encodeSync(source.codec) const equivalent = Schema.toEquivalence(source.codec) + const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value)) return context([ { key: source.key, + recall: (stored) => + Option.match(decode(stored.value), { + onNone: () => undefined, + onSome: baseline, + }), 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 => + applied: { + value: encode(value), + ...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}), + }, + baseline: () => baseline(value), + update: (previous) => + Option.match(decode(previous.value), { + onNone: () => baseline(value), + onSome: (decoded) => equivalent(decoded, value) - ? { _tag: "Unchanged" } - : { - _tag: "Updated", - render: () => ({ - text: requireText(source.key, "update", source.update(decoded, value)), - snapshot: snapshot(), - }), - }, + ? undefined + : requireText(source.key, "update", source.update(decoded, value)), }), - } + } satisfies Observed }), ), }, ]) } +/** + * Keyed three-way diff for list-shaped sources rendering delta updates. + * `changed` compares two values sharing a key; entries equal under it are dropped. + */ +export function diffByKey( + previous: ReadonlyArray, + current: ReadonlyArray, + key: (value: A) => string, + changed: (previous: A, current: A) => boolean, +): { + readonly added: ReadonlyArray + readonly removed: ReadonlyArray + readonly changed: ReadonlyArray<{ readonly previous: A; readonly current: A }> +} { + const currentKeys = new Set(current.map(key)) + const previousByKey = new Map(previous.map((value) => [key(value), value] as const)) + return { + added: current.filter((value) => !previousByKey.has(key(value))), + removed: previous.filter((value) => !currentKeys.has(key(value))), + changed: current.flatMap((value) => { + const before = previousByKey.get(key(value)) + return before === undefined || !changed(before, value) ? [] : [{ previous: before, current: value }] + }), + } +} + /** Combines contexts in order and rejects duplicate source keys immediately. */ export function combine(values: ReadonlyArray): SystemContext { const sources = values.flatMap((value) => value[ContextTypeId]) @@ -183,111 +190,91 @@ 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 }, - ), - ), + source.load.pipe(Effect.map((observed): Entry => ({ key: source.key, recall: source.recall, observed }))), { concurrency: "unbounded" }, ) -/** Creates the immutable baseline and durable snapshot for a new generation. */ -export function initialize(value: SystemContext): Effect.Effect { +/** Creates the first baseline. Blocks rather than admit a baseline missing an unobservable source. */ +export function initialize(value: SystemContext): Effect.Effect { return observe(value).pipe( Effect.flatMap((entries) => { - const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : [])) - if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable }) - return Effect.succeed(initializeObservation(entries)) + const blocked = entries.flatMap((entry) => (entry.observed === unavailable ? [entry.key] : [])) + if (blocked.length > 0) return new InitializationBlocked({ keys: blocked }) + const parts: string[] = [] + const applied: Record = {} + for (const entry of entries) { + if (entry.observed === unavailable) continue + parts.push(entry.observed.baseline()) + applied[entry.key] = entry.observed.applied + } + return Effect.succeed({ text: render(parts), applied }) }), ) } -function initializeObservation(entries: ReadonlyArray): Generation { - const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available") - const rendered = available.map((entry) => [entry.key, entry.baseline()] as const) - return { - baseline: render(rendered.map(([, result]) => result.text)), - snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])), - } -} - -/** Reconciles current source values with one active generation. */ -export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect { +/** Narrates drift between current source values and the model's beliefs. Never rewrites the baseline. */ +export function reconcile(value: SystemContext, previous: Applied): Effect.Effect { 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) + const updates: string[] = [] + const applied: Record = {} + for (const entry of entries) { + const stored = get(previous, entry.key) + if (entry.observed === unavailable) { + // The prior belief stands while the source cannot be observed. + if (stored) applied[entry.key] = stored + continue + } + if (!stored) { + updates.push(entry.observed.baseline()) + applied[entry.key] = entry.observed.applied + continue + } + const text = entry.observed.update(stored) + if (text === undefined) { + applied[entry.key] = stored + continue + } + updates.push(text) + applied[entry.key] = entry.observed.applied + } + const keys = new Set(entries.map((entry) => entry.key)) + for (const key of Object.keys(previous).sort()) { + if (keys.has(key)) continue + const removed = previous[key].removed + // An unannounced removal retains the belief; it clears at the next rebaseline. + if (removed === undefined) applied[key] = previous[key] + else updates.push(removed) + } + if (updates.length === 0) return { _tag: "Unchanged" } + return { _tag: "Updated", text: render(updates), applied } }), ) } -function reconcileObservation( - entries: ReadonlyArray, - previous: Snapshot, -): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } { - const keys = new Set(entries.map((entry) => entry.key)) - const comparisons = new Map() - 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 = {} - 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 { - return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous))) -} - -function replaceObservation(entries: ReadonlyArray, previous: Snapshot): ReplacementResult { - if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined)) - return { _tag: "ReplacementBlocked" } - return { _tag: "ReplacementReady", generation: initializeObservation(entries) } +/** Rebuilds the baseline, restating unobservable sources from the model's last-applied beliefs. */ +export function rebaseline(value: SystemContext, previous: Applied): Effect.Effect { + return observe(value).pipe( + Effect.map((entries): Baseline => { + const parts: string[] = [] + const applied: Record = {} + for (const entry of entries) { + if (entry.observed !== unavailable) { + parts.push(entry.observed.baseline()) + applied[entry.key] = entry.observed.applied + continue + } + const stored = get(previous, entry.key) + if (!stored) continue + const text = entry.recall(stored) + // An undecodable belief cannot be restated; the source re-announces when observable again. + if (text === undefined) continue + parts.push(text) + applied[entry.key] = stored + } + return { text: render(parts), applied } + }), + ) } function context(sources: ReadonlyArray): SystemContext { @@ -298,8 +285,8 @@ function render(parts: ReadonlyArray) { return parts.join("\n\n") } -function getSnapshot(snapshot: Snapshot, key: Key) { - return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined +function get(applied: Applied, key: Key) { + return Object.hasOwn(applied, key) ? applied[key] : undefined } function isUnavailable(value: unknown): value is Unavailable { diff --git a/packages/core/src/system-context/registry.ts b/packages/core/src/system-context/registry.ts deleted file mode 100644 index c1e7ca5e858..00000000000 --- a/packages/core/src/system-context/registry.ts +++ /dev/null @@ -1,49 +0,0 @@ -export * as SystemContextRegistry from "./registry" - -import { Context, Effect, Layer, Ref, Scope } from "effect" -import { SystemContext } from "./index" -import { makeLocationNode } from "../effect/app-node" - -export interface Entry { - readonly key: SystemContext.Key - readonly load: Effect.Effect -} - -export interface Interface { - readonly register: (entry: Entry) => Effect.Effect - readonly load: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/SystemContextRegistry") {} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const entries = yield* Ref.make>([]) - - return Service.of({ - register: Effect.fn("SystemContextRegistry.register")(function* (entry) { - yield* Effect.acquireRelease( - Ref.modify(entries, (current) => { - if (current.some((item) => item.key === entry.key)) return [false, current] - return [true, [...current, entry]] - }).pipe( - Effect.flatMap((added) => - added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`), - ), - Effect.as(entry), - ), - (entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)), - ) - }), - load: Effect.fn("SystemContextRegistry.load")(function* () { - const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) - return SystemContext.combine( - yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }), - ) - }), - }) - }), -) - -export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index b94b66f50d6..7f1d7ff0533 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -173,6 +173,7 @@ describe("ConfigAgentPlugin.Plugin", () => { model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined }, }) expect(reviewer.request).toEqual({ + settings: {}, headers: { first: "one", shared: "last", second: "two" }, body: { enabled: true, profile: "review", retries: 2, effort: "high" }, }) diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index 0a61ede3f05..8bd04d305ae 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -10,7 +10,6 @@ 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" @@ -22,7 +21,7 @@ const instructionLayer = (input: { locationServiceLayer: Layer.Layer filesystemLayer?: Layer.Layer }) => - AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [ + AppNodeBuilder.build(InstructionContext.node, [ [Global.node, Global.layerWith({ config: input.config })], [Location.node, input.locationServiceLayer], ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), @@ -52,7 +51,7 @@ describe("InstructionContext", () => { await fs.writeFile(packageFile, "package") }) - const load = SystemContextRegistry.Service.pipe( + const load = InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -71,23 +70,23 @@ describe("InstructionContext", () => { ) const initialized = yield* SystemContext.initialize(yield* load) - expect(initialized.baseline).toBe( + expect(initialized.text).toBe( [ `Instructions from: ${globalFile}\nglobal`, `Instructions from: ${packageFile}\npackage`, `Instructions from: ${projectFile}\nproject`, ].join("\n\n"), ) - expect(initialized.baseline).not.toContain("outside") + expect(initialized.text).not.toContain("outside") yield* Effect.promise(() => fs.writeFile(packageFile, "changed")) - expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toMatchObject({ + expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).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) + const partial = yield* SystemContext.reconcile(yield* load, initialized.applied) expect(partial).toEqual({ _tag: "Updated", text: [ @@ -95,14 +94,14 @@ describe("InstructionContext", () => { `Instructions from: ${globalFile}\nglobal`, `Instructions from: ${projectFile}\nproject`, ].join("\n\n"), - snapshot: expect.any(Object), + applied: expect.any(Object), }) yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)])) - expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({ + expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toEqual({ _tag: "Updated", text: "Previously loaded instructions no longer apply.", - snapshot: {}, + applied: {}, }) }), ), @@ -118,7 +117,7 @@ describe("InstructionContext", () => { Effect.gen(function* () { const file = path.join(tmp.path, "AGENTS.md") yield* Effect.promise(() => fs.writeFile(file, "")) - const context = yield* SystemContextRegistry.Service.pipe( + const context = yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -131,7 +130,7 @@ describe("InstructionContext", () => { ), ) - expect((yield* SystemContext.initialize(context)).baseline).toBe(`Instructions from: ${file}\n`) + expect((yield* SystemContext.initialize(context)).text).toBe(`Instructions from: ${file}\n`) }), ), ), @@ -147,7 +146,7 @@ describe("InstructionContext", () => { ), ), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) - const context = yield* SystemContextRegistry.Service.pipe( + const context = yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -187,7 +186,7 @@ describe("InstructionContext", () => { ), ), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) - const context = yield* SystemContextRegistry.Service.pipe( + const context = yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -231,7 +230,7 @@ describe("InstructionContext", () => { ), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) - yield* SystemContextRegistry.Service.pipe( + yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -261,7 +260,7 @@ describe("InstructionContext", () => { let scanned = false process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1" - yield* SystemContextRegistry.Service.pipe( + yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -293,7 +292,7 @@ describe("InstructionContext", () => { it.effect("does not discover project instructions outside the canonical project root", () => Effect.gen(function* () { let scanned = false - yield* SystemContextRegistry.Service.pipe( + yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 5dae04c2cf0..3395b8278d9 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -279,7 +279,11 @@ function agentInfo(value: AgentV2.Info) { return { ...value, model: value.model && { ...value.model }, - request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + request: { + settings: { ...value.request.settings }, + headers: { ...value.request.headers }, + body: { ...value.request.body }, + }, permissions: value.permissions.map((permission) => ({ ...permission })), } } @@ -288,7 +292,11 @@ function providerInfo(value: ProviderV2.MutableInfo) { return { ...value, api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, - request: { settings: { ...value.request.settings }, headers: { ...value.request.headers }, body: { ...value.request.body } }, + request: { + settings: { ...value.request.settings }, + headers: { ...value.request.headers }, + body: { ...value.request.body }, + }, } } diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index 5d24879e492..31065c2e3da 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -93,7 +93,7 @@ describe("AmazonBedrockPlugin", () => { }) catalog.provider.update(bedrock.id, (item) => { item.api = bedrock.api - item.request = bedrock.request + item.request = { settings: {}, headers: {}, body: { endpoint: "https://bedrock.example" } } }) }) yield* addPlugin() diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index a4ad4d7950c..df05a07f759 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -36,7 +36,7 @@ describe("AnthropicPlugin", () => { }) catalog.provider.update(item.id, (draft) => { draft.api = item.api - draft.request = item.request + draft.request = { settings: {}, headers: { Existing: "1" }, body: {} } }) }) yield* addPlugin() diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 1d8172854cf..42af292e158 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -87,7 +87,7 @@ describe("AzurePlugin", () => { }) catalog.provider.update(azure.id, (item) => { item.api = azure.api - item.request = azure.request + item.request = { settings: {}, headers: {}, body: { resourceName: "from-config" } } }) catalog.provider.update(ProviderV2.ID.openai, () => {}) }) @@ -110,7 +110,7 @@ describe("AzurePlugin", () => { }) catalog.provider.update(azure.id, (item) => { item.api = azure.api - item.request = azure.request + item.request = { settings: {}, headers: {}, body: { resourceName: "" } } }) }) yield* addPlugin() @@ -131,7 +131,7 @@ describe("AzurePlugin", () => { }) catalog.provider.update(azure.id, (item) => { item.api = azure.api - item.request = azure.request + item.request = { settings: {}, headers: {}, body: { resourceName: " " } } }) }) yield* addPlugin() diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 1df0fd31568..b34ceb2d5b3 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -32,7 +32,7 @@ describe("KiloPlugin", () => { package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway", } - provider.request = { headers: { Existing: "value" }, body: {} } + provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index d7f9d0d73d4..5dce7cdcf70 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -39,7 +39,7 @@ describe("LLMGatewayPlugin", () => { package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1", } - provider.request = { headers: { Existing: "value" }, body: {} } + provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index a1c05df3359..260ffff689e 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -32,7 +32,7 @@ describe("NvidiaPlugin", () => { package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1", } - provider.request = { headers: { Existing: "value" }, body: {} } + provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) @@ -80,6 +80,7 @@ describe("NvidiaPlugin", () => { url: "https://integrate.api.nvidia.com/v1", } provider.request = { + settings: {}, headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, body: { baseURL: "https://integrate.api.nvidia.com/v1" }, } diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 7f97d686d73..d2b1f2bdaea 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -373,7 +373,7 @@ describe("OpencodePlugin", () => { cost: cost(1), }) catalog.provider.update(provider.id, (draft) => { - draft.request = provider.request + draft.request = { settings: {}, headers: {}, body: { apiKey: "configured" } } }) catalog.model.update(provider.id, model.id, (draft) => { draft.cost = [...model.cost] diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 953943673f2..d611f40363a 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -31,7 +31,7 @@ describe("OpenRouterPlugin", () => { yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } - provider.request = { headers: { Existing: "value" }, body: {} } + provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) }) diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 2a317af2cac..3db3212c3c0 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-guidance.test.ts @@ -16,10 +16,10 @@ describe("ReferenceGuidance", () => { const guidance = yield* ReferenceGuidance.Service const generation = yield* SystemContext.initialize(yield* guidance.load()) - expect(generation.baseline).toContain("") - expect(generation.baseline).toContain("docs") - expect(generation.baseline).toContain("/docs") - expect(generation.baseline).toContain("Use for product documentation") + expect(generation.text).toContain("") + expect(generation.text).toContain("docs") + expect(generation.text).toContain("/docs") + expect(generation.text).toContain("Use for product documentation") }).pipe( Effect.provide( guidanceLayer( @@ -47,7 +47,7 @@ describe("ReferenceGuidance", () => { Effect.gen(function* () { const guidance = yield* ReferenceGuidance.Service const generation = yield* SystemContext.initialize(yield* guidance.load()) - expect(generation.baseline).toBe("") + expect(generation.text).toBe("") }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))), ) @@ -55,7 +55,7 @@ describe("ReferenceGuidance", () => { Effect.gen(function* () { const guidance = yield* ReferenceGuidance.Service const generation = yield* SystemContext.initialize(yield* guidance.load()) - expect(generation.baseline).toBe("") + expect(generation.text).toBe("") }).pipe( Effect.provide( guidanceLayer( @@ -73,4 +73,41 @@ describe("ReferenceGuidance", () => { ), ), ) + + it.effect("announces added and removed references as deltas", () => { + const reference = (name: string, description: string) => + new Reference.Info({ + name, + path: AbsolutePath.make(`/${name}`), + description, + source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make(`/${name}`), description }), + }) + let references = [reference("docs", "Use for product documentation")] + return Effect.gen(function* () { + const guidance = yield* ReferenceGuidance.Service + const initialized = yield* SystemContext.initialize(yield* guidance.load()) + + references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")] + const added = yield* SystemContext.reconcile(yield* guidance.load(), initialized.applied) + expect(added).toMatchObject({ + _tag: "Updated", + text: [ + "New project references are available in addition to those previously listed:", + " ", + " examples", + " /examples", + " Use for examples", + " ", + ].join("\n"), + }) + + references = [reference("examples", "Use for examples")] + expect( + yield* SystemContext.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}), + ).toMatchObject({ + _tag: "Updated", + text: "The following project references are no longer available and must not be used: docs.", + }) + }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) })))) + }) }) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index c78abd17d56..7b6f2f219ea 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -19,7 +19,12 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" -import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { + SessionContextCheckpointTable, + SessionInputTable, + SessionMessageTable, + SessionTable, +} from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" import { Snapshot } from "@opencode-ai/core/snapshot" @@ -67,6 +72,10 @@ describe("SessionProjector", () => { .insert(SessionMessageTable) .values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)]) .run() + yield* db + .insert(SessionContextCheckpointTable) + .values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 }) + .run() const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, @@ -93,6 +102,8 @@ describe("SessionProjector", () => { expect( (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), ).toEqual([boundary]) + // A committed revert resets the context checkpoint so the next turn re-initializes. + expect(yield* db.select().from(SessionContextCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined() }), ) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 7366105a7c2..57e0ba42f65 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -31,7 +31,8 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { Location } from "@opencode-ai/core/location" -import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins" +import { InstructionContext } from "@opencode-ai/core/instruction-context" import { SystemContext } from "@opencode-ai/core/system-context" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" @@ -72,7 +73,8 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) -const systemContext = AppNodeBuilder.build(SystemContextRegistry.node) +const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) }) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) @@ -81,7 +83,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], - [SystemContextRegistry.node, systemContext], + [SystemContextBuiltIns.node, systemContext], + [InstructionContext.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], @@ -116,7 +119,8 @@ const it = testEffect( AgentV2.node, ToolRegistry.node, SessionRunnerModel.node, - SystemContextRegistry.node, + SystemContextBuiltIns.node, + InstructionContext.node, SkillGuidance.node, ReferenceGuidance.node, Config.node, @@ -129,7 +133,8 @@ const it = testEffect( [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], [SessionRunnerModel.node, models], - [SystemContextRegistry.node, systemContext], + [SystemContextBuiltIns.node, systemContext], + [InstructionContext.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index b2631226e02..0b164701306 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -25,7 +25,6 @@ import { QuestionV2 } from "@opencode-ai/core/question" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" -import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionTitle } from "@opencode-ai/core/session/title" @@ -46,14 +45,15 @@ import { Config } from "@opencode-ai/core/config" import { ConfigCompaction } from "@opencode-ai/core/config/compaction" import { Tool } from "@opencode-ai/core/tool/tool" import { - SessionContextEpochTable, + SessionContextCheckpointTable, SessionInputTable, SessionMessageTable, SessionTable, } from "@opencode-ai/core/session/sql" 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 { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins" +import { InstructionContext } from "@opencode-ai/core/instruction-context" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { McpGuidance } from "@opencode-ai/core/mcp/guidance" @@ -169,35 +169,28 @@ let systemRemoved = false let systemUnavailable = false let systemLoadHook = Effect.void const skillBaselines = new Map() -const systemContext = Layer.effectDiscard( - SystemContextRegistry.Service.pipe( - Effect.flatMap((registry) => - registry.register({ - 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", - }), - ], - ), - ), - }), +const systemContext = Layer.mock(SystemContextBuiltIns.Service, { + 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(AppNodeBuilder.build(SystemContextRegistry.node))) +}) +const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) }) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: (agent) => Effect.succeed( @@ -236,7 +229,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], - [SystemContextRegistry.node, systemContext], + [SystemContextBuiltIns.node, systemContext], + [InstructionContext.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], @@ -274,7 +268,8 @@ const it = testEffect( ToolRegistry.toolsNode, echoNode, SessionRunnerModel.node, - SystemContextRegistry.node, + SystemContextBuiltIns.node, + InstructionContext.node, SkillGuidance.node, ReferenceGuidance.node, Config.node, @@ -287,7 +282,8 @@ const it = testEffect( [LayerNodePlatform.llmClient, client], [PermissionV2.node, permission], [SessionRunnerModel.node, models], - [SystemContextRegistry.node, systemContext], + [SystemContextBuiltIns.node, systemContext], + [InstructionContext.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], @@ -699,8 +695,8 @@ describe("SessionRunnerLLM", () => { expect( yield* db .select() - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) .get(), ).toBeUndefined() @@ -731,8 +727,8 @@ describe("SessionRunnerLLM", () => { expect( yield* db .select() - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) .get(), ).toBeUndefined() @@ -745,7 +741,36 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("fails gracefully when a stored context snapshot cannot be decoded", () => + it.effect("copies the context checkpoint to a fork", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const { db } = yield* Database.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + response = [] + yield* session.resume(sessionID) + + const forked = yield* session.fork({ sessionID }) + + const parent = yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + expect(parent).toBeDefined() + expect( + yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, forked.id)) + .get() + .pipe(Effect.orDie), + ).toEqual({ ...parent!, session_id: forked.id }) + }), + ) + + it.effect("heals an undecodable stored applied record by re-announcing context", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -754,19 +779,28 @@ describe("SessionRunnerLLM", () => { response = [] yield* session.resume(sessionID) yield* db - .update(SessionContextEpochTable) + .update(SessionContextCheckpointTable) .set({ snapshot: { invalid: { value: "bad" } } }) - .where(eq(SessionContextEpochTable.session_id, sessionID)) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) .run() .pipe(Effect.orDie) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 - const exit = yield* session.resume(sessionID).pipe(Effect.exit) + yield* session.resume(sessionID) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(ContextSnapshotDecodeError) - expect(requests).toHaveLength(0) + // Comparison state was lost, so every source re-announces as new. + expect(requests).toHaveLength(1) + expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"]) + expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(requests[0]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Initial context" }]) + const healed = yield* db + .select({ snapshot: SessionContextCheckpointTable.snapshot }) + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + expect(healed?.snapshot).toEqual({ "test/context": { value: "Initial context", removed: expect.any(String) } }) }), ) @@ -787,8 +821,8 @@ describe("SessionRunnerLLM", () => { [defaultSystem, "Initial context"], [defaultSystem, "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(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + 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( @@ -1049,8 +1083,8 @@ describe("SessionRunnerLLM", () => { yield* session.prompt({ sessionID, prompt: Prompt.make({ 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([ + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(requests[1]?.messages.at(1)?.content).toEqual([ { type: "text", text: "System context source removed: test/context" }, ]) expect(yield* session.messages({ sessionID })).toHaveLength(3) @@ -1085,15 +1119,15 @@ describe("SessionRunnerLLM", () => { [defaultSystem, "Initial context"], [defaultSystem, "Initial context"], ]) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2) expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([ - "user", "user", "system", + "user", "model-switched", - "user", "system", + "user", ]) yield* replaySessionProjection(sessionID) expect(yield* session.messages({ sessionID })).toHaveLength(6) @@ -1361,7 +1395,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("preserves effective System updates while compaction rebaseline is blocked", () => + it.effect("rebaselines after compaction from the last-applied belief while unobservable", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1393,8 +1427,9 @@ describe("SessionRunnerLLM", () => { yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) - expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"]) - expect(systemTexts(requests.at(-1)!)).toContain("Changed context") + // The rebaseline proceeds while the source is unobservable, restating the model's belief. + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"]) + expect(systemTexts(requests.at(-1)!)).not.toContain("Changed context") }), ) diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index 50e877917b4..b26b75fe711 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -53,7 +53,7 @@ describe("SkillGuidance", () => { .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap(SystemContext.initialize)) - expect(initialized.baseline).toBe( + expect(initialized.text).toBe( [ "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", @@ -65,16 +65,82 @@ describe("SkillGuidance", () => { "", ].join("\n"), ) - expect(initialized.baseline).not.toContain("manual") + expect(initialized.text).not.toContain("manual") skills = [] expect( yield* guidance .load({ id: agent.id, info: agent }) - .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.snapshot))), + .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))), ).toMatchObject({ _tag: "Updated", - text: expect.stringContaining("No skills are currently available."), + text: "The following skills are no longer available and must not be used: effect.", + }) + }).pipe(Effect.provide(layer(() => skills))) + }) + + it.effect("announces added and removed skills as deltas without restating the list", () => { + const agent = AgentV2.Info.make(AgentV2.Info.empty(build)) + const debugging = SkillV2.Info.make({ + name: "debugging", + description: "Diagnose hard bugs", + location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")), + content: "Debugging guidance", + }) + let skills = [effect] + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + const initialized = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap(SystemContext.initialize)) + + skills = [effect, debugging] + const added = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))) + expect(added).toMatchObject({ + _tag: "Updated", + text: [ + "New skills are available in addition to those previously listed:", + " ", + " debugging", + " Diagnose hard bugs", + " ", + ].join("\n"), + }) + + skills = [debugging] + const removed = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe( + Effect.flatMap((context) => SystemContext.reconcile(context, added._tag === "Updated" ? added.applied : {})), + ) + expect(removed).toMatchObject({ + _tag: "Updated", + text: "The following skills are no longer available and must not be used: effect.", + }) + }).pipe(Effect.provide(layer(() => skills))) + }) + + it.effect("restates the full skill list when a description changes", () => { + const agent = AgentV2.Info.make(AgentV2.Info.empty(build)) + let skills = [effect] + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + const initialized = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap(SystemContext.initialize)) + + skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })] + expect( + yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))), + ).toMatchObject({ + _tag: "Updated", + text: expect.stringContaining( + "The available skills have changed. This list supersedes the previous available skills list.", + ), }) }).pipe(Effect.provide(layer(() => skills))) }) @@ -89,8 +155,8 @@ describe("SkillGuidance", () => { expect( yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)), ).toEqual({ - baseline: "", - snapshot: {}, + text: "", + applied: {}, }) }).pipe(Effect.provide(layer(() => [effect]))) }) @@ -108,8 +174,8 @@ describe("SkillGuidance", () => { expect( yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)), ).toEqual({ - baseline: "", - snapshot: {}, + text: "", + applied: {}, }) }).pipe(Effect.provide(layer(() => [effect]))) }) @@ -125,7 +191,7 @@ describe("SkillGuidance", () => { return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service expect( - (yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).baseline, + (yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).text, ).toContain("effect") }).pipe(Effect.provide(layer(() => [effect]))) }) @@ -144,8 +210,8 @@ describe("SkillGuidance", () => { expect( yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)), ).toEqual({ - baseline: "", - snapshot: {}, + text: "", + applied: {}, }) }).pipe(Effect.provide(layer(() => [effect]))) }) diff --git a/packages/core/test/system-context/builtins.test.ts b/packages/core/test/system-context/builtins.test.ts index 26b91752236..2d4ac356b9b 100644 --- a/packages/core/test/system-context/builtins.test.ts +++ b/packages/core/test/system-context/builtins.test.ts @@ -9,7 +9,7 @@ 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 { InstructionContext } from "@opencode-ai/core/instruction-context" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" @@ -27,7 +27,7 @@ const locationLayer = Layer.succeed( ), ), ) -const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node]) +const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, InstructionContext.node]) const it = testEffect( AppNodeBuilder.build(builtInsNode, [ [Location.node, locationLayer], @@ -58,10 +58,10 @@ 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 context = yield* SystemContextBuiltIns.Service const initialized = yield* SystemContext.initialize(yield* context.load()) - expect(initialized.baseline).toBe( + expect(initialized.text).toBe( [ "Here is some useful information about the environment you are running in:", "", @@ -80,11 +80,11 @@ describe("SystemContextBuiltIns", () => { it.effect("reconciles the date without repeating unchanged environment context", () => Effect.gen(function* () { yield* TestClock.setTime(timestamp) - const context = yield* SystemContextRegistry.Service + const context = yield* SystemContextBuiltIns.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) + const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.applied) expect(refreshed).toMatchObject({ _tag: "Updated", @@ -96,20 +96,24 @@ describe("SystemContextBuiltIns", () => { 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 context = yield* SystemContextBuiltIns.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" }) + expect(yield* SystemContext.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" }) }), ) itWithInstructions.effect("composes ambient instructions after built-in context", () => Effect.gen(function* () { yield* TestClock.setTime(timestamp) - const context = yield* SystemContextRegistry.Service + const builtIns = yield* SystemContextBuiltIns.Service + const instructions = yield* InstructionContext.Service + const context = { + load: () => Effect.all([builtIns.load(), instructions.load()]).pipe(Effect.map(SystemContext.combine)), + } - expect((yield* SystemContext.initialize(yield* context.load())).baseline).toBe( + expect((yield* SystemContext.initialize(yield* context.load())).text).toBe( [ "Here is some useful information about the environment you are running in:", "", diff --git a/packages/core/test/system-context/index.test.ts b/packages/core/test/system-context/index.test.ts index 704843ba235..f62053b0884 100644 --- a/packages/core/test/system-context/index.test.ts +++ b/packages/core/test/system-context/index.test.ts @@ -32,11 +32,11 @@ describe("SystemContext", () => { removed: () => "Date removed", }) - expect((yield* SystemContext.initialize(context)).snapshot["core/date"].value).toBe("2026-06-03T12:00:00.000Z") + expect((yield* SystemContext.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z") }), ) - it.effect("loads once and initializes a baseline with a structured snapshot", () => + it.effect("loads once and initializes a baseline with the applied values", () => Effect.gen(function* () { let loads = 0 const context = SystemContext.combine([ @@ -55,8 +55,8 @@ describe("SystemContext", () => { ]) expect(yield* SystemContext.initialize(context)).toEqual({ - baseline: "Today's date is 2026-06-03.\n\nDirectory: /repo", - snapshot: { + text: "Today's date is 2026-06-03.\n\nDirectory: /repo", + applied: { "core/date": { value: "2026-06-03", removed: "The date was removed." }, "core/location": { value: "/repo" }, }, @@ -84,7 +84,7 @@ describe("SystemContext", () => { expect(yield* SystemContext.reconcile(changed, previous)).toEqual({ _tag: "Updated", text: "The date changed from 2026-06-03 to 2026-06-04.", - snapshot: { + applied: { "core/date": { value: "2026-06-04", removed: "The date was removed." }, "core/location": { value: "/repo", removed: "Removed: /repo" }, }, @@ -113,19 +113,17 @@ describe("SystemContext", () => { expect(yield* SystemContext.reconcile(context, {})).toEqual({ _tag: "Updated", text: "Available skill: effect", - snapshot: { "core/skills": { value: "effect" } }, + applied: { "core/skills": { value: "effect" } }, }) }), ) - it.effect("retains admitted snapshots while a source is temporarily unavailable", () => + it.effect("retains the belief 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" }) }), ) @@ -152,17 +150,29 @@ describe("SystemContext", () => { ).toEqual({ _tag: "Updated", text: "Instructions removed; stop applying them.", - snapshot: {}, + applied: {}, }) }), ) - it.effect("requests replacement when a source without removal text disappears", () => + it.effect("retains an unannounced removal silently", () => Effect.gen(function* () { + expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toEqual({ + _tag: "Unchanged", + }) + + // The retained belief survives alongside other updates. expect( - yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } }), - ).toMatchObject({ - _tag: "ReplacementReady", + yield* SystemContext.reconcile(stringContext({ key: "core/skills", value: "effect" }), { + "core/date": { value: "2026-06-04" }, + }), + ).toEqual({ + _tag: "Updated", + text: "effect", + applied: { + "core/skills": { value: "effect" }, + "core/date": { value: "2026-06-04" }, + }, }) }), ) @@ -189,17 +199,48 @@ describe("SystemContext", () => { }), ) - it.effect("requests replacement when a stored value no longer decodes", () => + it.effect("re-announces the baseline 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" }) + ).toEqual({ + _tag: "Updated", + text: "2026-06-04", + applied: { "core/date": { value: "2026-06-04" } }, + }) }), ) - it.effect("replaces from one coherent source observation", () => + it.effect("renders undecodable re-announcements alongside other updates", () => + Effect.gen(function* () { + const context = SystemContext.combine([ + stringContext({ + key: "core/date", + value: "2026-06-04", + update: (before, current) => `${before} -> ${current}`, + }), + stringContext({ key: "core/location", value: "/repo" }), + ]) + + expect( + yield* SystemContext.reconcile(context, { + "core/date": { value: "2026-06-03" }, + "core/location": { value: 42 }, + }), + ).toEqual({ + _tag: "Updated", + text: "2026-06-03 -> 2026-06-04\n\n/repo", + applied: { + "core/date": { value: "2026-06-04" }, + "core/location": { value: "/repo" }, + }, + }) + }), + ) + + it.effect("rebaselines from one coherent source observation", () => Effect.gen(function* () { let loads = 0 const context = SystemContext.make({ @@ -213,52 +254,83 @@ describe("SystemContext", () => { update: (_previous, current) => current, }) - expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({ - _tag: "ReplacementReady", - generation: { baseline: "2026-06-04" }, + expect(yield* SystemContext.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({ + text: "2026-06-04", + applied: { "core/date": { value: "2026-06-04" } }, }) expect(loads).toBe(1) }), ) - it.effect("does not render discarded updates while replacing", () => + it.effect("rebaselines an unavailable source from the last-applied belief", () => Effect.gen(function* () { - let updates = 0 const context = SystemContext.combine([ + stringContext({ key: "core/date", value: "2026-06-04" }), stringContext({ - key: "core/date", - value: "2026-06-04", - update: () => { - updates++ - return "updated" - }, + key: "core/remote", + value: SystemContext.unavailable, + baseline: (value) => `Instructions: ${value}`, }), - stringContext({ key: "core/location", value: "/repo" }), ]) expect( - yield* SystemContext.reconcile(context, { - "core/date": { value: "2026-06-03" }, - "core/location": { value: 42 }, + yield* SystemContext.rebaseline(context, { + "core/remote": { value: "contents", removed: "Instructions removed" }, }), - ).toMatchObject({ _tag: "ReplacementReady" }) - expect(updates).toBe(0) + ).toEqual({ + text: "2026-06-04\n\nInstructions: contents", + applied: { + "core/date": { value: "2026-06-04" }, + "core/remote": { value: "contents", removed: "Instructions removed" }, + }, + }) }), ) - it.effect("blocks an incompatible replacement while another admitted source is unavailable", () => + it.effect("drops undecodable beliefs and removed sources at rebaseline", () => Effect.gen(function* () { - const previous = { - "core/date": { value: 42, removed: "Date removed" }, - "core/remote": { value: "instructions", removed: "Instructions removed" }, - } - const context = SystemContext.combine([ - stringContext({ key: "core/date", value: "2026-06-04" }), - stringContext({ key: "core/remote", value: SystemContext.unavailable }), - ]) + const context = stringContext({ key: "core/remote", value: SystemContext.unavailable }) - expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) - expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) + // Undecodable belief cannot be restated; removed source entries self-clean. + expect( + yield* SystemContext.rebaseline(context, { + "core/remote": { value: 42 }, + "core/gone": { value: "gone" }, + }), + ).toEqual({ text: "", applied: {} }) + }), + ) + + it.effect("diffs list values by key with a changed comparator", () => + Effect.sync(() => { + const previous = [ + { name: "effect", description: "Build with Effect" }, + { name: "debugging", description: "Diagnose bugs" }, + { name: "retired", description: "Old" }, + ] + const current = [ + { name: "effect", description: "Build with Effect v4" }, + { name: "debugging", description: "Diagnose bugs" }, + { name: "writing", description: "Write prose" }, + ] + + expect( + SystemContext.diffByKey( + previous, + current, + (value) => value.name, + (before, after) => before.description !== after.description, + ), + ).toEqual({ + added: [{ name: "writing", description: "Write prose" }], + removed: [{ name: "retired", description: "Old" }], + changed: [ + { + previous: { name: "effect", description: "Build with Effect" }, + current: { name: "effect", description: "Build with Effect v4" }, + }, + ], + }) }), ) @@ -281,7 +353,7 @@ describe("SystemContext", () => { stringContext({ key: "core/date", value: "date" }), stringContext({ key: "core/location", value: "location" }), ]), - )).baseline, + )).text, ).toBe("date\n\nlocation") }), ) @@ -295,13 +367,13 @@ describe("SystemContext", () => { }), ) - it.effect("requires namespaced durable snapshot keys", () => + it.effect("requires namespaced applied keys", () => Effect.sync(() => { - const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot) + const decodeApplied = Schema.decodeUnknownSync(SystemContext.Applied) - 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() + expect(Object.keys(decodeApplied({ "core/date": { value: "date" } }))).toEqual(["core/date"]) + expect(() => decodeApplied({ date: { value: "date" } })).toThrow() + expect(() => decodeApplied({ "core/date": { value: "date", removed: "" } })).toThrow() }), ) }) diff --git a/packages/core/test/system-context/registry.test.ts b/packages/core/test/system-context/registry.test.ts deleted file mode 100644 index 3e684930786..00000000000 --- a/packages/core/test/system-context/registry.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect } from "bun:test" -import { Cause, Effect, Exit, Schema, Scope } from "effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { SystemContext } from "@opencode-ai/core/system-context" -import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" -import { testEffect } from "../lib/effect" - -const entry = (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(AppNodeBuilder.build(SystemContextRegistry.node)) - -describe("SystemContextRegistry", () => { - it.effect("loads empty system context when there are no entries", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - - expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} }) - }), - ) - - it.effect("loads scoped entries in stable key order", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - yield* registry.register(entry("test/second", "second")) - yield* registry.register(entry("test/first", "first")) - - expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond") - }), - ) - - it.effect("re-evaluates entry producers on each load", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - let loads = 0 - yield* registry.register({ - 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 entry producer failures", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - const failure = new Error("entry failed") - yield* registry.register({ 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 entries", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - yield* registry.register(entry("test/first", "first", "test/duplicate")) - yield* registry.register(entry("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 entry keys", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - yield* registry.register(entry("test/duplicate", "first")) - - const exit = yield* registry.register(entry("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 entry key") - }), - ) - - it.effect("removes an entry when its owning scope closes", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - const scope = yield* Scope.make() - yield* registry.register(entry("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: {} }) - }), - ) -}) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index f20ae25c2ca..f6268272922 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -508,11 +508,13 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re const thinking = anthropicOptions(request)?.thinking if (!ProviderShared.isRecord(thinking)) return undefined if (thinking.type === "adaptive") { - const display = thinking.display - return { - type: "adaptive" as const, - ...(display === "summarized" || display === "omitted" ? { display } : {}), - } + const display = + thinking.display === "summarized" + ? ("summarized" as const) + : thinking.display === "omitted" + ? ("omitted" as const) + : undefined + return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) } } if (thinking.type === "disabled") return { type: "disabled" as const } if (thinking.type !== "enabled") return undefined