diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index 94d9e787ce..745ec3420c 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -34,6 +34,7 @@ const layer = Layer.effect( const source = (value: ReadonlyArray | SystemContext.Unavailable) => SystemContext.make({ key, + description: "Ambient instructions", codec: Schema.toCodecJson(Files), load: Effect.succeed(value), baseline: render, diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index eed26170b0..b647bda4bf 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -31,22 +31,48 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray server.server, (before, after) => before.instructions !== after.instructions, ) + const items = [ + ...diff.added.map((server) => ({ + key: server.server, + description: "MCP server instructions", + action: "added" as const, + })), + ...diff.removed.map((server) => ({ + key: server.server, + description: "MCP server instructions", + action: "removed" as const, + })), + ...diff.changed.map((server) => ({ + key: server.current.server, + description: "MCP server instructions", + action: "updated" as const, + })), + ] // 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") + return { + text: [ + "The available MCP server instructions have changed. This list supersedes the previous one.", + render(current), + ].join("\n"), + items, + } + return { + text: [ + ...(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"), + items, + } } export interface Interface { @@ -83,6 +109,7 @@ export const layer = Layer.effect( if (visible.length === 0) return SystemContext.empty return SystemContext.make({ key: SystemContext.Key.make("core/mcp-guidance"), + description: "MCP server instructions", codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(visible), baseline: render, diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index 7fc4fc486c..84458cf0da 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -35,22 +35,45 @@ const update = (previous: ReadonlyArray, current: ReadonlyA (reference) => reference.name, (before, after) => before.path !== after.path || before.description !== after.description, ) + const items = [ + ...diff.added.map((reference) => ({ + key: reference.name, + description: reference.description ?? reference.path, + action: "added" as const, + })), + ...diff.removed.map((reference) => ({ + key: reference.name, + description: reference.description ?? reference.path, + action: "removed" as const, + })), + ...diff.changed.map((reference) => ({ + key: reference.current.name, + description: reference.current.description ?? reference.current.path, + action: "updated" as const, + })), + ] // 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") + return { + text: [ + "The available project references have changed. This list supersedes the previous reference list.", + render(current), + ].join("\n"), + items, + } + return { + text: [ + ...(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"), + items, + } } export interface Interface { @@ -77,6 +100,7 @@ const layer = Layer.effect( if (available.length === 0) return SystemContext.empty return SystemContext.make({ key: SystemContext.Key.make("core/reference-guidance"), + description: "Project references", codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(available), baseline: render, diff --git a/packages/core/src/session/context-entry.ts b/packages/core/src/session/context-entry.ts index 1f3ffdb053..5add7d3c69 100644 --- a/packages/core/src/session/context-entry.ts +++ b/packages/core/src/session/context-entry.ts @@ -38,6 +38,7 @@ const renderBlock = (key: Key, value: Schema.Json) => const source = (entry: Info) => SystemContext.make({ key: SystemContext.Key.make(`api/${entry.key}`), + description: `Session context: ${entry.key}`, codec: Schema.toCodecJson(Schema.Json), load: Effect.succeed(entry.value), baseline: (value) => renderBlock(entry.key, value), diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index a95a1ab0b8..5ff1918e2d 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -37,22 +37,37 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray skill.name, (before, after) => before.description !== after.description, ) + const items = [ + ...diff.added.map((skill) => ({ key: skill.name, description: skill.description, action: "added" as const })), + ...diff.removed.map((skill) => ({ key: skill.name, description: skill.description, action: "removed" as const })), + ...diff.changed.map((skill) => ({ + key: skill.current.name, + description: skill.current.description, + action: "updated" as const, + })), + ] // 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 - ? [] - : [ - `The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`, - ]), - ].join("\n") + return { + text: [ + "The available skills have changed. This list supersedes the previous available skills list.", + render(current), + ].join("\n"), + items, + } + return { + text: [ + ...(diff.added.length === 0 + ? [] + : ["New skills are available in addition to those previously listed:", ...entries(diff.added)]), + ...(diff.removed.length === 0 + ? [] + : [ + `The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`, + ]), + ].join("\n"), + items, + } } export interface Interface { @@ -82,6 +97,7 @@ const layer = Layer.effect( .toSorted((a, b) => a.name.localeCompare(b.name)) return SystemContext.make({ key: SystemContext.Key.make("core/skill-guidance"), + description: "Available skills", codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(available), baseline: render, diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index 1cf470fb4a..4d481699cc 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -26,6 +26,7 @@ const layer = Layer.effect( const context = SystemContext.combine([ SystemContext.make({ key: SystemContext.Key.make("core/environment"), + description: "Environment", codec: Schema.toCodecJson(Schema.String), load: Effect.succeed(environment), baseline: (environment) => @@ -34,6 +35,7 @@ const layer = Layer.effect( }), SystemContext.make({ key: SystemContext.Key.make("core/date"), + description: "Current date", codec: Schema.toCodecJson(Schema.String), load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())), baseline: (date) => `Today's date: ${date}`, diff --git a/packages/core/src/system-context/index.ts b/packages/core/src/system-context/index.ts index ce6aaae454..0b486f705f 100644 --- a/packages/core/src/system-context/index.ts +++ b/packages/core/src/system-context/index.ts @@ -36,13 +36,34 @@ export type Unavailable = typeof unavailable /** Defines one typed source before its value type is hidden by `make`. */ export interface Source { readonly key: Key - readonly codec: Schema.Codec + readonly description: string + readonly codec: Schema.Codec readonly load: Effect.Effect readonly baseline: (current: A) => string - readonly update: (previous: A, current: A) => string + readonly update: (previous: A, current: A) => string | StructuredUpdate readonly removed?: (previous: A) => string } +export type ReconcileAction = "added" | "updated" | "removed" + +export interface ReconcileItemUpdate { + readonly key: string + readonly description: string + readonly action: ReconcileAction +} + +export interface ReconcileUpdate { + readonly key: Key + readonly description: string + readonly action: ReconcileAction + readonly items?: ReadonlyArray +} + +export interface StructuredUpdate { + readonly text: string + readonly items?: ReadonlyArray +} + const ContextTypeId: unique symbol = Symbol.for("@opencode/SystemContext") /** Opaque carrier for composable system context sources. */ @@ -53,6 +74,7 @@ export interface SystemContext { /** The value last applied to the model for one admitted source. */ export const AppliedSource = Schema.Struct({ value: Schema.Json, + description: Schema.optional(Schema.NonEmptyString), removed: Schema.optional(Schema.NonEmptyString), }) export type AppliedSource = typeof AppliedSource.Type @@ -70,6 +92,7 @@ export interface Baseline { export interface Updated { readonly _tag: "Updated" readonly text: string + readonly updates: ReadonlyArray readonly applied: Applied } @@ -94,16 +117,18 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass /** Restates the model's belief from a last-applied value when the source cannot be observed. */ readonly recall: (stored: AppliedSource) => string | undefined } interface Observed { + readonly description: string 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 + readonly update: (previous: AppliedSource) => StructuredUpdate | undefined } interface Entry { @@ -120,10 +145,12 @@ 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 description = requireText(source.key, "description", source.description) const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value)) return context([ { key: source.key, + description, recall: (stored) => Option.match(decode(stored.value), { onNone: () => undefined, @@ -133,18 +160,18 @@ export function make(source: Source): SystemContext { Effect.map((value) => { if (isUnavailable(value)) return value return { + description, applied: { value: encode(value), + description, ...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}), }, baseline: () => baseline(value), update: (previous) => Option.match(decode(previous.value), { - onNone: () => baseline(value), + onNone: () => ({ text: baseline(value) }), onSome: (decoded) => - equivalent(decoded, value) - ? undefined - : requireText(source.key, "update", source.update(decoded, value)), + equivalent(decoded, value) ? undefined : normalizeUpdate(source.key, source.update(decoded, value)), }), } satisfies Observed }), @@ -216,7 +243,8 @@ export function initialize(value: SystemContext): Effect.Effect { return observe(value).pipe( Effect.map((entries): ReconcileResult => { - const updates: string[] = [] + const parts: string[] = [] + const updates: ReconcileUpdate[] = [] const applied: Record = {} for (const entry of entries) { const stored = get(previous, entry.key) @@ -226,16 +254,23 @@ export function reconcile(value: SystemContext, previous: Applied): Effect.Effec continue } if (!stored) { - updates.push(entry.observed.baseline()) + parts.push(entry.observed.baseline()) + updates.push({ key: entry.key, description: entry.observed.description, action: "added" }) applied[entry.key] = entry.observed.applied continue } - const text = entry.observed.update(stored) - if (text === undefined) { + const update = entry.observed.update(stored) + if (update === undefined) { applied[entry.key] = stored continue } - updates.push(text) + parts.push(update.text) + updates.push({ + key: entry.key, + description: entry.observed.description, + action: "updated", + ...(update.items === undefined ? {} : { items: update.items }), + }) applied[entry.key] = entry.observed.applied } const keys = new Set(entries.map((entry) => entry.key)) @@ -244,10 +279,17 @@ export function reconcile(value: SystemContext, previous: Applied): Effect.Effec 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) + else { + parts.push(removed) + updates.push({ + key: Key.make(key), + description: previous[key].description ?? key, + action: "removed", + }) + } } if (updates.length === 0) return { _tag: "Unchanged" } - return { _tag: "Updated", text: render(updates), applied } + return { _tag: "Updated", text: render(parts), updates, applied } }), ) } @@ -298,6 +340,11 @@ function requireText(key: Key, kind: string, text: string) { return text } +function normalizeUpdate(key: Key, update: string | StructuredUpdate) { + if (typeof update === "string") return { text: requireText(key, "update", update) } + return { ...update, text: requireText(key, "update", update.text) } +} + function assertUniqueKeys(sources: ReadonlyArray) { const keys = new Set() for (const source of sources) { diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index 8bd04d305a..f87dea689c 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -94,6 +94,13 @@ describe("InstructionContext", () => { `Instructions from: ${globalFile}\nglobal`, `Instructions from: ${projectFile}\nproject`, ].join("\n\n"), + updates: [ + { + key: SystemContext.Key.make("core/instructions"), + description: "Ambient instructions", + action: "updated", + }, + ], applied: expect.any(Object), }) @@ -101,6 +108,13 @@ describe("InstructionContext", () => { expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toEqual({ _tag: "Updated", text: "Previously loaded instructions no longer apply.", + updates: [ + { + key: SystemContext.Key.make("core/instructions"), + description: "Ambient instructions", + action: "removed", + }, + ], applied: {}, }) }), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index af96096266..c0420e73db 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -186,6 +186,7 @@ const systemContext = Layer.mock(SystemContextBuiltIns.Service, { : [ SystemContext.make({ key: systemContextKey, + description: "Test context", codec: Schema.toCodecJson(Schema.String), load: systemLoadHook.pipe( Effect.andThen(Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline))), @@ -205,6 +206,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { skillBaselines.has(agent.id) ? SystemContext.make({ key: SystemContext.Key.make("test/skill-guidance"), + description: "Test skill guidance", codec: Schema.toCodecJson(Schema.String), load: Effect.succeed(skillBaselines.get(agent.id)!), baseline: String, @@ -809,7 +811,9 @@ describe("SessionRunnerLLM", () => { .where(eq(SessionContextCheckpointTable.session_id, sessionID)) .get() .pipe(Effect.orDie) - expect(healed?.snapshot).toEqual({ "test/context": { value: "Initial context", removed: expect.any(String) } }) + expect(healed?.snapshot).toEqual({ + "test/context": { value: "Initial context", description: "Test context", removed: expect.any(String) }, + }) }), ) @@ -2807,8 +2811,7 @@ describe("SessionRunnerLLM", () => { description: "Ask the user", input: Schema.Struct({}), output: Schema.Struct({}), - execute: (_, context) => - questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie), + execute: (_, context) => questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie), }), }) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false }) diff --git a/packages/core/test/system-context/index.test.ts b/packages/core/test/system-context/index.test.ts index f62053b088..c0409ce464 100644 --- a/packages/core/test/system-context/index.test.ts +++ b/packages/core/test/system-context/index.test.ts @@ -3,16 +3,18 @@ import { Cause, Effect, Exit, Schema } from "effect" import { SystemContext } from "@opencode-ai/core/system-context" import { it } from "../lib/effect" -const key = SystemContext.Key.make +const key = (value: string) => SystemContext.Key.make(value) const stringContext = (input: { key: string value: string | SystemContext.Unavailable + description?: string baseline?: (value: string) => string - update?: (previous: string, current: string) => string + update?: (previous: string, current: string) => string | SystemContext.StructuredUpdate removed?: (value: string) => string }) => SystemContext.make({ key: key(input.key), + description: input.description ?? `Description for ${input.key}`, codec: Schema.toCodecJson(Schema.String), load: Effect.succeed(input.value), baseline: input.baseline ?? String, @@ -25,6 +27,7 @@ describe("SystemContext", () => { Effect.gen(function* () { const context = SystemContext.make({ key: key("core/date"), + description: "Current date", codec: Schema.toCodecJson(Schema.DateFromString), load: Effect.succeed(new Date("2026-06-03T12:00:00.000Z")), baseline: (date) => date.toISOString(), @@ -42,6 +45,7 @@ describe("SystemContext", () => { const context = SystemContext.combine([ SystemContext.make({ key: key("core/date"), + description: "Current date", codec: Schema.toCodecJson(Schema.String), load: Effect.sync(() => { loads++ @@ -57,8 +61,8 @@ describe("SystemContext", () => { expect(yield* SystemContext.initialize(context)).toEqual({ 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" }, + "core/date": { value: "2026-06-03", description: "Current date", removed: "The date was removed." }, + "core/location": { value: "/repo", description: "Description for core/location" }, }, }) expect(loads).toBe(1) @@ -84,8 +88,13 @@ describe("SystemContext", () => { expect(yield* SystemContext.reconcile(changed, previous)).toEqual({ _tag: "Updated", text: "The date changed from 2026-06-03 to 2026-06-04.", + updates: [{ key: key("core/date"), description: "Description for core/date", action: "updated" }], applied: { - "core/date": { value: "2026-06-04", removed: "The date was removed." }, + "core/date": { + value: "2026-06-04", + description: "Description for core/date", + removed: "The date was removed.", + }, "core/location": { value: "/repo", removed: "Removed: /repo" }, }, }) @@ -113,7 +122,8 @@ describe("SystemContext", () => { expect(yield* SystemContext.reconcile(context, {})).toEqual({ _tag: "Updated", text: "Available skill: effect", - applied: { "core/skills": { value: "effect" } }, + updates: [{ key: key("core/skills"), description: "Description for core/skills", action: "added" }], + applied: { "core/skills": { value: "effect", description: "Description for core/skills" } }, }) }), ) @@ -150,6 +160,7 @@ describe("SystemContext", () => { ).toEqual({ _tag: "Updated", text: "Instructions removed; stop applying them.", + updates: [{ key: key("core/instructions"), description: "core/instructions", action: "removed" }], applied: {}, }) }), @@ -169,8 +180,9 @@ describe("SystemContext", () => { ).toEqual({ _tag: "Updated", text: "effect", + updates: [{ key: key("core/skills"), description: "Description for core/skills", action: "added" }], applied: { - "core/skills": { value: "effect" }, + "core/skills": { value: "effect", description: "Description for core/skills" }, "core/date": { value: "2026-06-04" }, }, }) @@ -208,7 +220,8 @@ describe("SystemContext", () => { ).toEqual({ _tag: "Updated", text: "2026-06-04", - applied: { "core/date": { value: "2026-06-04" } }, + updates: [{ key: key("core/date"), description: "Description for core/date", action: "updated" }], + applied: { "core/date": { value: "2026-06-04", description: "Description for core/date" } }, }) }), ) @@ -232,9 +245,13 @@ describe("SystemContext", () => { ).toEqual({ _tag: "Updated", text: "2026-06-03 -> 2026-06-04\n\n/repo", + updates: [ + { key: key("core/date"), description: "Description for core/date", action: "updated" }, + { key: key("core/location"), description: "Description for core/location", action: "updated" }, + ], applied: { - "core/date": { value: "2026-06-04" }, - "core/location": { value: "/repo" }, + "core/date": { value: "2026-06-04", description: "Description for core/date" }, + "core/location": { value: "/repo", description: "Description for core/location" }, }, }) }), @@ -245,6 +262,7 @@ describe("SystemContext", () => { let loads = 0 const context = SystemContext.make({ key: key("core/date"), + description: "Current date", codec: Schema.toCodecJson(Schema.String), load: Effect.sync(() => { loads++ @@ -256,7 +274,7 @@ describe("SystemContext", () => { expect(yield* SystemContext.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({ text: "2026-06-04", - applied: { "core/date": { value: "2026-06-04" } }, + applied: { "core/date": { value: "2026-06-04", description: "Current date" } }, }) expect(loads).toBe(1) }), @@ -280,7 +298,7 @@ describe("SystemContext", () => { ).toEqual({ text: "2026-06-04\n\nInstructions: contents", applied: { - "core/date": { value: "2026-06-04" }, + "core/date": { value: "2026-06-04", description: "Description for core/date" }, "core/remote": { value: "contents", removed: "Instructions removed" }, }, }) @@ -334,6 +352,34 @@ describe("SystemContext", () => { }), ) + it.effect("includes structured item updates from source reconciliation", () => + Effect.gen(function* () { + const context = stringContext({ + key: "core/skills", + value: "new", + description: "Available skills", + update: () => ({ + text: "Skill changes", + items: [{ key: "effect", description: "Build with Effect", action: "updated" }], + }), + }) + + expect(yield* SystemContext.reconcile(context, { "core/skills": { value: "old" } })).toEqual({ + _tag: "Updated", + text: "Skill changes", + updates: [ + { + key: key("core/skills"), + description: "Available skills", + action: "updated", + items: [{ key: "effect", description: "Build with Effect", action: "updated" }], + }, + ], + applied: { "core/skills": { value: "new", description: "Available skills" } }, + }) + }), + ) + it.effect("rejects duplicate source keys", () => Effect.sync(() => { expect(() =>