Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton 417c88fc40 chore(core): format session runner test 2026-07-02 20:54:00 -04:00
Kit Langton 860604ec4c refactor(core): simplify system context update metadata 2026-07-02 20:52:58 -04:00
Kit Langton 29c29739b5 feat(core): structure system context updates 2026-07-02 20:52:55 -04:00
10 changed files with 235 additions and 62 deletions
+1
View File
@@ -34,6 +34,7 @@ const layer = Layer.effect(
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
SystemContext.make({
key,
description: "Ambient instructions",
codec: Schema.toCodecJson(Files),
load: Effect.succeed(value),
baseline: render,
+28 -14
View File
@@ -31,22 +31,35 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
(server) => server.server,
(before, after) => before.instructions !== after.instructions,
)
const items = SystemContext.diffItems(diff, (server) => ({
key: server.server,
description: "MCP server 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")
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 +96,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,
+25 -14
View File
@@ -35,22 +35,32 @@ const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyA
(reference) => reference.name,
(before, after) => before.path !== after.path || before.description !== after.description,
)
const items = SystemContext.diffItems(diff, (reference) => ({
key: reference.name,
description: reference.description ?? reference.path,
}))
// 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 +87,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,
@@ -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),
+22 -14
View File
@@ -37,22 +37,29 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
(skill) => skill.name,
(before, after) => before.description !== after.description,
)
const items = SystemContext.diffItems(diff, (skill) => ({ key: skill.name, description: skill.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
? []
: [
`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 +89,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,
@@ -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}`,
+72 -15
View File
@@ -36,13 +36,34 @@ export type Unavailable = typeof unavailable
/** Defines one typed source before its value type is hidden by `make`. */
export interface Source<A> {
readonly key: Key
readonly codec: Schema.Codec<A, Schema.Json, never, never>
readonly description: string
readonly codec: Schema.Codec<A, Schema.Json>
readonly load: Effect.Effect<A | Unavailable>
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<ReconcileItemUpdate>
}
export interface StructuredUpdate {
readonly text: string
readonly items?: ReadonlyArray<ReconcileItemUpdate>
}
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<ReconcileUpdate>
readonly applied: Applied
}
@@ -100,10 +123,11 @@ interface PackedSource {
}
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,6 +144,7 @@ export function make<A>(source: Source<A>): SystemContext {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const equivalent = Schema.toEquivalence(source.codec)
const description = requireText(source.key, "description", source.description)
const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value))
return context([
{
@@ -133,18 +158,19 @@ export function make<A>(source: Source<A>): SystemContext {
Effect.map((value) => {
if (isUnavailable(value)) return value
return {
description,
applied: {
value: encode(value),
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
...(source.removed
? { description, 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
}),
@@ -179,6 +205,17 @@ export function diffByKey<A>(
}
}
export function diffItems<A>(
diff: ReturnType<typeof diffByKey<A>>,
item: (value: A) => { readonly key: string; readonly description: string },
): ReadonlyArray<ReconcileItemUpdate> {
return [
...diff.added.map((value) => ({ ...item(value), action: "added" as const })),
...diff.removed.map((value) => ({ ...item(value), action: "removed" as const })),
...diff.changed.map((value) => ({ ...item(value.current), action: "updated" as const })),
]
}
/** Combines contexts in order and rejects duplicate source keys immediately. */
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
const sources = values.flatMap((value) => value[ContextTypeId])
@@ -216,7 +253,8 @@ export function initialize(value: SystemContext): Effect.Effect<Baseline, Initia
export function reconcile(value: SystemContext, previous: Applied): Effect.Effect<ReconcileResult> {
return observe(value).pipe(
Effect.map((entries): ReconcileResult => {
const updates: string[] = []
const parts: string[] = []
const updates: ReconcileUpdate[] = []
const applied: Record<string, AppliedSource> = {}
for (const entry of entries) {
const stored = get(previous, entry.key)
@@ -226,16 +264,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<string>(entries.map((entry) => entry.key))
@@ -244,10 +289,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 +350,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<PackedSource>) {
const keys = new Set<Key>()
for (const source of sources) {
@@ -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: {},
})
}),
+5 -1
View File
@@ -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) },
})
}),
)
@@ -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,7 +61,7 @@ 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/date": { value: "2026-06-03", description: "Current date", removed: "The date was removed." },
"core/location": { value: "/repo" },
},
})
@@ -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,6 +122,7 @@ describe("SystemContext", () => {
expect(yield* SystemContext.reconcile(context, {})).toEqual({
_tag: "Updated",
text: "Available skill: effect",
updates: [{ key: key("core/skills"), description: "Description for core/skills", action: "added" }],
applied: { "core/skills": { value: "effect" } },
})
}),
@@ -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,6 +180,7 @@ 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/date": { value: "2026-06-04" },
@@ -208,6 +220,7 @@ describe("SystemContext", () => {
).toEqual({
_tag: "Updated",
text: "2026-06-04",
updates: [{ key: key("core/date"), description: "Description for core/date", action: "updated" }],
applied: { "core/date": { value: "2026-06-04" } },
})
}),
@@ -232,6 +245,10 @@ 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" },
@@ -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++
@@ -331,6 +349,49 @@ describe("SystemContext", () => {
},
],
})
expect(
SystemContext.diffItems(
SystemContext.diffByKey(
previous,
current,
(value) => value.name,
(before, after) => before.description !== after.description,
),
(value) => ({ key: value.name, description: value.description }),
),
).toEqual([
{ key: "writing", description: "Write prose", action: "added" },
{ key: "retired", description: "Old", action: "removed" },
{ key: "effect", description: "Build with Effect v4", action: "updated" },
])
}),
)
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" } },
})
}),
)