diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 227214569b..404b3eb2f3 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -48,26 +48,21 @@ const prepareOnce = Effect.fnUntraced(function* ( ) if (!stored) return yield* insertInitial(db, sessionID, value) - const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe( + const applied = yield* Schema.decodeUnknownEffect(SystemContext.Applied)(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 } + if (compaction !== undefined && compaction.seq > stored.baseline_seq) { + const generation = yield* SystemContext.rebaseline(value, applied) + yield* replace(db, sessionID, compaction.seq, generation) + return { baseline: generation.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.snapshot).pipe(Effect.orDie) }, + { commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) }, ) return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } }) @@ -88,7 +83,7 @@ const insertInitial = Effect.fnUntraced(function* ( ) { const generation = yield* SystemContext.initialize(value) const baselineSeq = yield* insert(db, sessionID, generation) - return { baseline: generation.baseline, baselineSeq } + return { baseline: generation.text, baselineSeq } }) const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { @@ -125,15 +120,15 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( const insert = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - generation: SystemContext.Generation, + generation: SystemContext.Baseline, ) { const baselineSeq = yield* EventV2.latestSequence(db, sessionID) yield* db .insert(SessionContextEpochTable) .values({ session_id: sessionID, - baseline: generation.baseline, - snapshot: generation.snapshot, + baseline: generation.text, + snapshot: generation.applied, baseline_seq: baselineSeq, }) .run() @@ -145,13 +140,13 @@ const replace = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, baselineSeq: number, - generation: SystemContext.Generation, + generation: SystemContext.Baseline, ) { const updated = yield* db .update(SessionContextEpochTable) .set({ - baseline: generation.baseline, - snapshot: generation.snapshot, + baseline: generation.text, + snapshot: generation.applied, baseline_seq: baselineSeq, }) .where(eq(SessionContextEpochTable.session_id, sessionID)) @@ -164,11 +159,11 @@ const replace = Effect.fnUntraced(function* ( const advance = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - snapshot: SystemContext.Snapshot, + applied: SystemContext.Applied, ) { const updated = yield* db .update(SessionContextEpochTable) - .set({ snapshot }) + .set({ snapshot: applied }) .where(eq(SessionContextEpochTable.session_id, sessionID)) .returning({ sessionID: SessionContextEpochTable.session_id }) .get() diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 264a1d2cca..63413bf40a 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -171,6 +171,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", { .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/system-context/index.ts b/packages/core/src/system-context/index.ts index c0a583c08c..e9b04cb480 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,36 +120,33 @@ 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 }), ), }, @@ -183,111 +164,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 +259,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/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index 0a61ede3f0..1c3c764ee9 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -71,23 +71,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 +95,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: {}, }) }), ), @@ -131,7 +131,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`) }), ), ), diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 2a317af2ca..4ff4a2ace4 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( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index bce33797f6..c8c9340aff 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1371,7 +1371,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 @@ -1403,8 +1403,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 50e877917b..e7ef9c83ca 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,13 +65,13 @@ 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."), @@ -89,8 +89,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 +108,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 +125,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 +144,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 26b9175223..8545c90449 100644 --- a/packages/core/test/system-context/builtins.test.ts +++ b/packages/core/test/system-context/builtins.test.ts @@ -61,7 +61,7 @@ describe("SystemContextBuiltIns", () => { const context = yield* SystemContextRegistry.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:", "", @@ -84,7 +84,7 @@ describe("SystemContextBuiltIns", () => { 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", @@ -100,7 +100,7 @@ describe("SystemContextBuiltIns", () => { 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" }) }), ) @@ -109,7 +109,7 @@ describe("SystemContextBuiltIns", () => { yield* TestClock.setTime(timestamp) const context = yield* SystemContextRegistry.Service - 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 704843ba23..492829c34d 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,50 @@ 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: {} }) }), ) @@ -281,7 +320,7 @@ describe("SystemContext", () => { stringContext({ key: "core/date", value: "date" }), stringContext({ key: "core/location", value: "location" }), ]), - )).baseline, + )).text, ).toBe("date\n\nlocation") }), ) @@ -295,13 +334,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 index 3e68493078..3fd3bf5aac 100644 --- a/packages/core/test/system-context/registry.test.ts +++ b/packages/core/test/system-context/registry.test.ts @@ -25,7 +25,7 @@ describe("SystemContextRegistry", () => { Effect.gen(function* () { const registry = yield* SystemContextRegistry.Service - expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} }) + expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ text: "", applied: {} }) }), ) @@ -35,7 +35,7 @@ describe("SystemContextRegistry", () => { 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") + expect((yield* SystemContext.initialize(yield* registry.load())).text).toBe("first\n\nsecond") }), ) @@ -105,10 +105,10 @@ describe("SystemContextRegistry", () => { 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") + expect((yield* SystemContext.initialize(yield* registry.load())).text).toBe("scoped") yield* Scope.close(scope, Exit.void) - expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} }) + expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ text: "", applied: {} }) }), ) })