feat(core): replace instruction checkpoints with value-delta sync (#36254)

This commit is contained in:
Kit Langton 2026-07-10 13:26:25 -04:00 committed by GitHub
commit 96a9731947
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 2053 additions and 1278 deletions

View file

@ -23,6 +23,7 @@ import sessionPendingTableMigration from "@opencode-ai/core/database/migration/2
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@ -359,12 +360,12 @@ describe("DatabaseMigration", () => {
).toEqual({ name: "session_pending" })
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
).toEqual({ name: "instruction_checkpoint" })
expect(
yield* db.get(
sql`SELECT name FROM pragma_table_info('instruction_checkpoint') WHERE name IN ('agent', 'replacement_seq', 'revision')`,
),
).toBeUndefined()
expect(
yield* db.all(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('instruction_blob', 'instruction_state') ORDER BY name`,
),
).toEqual([{ name: "instruction_blob" }, { name: "instruction_state" }])
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect(
yield* db.all(
@ -507,6 +508,91 @@ describe("DatabaseMigration", () => {
)
})
test("deletes pre-beta instruction events and projected System messages", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, fork_session_id text)`)
yield* db.run(
sql`CREATE TABLE instruction_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`,
)
yield* db.run(sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, type text NOT NULL)`)
yield* db.run(sql`INSERT INTO session VALUES ('ses_test', NULL)`)
yield* db.run(
sql`INSERT INTO event VALUES ('evt_instruction', 'ses_test', 0, 'session.instructions.updated.1', '{"sessionID":"ses_test","text":"changed"}')`,
)
yield* db.run(
sql`INSERT INTO event VALUES ('evt_other', 'ses_test', 1, 'session.synthetic.1', '{"sessionID":"ses_test","text":"keep"}')`,
)
yield* db.run(
sql`INSERT INTO session_message VALUES ('msg_instruction', 'system'), ('msg_other', 'system'), ('msg_user', 'user')`,
)
yield* db.run(sql`INSERT INTO instruction_entry VALUES ('ses_test', 'plan', '"ready"', 1, 2)`)
yield* DatabaseMigration.applyOnly(db, [instructionSyncMigration])
expect(yield* db.all(sql`SELECT id, type FROM event`)).toEqual([
{ id: "evt_other", type: "session.synthetic.1" },
])
expect(yield* db.all(sql`SELECT id, type FROM session_message ORDER BY id`)).toEqual([
{ id: "msg_user", type: "user" },
])
expect(yield* db.get(sql`SELECT * FROM instruction_entry`)).toEqual({
session_id: "ses_test",
key: "plan",
value: '"ready"',
removed: 0,
time_created: 1,
time_updated: 2,
})
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
).toBeUndefined()
}),
)
})
test("records the authoritative parent sequence on existing forks", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, fork_session_id text)`)
yield* db.run(
sql`CREATE TABLE instruction_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`,
)
yield* db.run(sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, type text NOT NULL)`)
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`INSERT INTO session VALUES ('ses_child', 'ses_parent')`)
yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_child', 8)`)
yield* db.run(
sql`INSERT INTO event VALUES ('evt_fork', 'ses_child', 0, 'session.forked.1', '{"sessionID":"ses_child","parentID":"ses_parent"}')`,
)
yield* db.run(
sql`INSERT INTO event VALUES ('evt_instruction', 'ses_child', 5, 'session.instructions.updated.1', '{"sessionID":"ses_child","text":"changed"}')`,
)
yield* db.run(sql`INSERT INTO event VALUES ('evt_input', 'ses_child', 6, 'session.input.admitted.1', '{}')`)
yield* DatabaseMigration.applyOnly(db, [instructionSyncMigration])
expect(yield* db.get(sql`SELECT fork_seq FROM session`)).toEqual({ fork_seq: 4 })
expect(yield* db.get(sql`SELECT type, data FROM event WHERE seq = 0`)).toEqual({
type: "session.forked.2",
data: '{"sessionID":"ses_child","parentID":"ses_parent","parentSeq":4}',
})
expect(yield* db.get(sql`SELECT id FROM event WHERE id = 'evt_instruction'`)).toBeUndefined()
}),
)
})
test("keeps legacy credential fields nullable", async () => {
await run(
Effect.gen(function* () {
@ -639,17 +725,14 @@ describe("DatabaseMigration", () => {
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
)
yield* db.run(
sql`INSERT INTO instruction_checkpoint (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
)
yield* db.run(sql`ALTER TABLE instruction_checkpoint RENAME TO session_context_epoch`)
yield* db.run(sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY)`)
// The partial compaction index embeds the qualified table name, so it
// must drop before the historical rename dance and recreate after.
yield* db.run(sql`DROP INDEX session_pending_session_compaction_idx`)
yield* db.run(sql`ALTER TABLE session_pending RENAME TO session_input`)
yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionPendingMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [simplifySessionPendingMigration])
yield* db.run(sql`ALTER TABLE session_context_epoch RENAME TO instruction_checkpoint`)
yield* db.run(sql`DROP TABLE session_context_epoch`)
yield* db.run(sql`ALTER TABLE session_input RENAME TO session_pending`)
yield* db.run(
sql`CREATE UNIQUE INDEX session_pending_session_compaction_idx ON session_pending (session_id) WHERE "session_pending"."type" = 'compaction'`,
@ -685,7 +768,7 @@ describe("DatabaseMigration", () => {
(SELECT COUNT(*) FROM workspace) AS workspaces,
(SELECT COUNT(*) FROM session_pending) AS sessionInputs,
(SELECT COUNT(*) FROM session_message) AS sessionMessages,
(SELECT COUNT(*) FROM instruction_checkpoint) AS instructionCheckpoints,
(SELECT COUNT(*) FROM instruction_state) AS instructionStates,
(SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq,
(SELECT type FROM event WHERE aggregate_id = 'session') AS eventType
`),
@ -697,7 +780,7 @@ describe("DatabaseMigration", () => {
workspaces: 0,
sessionInputs: 0,
sessionMessages: 0,
instructionCheckpoints: 0,
instructionStates: 0,
seq: 0,
eventType: "session.updated.1",
})

View file

@ -55,6 +55,17 @@ const SyncSent = EventV2.durable({
},
})
const VersionedMessageV1 = EventV2.durable({
type: "test.versioned",
durable: { version: 1, aggregate: "id" },
schema: { id: Schema.String },
})
const VersionedMessageV2 = EventV2.durable({
type: "test.versioned",
durable: { version: 2, aggregate: "id" },
schema: { id: Schema.String },
})
const GlobalMessage = EventV2.ephemeral({
type: "test.global",
schema: {
@ -722,16 +733,35 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(SessionEvent.InstructionsUpdated.type, 1),
type: EventV2.versionedType(SessionEvent.InstructionsUpdated.type, 2),
seq: 0,
aggregateID,
data: { sessionID: aggregateID, text: "context" },
data: { sessionID: aggregateID, delta: { "core/context": "0".repeat(64) } },
})
expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
}),
)
it.effect("dispatches durable projectors by exact event version", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = Session.ID.create()
const received = new Array<typeof VersionedMessageV2.Type>()
yield* events.project(VersionedMessageV2, (event) =>
Effect.sync(() => {
received.push(event)
}),
)
yield* events.publish(VersionedMessageV1, { id: aggregateID })
yield* events.publish(VersionedMessageV2, { id: aggregateID })
expect(received).toHaveLength(1)
expect(received[0]?.durable.version).toBe(EventV2.Version.make(2))
}),
)
it.effect("replay defects on unknown event type", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service

View file

@ -9,10 +9,10 @@ import { Global } from "@opencode-ai/core/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Instructions } from "@opencode-ai/core/instructions"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { readInitial, readUpdate, state } from "./lib/instructions"
const it = testEffect(Layer.empty)
@ -69,7 +69,7 @@ describe("InstructionDiscovery", () => {
),
)
const initialized = yield* Instructions.initialize(yield* load)
const initialized = yield* readInitial(yield* load)
expect(initialized.text).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
@ -80,29 +80,24 @@ describe("InstructionDiscovery", () => {
expect(initialized.text).not.toContain("outside")
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
expect(yield* Instructions.reconcile(yield* load, initialized.applied)).toMatchObject({
_tag: "Updated",
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
})
expect((yield* readUpdate(yield* load, initialized)).text).toContain(
`Instructions from: ${packageFile}\nchanged`,
)
yield* Effect.promise(() => fs.rm(packageFile))
const partial = yield* Instructions.reconcile(yield* load, initialized.applied)
expect(partial).toEqual({
_tag: "Updated",
text: [
const partial = yield* readUpdate(yield* load, initialized)
expect(partial.text).toBe(
[
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${projectFile}\nproject`,
].join("\n\n"),
applied: expect.any(Object),
})
)
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
expect(yield* Instructions.reconcile(yield* load, initialized.applied)).toEqual({
_tag: "Updated",
text: "Previously loaded instructions no longer apply.",
applied: {},
})
expect((yield* readUpdate(yield* load, initialized)).text).toBe(
"Previously loaded instructions no longer apply.",
)
}),
),
),
@ -130,7 +125,7 @@ describe("InstructionDiscovery", () => {
),
)
expect((yield* Instructions.initialize(context)).text).toBe(`Instructions from: ${file}\n`)
expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`)
}),
),
),
@ -161,13 +156,9 @@ describe("InstructionDiscovery", () => {
)
expect(
yield* Instructions.reconcile(context, {
"core/instructions": {
value: [{ path: "/repo/AGENTS.md", content: "old" }],
removed: "Previously loaded instructions no longer apply.",
},
}),
).toEqual({ _tag: "Unchanged" })
(yield* readUpdate(context, state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] })))
.changed,
).toBe(false)
}),
)
@ -201,13 +192,8 @@ describe("InstructionDiscovery", () => {
)
expect(
yield* Instructions.reconcile(context, {
"core/instructions": {
value: [{ path: file, content: "old" }],
removed: "Previously loaded instructions no longer apply.",
},
}),
).toEqual({ _tag: "Unchanged" })
(yield* readUpdate(context, state({ "core/instructions": [{ path: file, content: "old" }] }))).changed,
).toBe(false)
}),
)

View file

@ -6,10 +6,10 @@ import { Location } from "@opencode-ai/core/location"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Instructions } from "@opencode-ai/core/instructions"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
@ -36,7 +36,7 @@ describe("InstructionBuiltIns", () => {
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* Instructions.initialize(yield* context.load())
const initialized = yield* readInitial(yield* context.load())
expect(initialized.text).toBe(
[
@ -54,19 +54,16 @@ describe("InstructionBuiltIns", () => {
}),
)
it.effect("reconciles the date without repeating unchanged environment instructions", () =>
it.effect("updates the date without repeating unchanged environment instructions", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* Instructions.initialize(yield* context.load())
const initialized = yield* readInitial(yield* context.load())
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
const refreshed = yield* Instructions.reconcile(yield* context.load(), initialized.applied)
const refreshed = yield* readUpdate(yield* context.load(), initialized)
expect(refreshed).toMatchObject({
_tag: "Updated",
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
})
expect(refreshed.text).toBe(`Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`)
}),
)
@ -74,10 +71,10 @@ describe("InstructionBuiltIns", () => {
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* Instructions.initialize(yield* context.load())
const initialized = yield* readInitial(yield* context.load())
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
expect(yield* Instructions.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" })
expect((yield* readUpdate(yield* context.load(), initialized)).changed).toBe(false)
}),
)
})

View file

@ -1,137 +1,142 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Schema } from "effect"
import { Cause, Effect, Exit, Option, Schema } from "effect"
import { Instructions } from "@opencode-ai/core/instructions"
import { it } from "../lib/effect"
const key = Instructions.Key.make
const stringContext = (input: {
const key = (value: string) => Instructions.Key.make(value)
const source = (input: {
key: string
value: string | Instructions.Unavailable
baseline?: (value: string) => string
update?: (previous: string, current: string) => string
value: string | Instructions.Unavailable | Instructions.Removed
initial?: (value: string) => string
changed?: (previous: string, current: string) => string
removed?: (value: string) => string
}) =>
Instructions.make({
key: key(input.key),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(input.value),
baseline: input.baseline ?? String,
update: input.update ?? ((_previous, current) => current),
removed: input.removed,
read: Effect.succeed(input.value),
render: {
initial: input.initial ?? String,
changed: input.changed ?? ((_previous, current) => current),
removed: input.removed,
},
})
describe("Instructions", () => {
it.effect("stores the canonical JSON encoding of the loaded value", () =>
it.effect("reads each source once and derives the initial delta and text", () =>
Effect.gen(function* () {
const context = Instructions.make({
let reads = 0
const instructions = Instructions.make({
key: key("core/date"),
codec: Schema.toCodecJson(Schema.DateFromString),
load: Effect.succeed(new Date("2026-06-03T12:00:00.000Z")),
baseline: (date) => date.toISOString(),
update: (_previous, date) => date.toISOString(),
removed: () => "Date removed",
})
expect((yield* Instructions.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
}),
)
it.effect("loads once and initializes a baseline with the applied values", () =>
Effect.gen(function* () {
let loads = 0
const context = Instructions.combine([
Instructions.make({
key: key("core/date"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.sync(() => {
loads++
return "2026-06-03"
}),
baseline: (date) => `Today's date is ${date}.`,
update: (previous, current) => `The date changed from ${previous} to ${current}.`,
removed: () => "The date was removed.",
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() => {
reads++
return "2026-07-09"
}),
stringContext({ key: "core/location", value: "/repo", baseline: (value) => `Directory: ${value}` }),
])
expect(yield* Instructions.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" },
},
})
expect(loads).toBe(1)
}),
)
it.effect("renders updates only after a structured value changes", () =>
Effect.gen(function* () {
const previous = {
"core/date": { value: "2026-06-03", removed: "The date was removed." },
"core/location": { value: "/repo", removed: "Removed: /repo" },
}
const changed = Instructions.combine([
stringContext({
key: "core/date",
value: "2026-06-04",
update: (before, current) => `The date changed from ${before} to ${current}.`,
removed: () => "The date was removed.",
}),
stringContext({ key: "core/location", value: "/repo" }),
])
expect(yield* Instructions.reconcile(changed, previous)).toEqual({
_tag: "Updated",
text: "The date changed from 2026-06-03 to 2026-06-04.",
applied: {
"core/date": { value: "2026-06-04", removed: "The date was removed." },
"core/location": { value: "/repo", removed: "Removed: /repo" },
render: {
initial: (date) => `Today's date: ${date}`,
changed: (previous, current) => `The date changed from ${previous} to ${current}`,
},
})
const admitted = yield* Instructions.read(instructions).pipe(Effect.flatMap(Instructions.diff))
const hash = Instructions.hash("2026-07-09")
expect(reads).toBe(1)
expect(admitted).toEqual({
delta: { "core/date": hash },
blobs: { [hash]: "2026-07-09" },
})
expect(Instructions.renderInitial(instructions, { "core/date": "2026-07-09" })).toBe("Today's date: 2026-07-09")
}),
)
it.effect("derives no delta when the encoded value is unchanged", () =>
Effect.gen(function* () {
const instructions = source({ key: "core/date", value: "2026-07-09" })
const admitted = yield* Instructions.read(instructions).pipe(
Effect.flatMap((observed) => Instructions.diff(observed, { "core/date": Instructions.hash("2026-07-09") })),
)
expect(admitted).toEqual({ delta: {}, blobs: {} })
}),
)
it.effect("renders a changed value from stored values", () =>
Effect.gen(function* () {
const instructions = source({
key: "core/date",
value: "2026-07-10",
changed: (previous, current) => `The date changed from ${previous} to ${current}`,
})
const admitted = yield* Instructions.read(instructions).pipe(
Effect.flatMap((observed) => Instructions.diff(observed, { "core/date": Instructions.hash("2026-07-09") })),
)
expect(admitted.delta).toEqual({ "core/date": Instructions.hash("2026-07-10") })
expect(
yield* Instructions.reconcile(
Instructions.combine([
stringContext({ key: "core/date", value: "2026-06-03", removed: () => "The date was removed." }),
stringContext({ key: "core/location", value: "/repo" }),
]),
previous,
Instructions.renderUpdate(
instructions,
{ "core/date": "2026-07-09" },
{ "core/date": Option.some("2026-07-10") },
),
).toEqual({ _tag: "Unchanged" })
).toBe("The date changed from 2026-07-09 to 2026-07-10")
}),
)
it.effect("uses the baseline for a newly added source", () =>
it.effect("admits and renders an observed removal", () =>
Effect.gen(function* () {
const context = stringContext({
key: "core/skills",
value: "effect",
baseline: (skill) => `Available skill: ${skill}`,
const instructions = source({
key: "core/remote",
value: Instructions.removed,
removed: (previous) => `Stop applying ${previous}`,
})
const admitted = yield* Instructions.read(instructions).pipe(
Effect.flatMap((observed) => Instructions.diff(observed, { "core/remote": Instructions.hash("instructions") })),
)
expect(yield* Instructions.reconcile(context, {})).toEqual({
_tag: "Updated",
text: "Available skill: effect",
applied: { "core/skills": { value: "effect" } },
expect(admitted).toEqual({ delta: { "core/remote": "removed" }, blobs: {} })
expect(
Instructions.renderUpdate(instructions, { "core/remote": "instructions" }, { "core/remote": Option.none() }),
).toBe("Stop applying instructions")
}),
)
it.effect("treats JSON null as a value rather than a removal", () =>
Effect.gen(function* () {
const instructions = Instructions.make<Schema.Json>({
key: key("api/value"),
codec: Schema.toCodecJson(Schema.Json),
read: Effect.succeed(null),
render: {
initial: String,
changed: (_previous, current) => String(current),
removed: () => "removed",
},
})
const admitted = yield* Instructions.read(instructions).pipe(
Effect.flatMap((observed) => Instructions.diff(observed, { "api/value": Instructions.hash("previous") })),
)
expect(admitted).toEqual({
delta: { "api/value": Instructions.hash(null) },
blobs: { [Instructions.hash(null)]: null },
})
expect(
Instructions.renderUpdate(instructions, { "api/value": "previous" }, { "api/value": Option.some(null) }),
).toBe("null")
expect(Instructions.applyDelta({ "api/value": "previous" }, { "api/value": Option.some(null) })).toEqual({
"api/value": null,
})
}),
)
it.effect("retains the belief while a source is temporarily unavailable", () =>
it.effect("blocks the initial delta while any source is unavailable", () =>
Effect.gen(function* () {
const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } }
const context = stringContext({ key: "core/remote", value: Instructions.unavailable })
expect(yield* Instructions.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
}),
)
it.effect("blocks initialization while a source is unavailable", () =>
Effect.gen(function* () {
const exit = yield* Instructions.initialize(
stringContext({ key: "core/remote", value: Instructions.unavailable }),
).pipe(Effect.exit)
const exit = yield* Instructions.read(source({ key: "core/remote", value: Instructions.unavailable })).pipe(
Effect.flatMap(Instructions.diff),
Effect.exit,
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
@ -139,176 +144,89 @@ describe("Instructions", () => {
}),
)
it.effect("emits the previously stored removal message", () =>
it.effect("keeps the stored value while a source is unavailable mid-session", () =>
Effect.gen(function* () {
expect(
yield* Instructions.reconcile(Instructions.empty, {
"core/instructions": { value: "contents", removed: "Instructions removed; stop applying them." },
}),
).toEqual({
_tag: "Updated",
text: "Instructions removed; stop applying them.",
applied: {},
})
const admitted = yield* Instructions.read(source({ key: "core/remote", value: Instructions.unavailable })).pipe(
Effect.flatMap((observed) => Instructions.diff(observed, { "core/remote": Instructions.hash("instructions") })),
)
expect(admitted).toEqual({ delta: {}, blobs: {} })
}),
)
it.effect("retains an unannounced removal silently", () =>
it.effect("does not infer removal when a source is absent from the current version", () =>
Effect.gen(function* () {
expect(yield* Instructions.reconcile(Instructions.empty, { "core/date": { value: "2026-06-04" } })).toEqual({
_tag: "Unchanged",
})
const admitted = yield* Instructions.read(Instructions.empty).pipe(
Effect.flatMap((observed) =>
Instructions.diff(observed, { "core/retired": Instructions.hash("old instructions") }),
),
)
// The retained belief survives alongside other updates.
expect(
yield* Instructions.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" },
},
})
expect(admitted).toEqual({ delta: {}, blobs: {} })
}),
)
it.effect("renders multiple removals in stable key order", () =>
it.effect("renders a newly added source with its initial renderer", () =>
Effect.gen(function* () {
expect(
yield* Instructions.reconcile(Instructions.empty, {
"core/z": { value: "z", removed: "Removed z" },
"core/a": { value: "a", removed: "Removed a" },
}),
).toMatchObject({ _tag: "Updated", text: "Removed a\n\nRemoved z" })
const instructions = source({
key: "core/skills",
value: "effect",
initial: (skill) => `Available skill: ${skill}`,
})
expect(Instructions.renderUpdate(instructions, {}, { "core/skills": Option.some("effect") })).toBe(
"Available skill: effect",
)
}),
)
it.effect("hashes objects independently of key order", () =>
Effect.sync(() => {
expect(Instructions.hash({ a: 1, b: { x: true, y: false } })).toBe(
Instructions.hash({ b: { y: false, x: true }, a: 1 }),
)
}),
)
it.effect("renders sources in composition order", () =>
Effect.sync(() => {
const instructions = Instructions.combine([
source({ key: "core/date", value: "date" }),
source({ key: "core/location", value: "location" }),
])
expect(Instructions.renderInitial(instructions, { "core/date": "date", "core/location": "location" })).toBe(
"date\n\nlocation",
)
}),
)
it.effect("rejects duplicate source keys", () =>
Effect.sync(() => {
expect(() =>
Instructions.combine([source({ key: "core/date", value: "one" }), source({ key: "core/date", value: "two" })]),
).toThrow(new Instructions.DuplicateKeyError({ key: key("core/date") }))
}),
)
it.effect("rejects empty model-visible renderings", () =>
Effect.gen(function* () {
const exit = yield* Instructions.initialize(
stringContext({ key: "core/empty", value: "value", baseline: () => "" }),
).pipe(Effect.exit)
Effect.sync(() => {
const instructions = source({ key: "core/empty", value: "value", initial: () => "" })
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("rendered an empty baseline")
expect(() => Instructions.renderInitial(instructions, { "core/empty": "value" })).toThrow(
"Instruction source core/empty rendered an empty initial",
)
}),
)
it.effect("re-announces the baseline when a stored value no longer decodes", () =>
Effect.gen(function* () {
expect(
yield* Instructions.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
"core/date": { value: 42, removed: "Date removed" },
}),
).toEqual({
_tag: "Updated",
text: "2026-06-04",
applied: { "core/date": { value: "2026-06-04" } },
})
}),
)
it.effect("renders undecodable re-announcements alongside other updates", () =>
Effect.gen(function* () {
const context = Instructions.combine([
stringContext({
key: "core/date",
value: "2026-06-04",
update: (before, current) => `${before} -> ${current}`,
}),
stringContext({ key: "core/location", value: "/repo" }),
])
expect(
yield* Instructions.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 = Instructions.make({
key: key("core/date"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.sync(() => {
loads++
return "2026-06-04"
}),
baseline: String,
update: (_previous, current) => current,
})
expect(yield* Instructions.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("rebaselines an unavailable source from the last-applied belief", () =>
Effect.gen(function* () {
const context = Instructions.combine([
stringContext({ key: "core/date", value: "2026-06-04" }),
stringContext({
key: "core/remote",
value: Instructions.unavailable,
baseline: (value) => `Instructions: ${value}`,
}),
])
expect(
yield* Instructions.rebaseline(context, {
"core/remote": { value: "contents", removed: "Instructions removed" },
}),
).toEqual({
text: "2026-06-04\n\nInstructions: contents",
applied: {
"core/date": { value: "2026-06-04" },
"core/remote": { value: "contents", removed: "Instructions removed" },
},
})
}),
)
it.effect("drops undecodable beliefs and removed sources at rebaseline", () =>
Effect.gen(function* () {
const context = stringContext({ key: "core/remote", value: Instructions.unavailable })
// Undecodable belief cannot be restated; removed source entries self-clean.
expect(
yield* Instructions.rebaseline(context, {
"core/remote": { value: 42 },
"core/gone": { value: "gone" },
}),
).toEqual({ text: "", applied: {} })
}),
)
it.effect("diffs list values by key with a changed comparator", () =>
it.effect("diffs list values by key", () =>
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" },
]
@ -331,47 +249,4 @@ describe("Instructions", () => {
})
}),
)
it.effect("rejects duplicate source keys", () =>
Effect.sync(() => {
expect(() =>
Instructions.combine([
stringContext({ key: "core/date", value: "one" }),
stringContext({ key: "core/date", value: "two" }),
]),
).toThrow(new Instructions.DuplicateKeyError({ key: key("core/date") }))
}),
)
it.effect("combines instructions in order", () =>
Effect.gen(function* () {
expect(
(yield* Instructions.initialize(
Instructions.combine([
stringContext({ key: "core/date", value: "date" }),
stringContext({ key: "core/location", value: "location" }),
]),
)).text,
).toBe("date\n\nlocation")
}),
)
it.effect("requires namespaced source keys", () =>
Effect.sync(() => {
const decodeKey = Schema.decodeUnknownSync(Instructions.Key)
expect(decodeKey("core/date")).toBe(key("core/date"))
expect(() => decodeKey("date")).toThrow()
}),
)
it.effect("requires namespaced applied keys", () =>
Effect.sync(() => {
const decodeApplied = Schema.decodeUnknownSync(Instructions.Applied)
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()
}),
)
})

View file

@ -0,0 +1,43 @@
import { Effect, Option, Schema } from "effect"
import { Instructions } from "@opencode-ai/core/instructions"
export interface State {
readonly values: Readonly<Record<string, Schema.Json>>
}
export const state = (values: Readonly<Record<string, Schema.Json>>): State => ({ values })
const hashes = (values: Readonly<Record<string, Schema.Json>>): Instructions.Values =>
Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Instructions.hash(value)]))
export const readInitial = (instructions: Instructions.Instructions) =>
Effect.gen(function* () {
const admission = yield* Instructions.read(instructions).pipe(Effect.flatMap(Instructions.diff))
const current = state(
Object.fromEntries(
Object.entries(admission.delta).flatMap(([key, hash]) =>
hash === "removed" ? [] : [[key, admission.blobs[hash]]],
),
),
)
return { ...current, text: Instructions.renderInitial(instructions, current.values) }
})
export const readUpdate = (instructions: Instructions.Instructions, previous: State) =>
Effect.gen(function* () {
const admission = yield* Instructions.read(instructions).pipe(
Effect.flatMap((observed) => Instructions.diff(observed, hashes(previous.values))),
)
const delta = Object.fromEntries(
Object.entries(admission.delta).map(([key, hash]) => [
key,
hash === "removed" ? Option.none() : Option.some(admission.blobs[hash]),
]),
) as Readonly<Record<string, Option.Option<Schema.Json>>>
const values = Instructions.applyDelta(previous.values, delta)
return {
values,
text: Instructions.renderUpdate(instructions, previous.values, delta),
changed: Object.keys(admission.delta).length > 0,
}
})

View file

@ -3,7 +3,7 @@ import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -15,12 +15,26 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// Records the execution serialization a move must perform before relocating.
const executionCalls: string[] = []
const recordingExecution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: (sessionID) => Effect.sync(() => void executionCalls.push(`interrupt:${sessionID}`)),
awaitIdle: (sessionID) => Effect.sync(() => void executionCalls.push(`awaitIdle:${sessionID}`)),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@ -32,6 +46,7 @@ const it = testEffect(
SessionProjector.node,
SessionStore.node,
]),
[[SessionExecution.node, recordingExecution]],
),
)
@ -92,10 +107,13 @@ describe("MoveSession", () => {
.run()
.pipe(Effect.orDie)
executionCalls.length = 0
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
)
// The move stops active execution before any relocation side effect.
expect(executionCalls).toEqual([`interrupt:${sessionID}`, `awaitIdle:${sessionID}`])
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")

View file

@ -4,8 +4,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Reference } from "@opencode-ai/core/reference"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { it } from "./lib/effect"
import { readInitial, readUpdate } from "./lib/instructions"
const guidanceLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]])
@ -14,7 +14,7 @@ describe("ReferenceGuidance", () => {
it.effect("lists available references in the instructions", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* Instructions.initialize(yield* guidance.load())
const generation = yield* readInitial(yield* guidance.load())
expect(generation.text).toContain("<available_references>")
expect(generation.text).toContain("<name>docs</name>")
@ -46,7 +46,7 @@ describe("ReferenceGuidance", () => {
it.effect("omits guidance when no references are available", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* Instructions.initialize(yield* guidance.load())
const generation = yield* readInitial(yield* guidance.load())
expect(generation.text).toBe("")
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
)
@ -54,7 +54,7 @@ describe("ReferenceGuidance", () => {
it.effect("omits references without descriptions", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* Instructions.initialize(yield* guidance.load())
const generation = yield* readInitial(yield* guidance.load())
expect(generation.text).toBe("")
}).pipe(
Effect.provide(
@ -85,13 +85,12 @@ describe("ReferenceGuidance", () => {
let references = [reference("docs", "Use for product documentation")]
return Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const initialized = yield* Instructions.initialize(yield* guidance.load())
const initialized = yield* readInitial(yield* guidance.load())
references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")]
const added = yield* Instructions.reconcile(yield* guidance.load(), initialized.applied)
expect(added).toMatchObject({
_tag: "Updated",
text: [
const added = yield* readUpdate(yield* guidance.load(), initialized)
expect(added.text).toBe(
[
"New project references are available in addition to those previously listed:",
" <reference>",
" <name>examples</name>",
@ -99,15 +98,12 @@ describe("ReferenceGuidance", () => {
" <description>Use for examples</description>",
" </reference>",
].join("\n"),
})
)
references = [reference("examples", "Use for examples")]
expect(
yield* Instructions.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.",
})
expect((yield* readUpdate(yield* guidance.load(), added)).text).toBe(
"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) }))))
})
})

View file

@ -162,7 +162,7 @@ describe("SessionInstructions", () => {
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
// excluding the Location root (already supplied by the core/instructions baseline).
// excluding the Location root (already supplied by core initial instructions).
yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
const firstInjected = yield* synthetics(sessionID)
@ -235,7 +235,7 @@ describe("SessionInstructions", () => {
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
// the Location root (already supplied by the core/instructions baseline).
// the Location root (already supplied by core initial instructions).
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
const firstInjected = yield* synthetics(sessionID)

View file

@ -23,7 +23,7 @@ import { fromRow } from "@opencode-ai/core/session/info"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { Shell } from "@opencode-ai/schema/shell"
import {
InstructionCheckpointTable,
InstructionStateTable,
SessionPendingTable,
SessionMessageTable,
SessionTable,
@ -204,8 +204,14 @@ describe("SessionProjector", () => {
])
.run()
yield* db
.insert(InstructionCheckpointTable)
.values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 })
.insert(InstructionStateTable)
.values({
session_id: sessionID,
epoch_start: 0,
through_seq: 0,
initial_values: {},
current_values: {},
})
.run()
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Staged, {
@ -238,8 +244,8 @@ describe("SessionProjector", () => {
tokens_cache_read: 3,
tokens_cache_write: 1,
})
// A committed revert resets the context checkpoint so the next turn re-initializes.
expect(yield* db.select().from(InstructionCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
// A committed revert resets the fold cache so the next boundary establishes a new epoch.
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
}),
)

View file

@ -200,6 +200,7 @@ describe("SessionRunnerLLM recorded", () => {
.all()).map((event) => event.type),
).toEqual([
"session.input.admitted.1",
"session.instructions.updated.2",
"session.input.promoted.1",
"session.step.started.1",
"session.text.started.1",

View file

@ -46,7 +46,7 @@ import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { Tool } from "@opencode-ai/core/tool/tool"
import {
InstructionCheckpointTable,
InstructionStateTable,
SessionPendingTable,
SessionMessageTable,
SessionTable,
@ -285,22 +285,22 @@ const skillBaselines = new Map<AgentV2.ID, string>()
const systemContext = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.sync(() =>
Instructions.combine(
systemRemoved
? []
: [
Instructions.make({
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
load: systemLoadHook.pipe(
Effect.andThen(Effect.sync(() => (systemUnavailable ? Instructions.unavailable : systemBaseline))),
),
baseline: String,
update: (_previous, current) => current,
removed: () => "System context source removed: test/context",
}),
],
),
Instructions.make({
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
read: systemLoadHook.pipe(
Effect.andThen(
Effect.sync(() =>
systemUnavailable ? Instructions.unavailable : systemRemoved ? Instructions.removed : systemBaseline,
),
),
),
render: {
initial: String,
changed: (_previous, current) => current,
removed: () => "System context source removed: test/context",
},
}),
),
})
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
@ -311,10 +311,12 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, {
? Instructions.make({
key: Instructions.Key.make("test/skill-guidance"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(skillBaselines.get(agent.id)!),
baseline: String,
update: (_previous, current) => current,
removed: () => "Skill guidance removed",
read: Effect.succeed(skillBaselines.get(agent.id)!),
render: {
initial: String,
changed: (_previous, current) => current,
removed: () => "Skill guidance removed",
},
})
: Instructions.empty,
),
@ -577,6 +579,7 @@ const replaySessionProjection = (id: SessionV2.ID) =>
.pipe(Effect.orDie)
yield* events.remove(id)
yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, id)).run().pipe(Effect.orDie)
yield* db.delete(SessionPendingTable).where(eq(SessionPendingTable.session_id, id)).run().pipe(Effect.orDie)
yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
yield* events.replayAll(
@ -942,11 +945,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(0)
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
expect(
yield* db
.select()
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get(),
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
).toBeUndefined()
systemUnavailable = false
@ -971,11 +970,7 @@ describe("SessionRunnerLLM", () => {
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
})
expect(
yield* db
.select()
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get(),
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
).toBeUndefined()
yield* admit(session, "Second")
@ -987,66 +982,121 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("copies the context checkpoint to a fork", () =>
it.effect("forks instruction values at the selected message instead of the parent's latest state", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
yield* admit(session, "First")
const first = yield* admit(session, "First")
yield* session.resume(sessionID)
systemBaseline = "Changed context"
const second = yield* admit(session, "Second")
yield* session.resume(sessionID)
systemBaseline = "Latest context"
yield* admit(session, "Third")
yield* session.resume(sessionID)
const forked = yield* session.fork({ sessionID })
const parent = yield* db
.select()
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
expect(parent).toBeDefined()
const forked = yield* session.fork({ sessionID, messageID: second.id })
expect(
yield* db
yield* (yield* Database.Service).db
.select()
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, forked.id))
.get()
.pipe(Effect.orDie),
).toEqual({ ...parent!, session_id: forked.id })
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, forked.id))
.get(),
).toMatchObject({
initial_values: { "test/context": Instructions.hash("Initial context") },
current_values: { "test/context": Instructions.hash("Changed context") },
})
yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false })
yield* session.resume(forked.id)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
expect(systemTexts(requests.at(-1)!)).toContain("Latest context")
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const recorded = yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, forked.id))
.orderBy(asc(EventTable.seq))
.all()
yield* events.remove(forked.id)
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run()
yield* events.replayAll(
recorded.map((event) => ({
id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
})),
)
expect(
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, forked.id)).get(),
).toMatchObject({ current_values: { "test/context": Instructions.hash("Latest context") } })
}),
)
it.effect("heals an undecodable stored applied record by re-announcing context", () =>
it.effect("caps nested fork instruction ancestry at the selected message", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "First")
yield* session.resume(sessionID)
systemBaseline = "Changed context"
const second = yield* admit(session, "Second")
yield* session.resume(sessionID)
const child = yield* session.fork({ sessionID, messageID: second.id })
const inheritedFirst = (yield* session.messages({ sessionID: child.id })).find(
(message) => message.type === "user" && message.text === "First",
)
if (!inheritedFirst) return yield* Effect.die(new Error("Nested fork boundary message not found"))
const grandchild = yield* session.fork({ sessionID: child.id, messageID: inheritedFirst.id })
expect(
yield* (yield* Database.Service).db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, grandchild.id))
.get(),
).toMatchObject({
initial_values: { "test/context": Instructions.hash("Initial context") },
current_values: { "test/context": Instructions.hash("Initial context") },
})
}),
)
it.effect("rebuilds a missing instruction cache without admitting another delta", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
yield* admit(session, "First")
yield* session.resume(sessionID)
yield* db
.update(InstructionCheckpointTable)
.set({ snapshot: { invalid: { value: "bad" } } })
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).run()
yield* admit(session, "Second")
requests.length = 0
yield* session.resume(sessionID)
// 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: InstructionCheckpointTable.snapshot })
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
expect(healed?.snapshot).toEqual({ "test/context": { value: "Initial context", removed: expect.any(String) } })
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "user"])
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.instructions.updated.2"))
.all(),
).toHaveLength(1)
expect(yield* db.select().from(InstructionStateTable).get()).toMatchObject({
initial_values: { "test/context": Instructions.hash("Initial context") },
current_values: { "test/context": Instructions.hash("Initial context") },
})
}),
)
it.effect("reuses one durable baseline after the context producer changes", () =>
it.effect("keeps the initial instructions stable and derives a chronological update from values", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "First")
@ -1062,18 +1112,26 @@ describe("SessionRunnerLLM", () => {
])
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)
expect(yield* session.messages({ sessionID })).toHaveLength(2)
const { db } = yield* Database.Service
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.instructions.updated.1"))
.all()
.pipe(Effect.orDie),
).toHaveLength(1)
const updates = yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.type, "session.instructions.updated.2"))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
expect(updates).toHaveLength(2)
expect(updates[0]?.data).toEqual({
sessionID,
delta: { "test/context": Instructions.hash("Initial context") },
})
expect(updates[1]?.data).toEqual({
sessionID,
delta: { "test/context": Instructions.hash("Changed context") },
})
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(3)
expect(yield* session.messages({ sessionID })).toHaveLength(2)
}),
)
@ -1160,7 +1218,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("uses only the agent prompt and durable baseline as system parts", () =>
it.effect("uses only the agent prompt and initial instructions as system parts", () =>
Effect.gen(function* () {
const session = yield* setup
const agent = yield* AgentV2.Service
@ -1350,11 +1408,11 @@ describe("SessionRunnerLLM", () => {
expect(requests[1]?.messages.at(1)?.content).toEqual([
{ type: "text", text: "System context source removed: test/context" },
])
expect(yield* session.messages({ sessionID })).toHaveLength(3)
expect(yield* session.messages({ sessionID })).toHaveLength(2)
}),
)
it.effect("renders API context entries through the belief lifecycle", () =>
it.effect("renders API context entries through add, change, and removal", () =>
Effect.gen(function* () {
const session = yield* setup
const contextEntries = yield* InstructionEntry.Service
@ -1363,7 +1421,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
// String values render verbatim inside the tagged block at baseline.
// String values render verbatim inside the initial tagged block.
expect(requests[0]?.system.map((part) => part.text)).toEqual([
defaultSystem,
["Initial context", "", '<context key="deploy-target">', "production", "</context>"].join("\n"),
@ -1403,7 +1461,49 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("keeps the baseline and chronological System updates after a model switch", () =>
it.effect("retains JSON null API entries as values", () =>
Effect.gen(function* () {
const session = yield* setup
const entries = yield* InstructionEntry.Service
yield* entries.put({ sessionID, key: "nullable", value: "present" })
yield* admit(session, "First")
yield* session.resume(sessionID)
yield* entries.put({ sessionID, key: "nullable", value: null })
yield* admit(session, "Second")
yield* session.resume(sessionID)
expect(requests[1]?.messages.at(1)?.content).toEqual([
{
type: "text",
text: [
'The context under "nullable" changed and supersedes the previous value:',
'<context key="nullable">',
"null",
"</context>",
].join("\n"),
},
])
expect(yield* entries.list(sessionID)).toEqual([{ key: "nullable", value: null }])
}),
)
it.effect("rejects API instruction entries larger than 8KB", () =>
Effect.gen(function* () {
yield* setup
const entries = yield* InstructionEntry.Service
const exit = yield* entries
.put({ sessionID, key: "oversized", value: "x".repeat(InstructionEntry.MaxValueBytes) })
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(InstructionEntry.ValueTooLargeError)
expect(yield* entries.list(sessionID)).toEqual([])
}),
)
it.effect("keeps initial instructions and chronological updates after a model switch", () =>
Effect.gen(function* () {
const session = yield* setup
const events = yield* EventV2.Service
@ -1430,20 +1530,18 @@ describe("SessionRunnerLLM", () => {
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
"user",
"system",
"user",
"model-switched",
"system",
"user",
])
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(6)
expect(yield* session.messages({ sessionID })).toHaveLength(4)
yield* admit(session, "Fourth")
yield* session.resume(sessionID)
}),
)
it.effect("preserves the baseline while context is temporarily unavailable", () =>
it.effect("preserves instruction values while a source is temporarily unavailable", () =>
Effect.gen(function* () {
const session = yield* setup
const events = yield* EventV2.Service
@ -1470,7 +1568,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("rebuilds the baseline directly after completed compaction", () =>
it.effect("moves the epoch at compaction and narrates later changes", () =>
Effect.gen(function* () {
const session = yield* setup
const events = yield* EventV2.Service
@ -1494,8 +1592,10 @@ describe("SessionRunnerLLM", () => {
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, "Initial context"],
[defaultSystem, "Replacement context"],
[defaultSystem, "Initial context"],
])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Replacement context" }])
yield* replaySessionProjection(sessionID)
yield* admit(session, "Third")
yield* session.resume(sessionID)
@ -1953,7 +2053,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("rebaselines after compaction from the last-applied belief while unobservable", () =>
it.effect("uses epoch values after compaction while a source is unavailable", () =>
Effect.gen(function* () {
const session = yield* setup
const events = yield* EventV2.Service
@ -1978,7 +2078,7 @@ describe("SessionRunnerLLM", () => {
yield* admit(session, "Third")
yield* session.resume(sessionID)
// The rebaseline proceeds while the source is unobservable, restating the model's belief.
// Compaction already moved current values into the new epoch before the unavailable read.
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
expect(systemTexts(requests.at(-1)!)).not.toContain("Changed context")
}),
@ -2861,7 +2961,7 @@ describe("SessionRunnerLLM", () => {
})
yield* (yield* SessionExecution.Service).wake(sessionID)
yield* Effect.yieldNow
while (requests.length === 0) yield* Effect.yieldNow
expect(requests).toHaveLength(1)
expect(userTexts(requests[0]!)).toEqual(["Wait in queue"])

View file

@ -5,9 +5,9 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Instructions } from "@opencode-ai/core/instructions"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
const build = AgentV2.ID.make("build")
const effect = SkillV2.Info.make({
@ -45,7 +45,7 @@ const layer = (list: () => SkillV2.Info[]) =>
])
describe("SkillGuidance", () => {
it.effect("renders described agent skills and reconciles the complete available list", () => {
it.effect("renders described agent skills and updates the complete available list", () => {
const agent = AgentV2.Info.make({
...AgentV2.Info.empty(build),
permissions: [{ action: "skill", resource: "denied", effect: "deny" }],
@ -53,9 +53,7 @@ describe("SkillGuidance", () => {
let skills = [hidden, denied, manual, effect]
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
const initialized = yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap(Instructions.initialize))
const initialized = yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
expect(initialized.text).toBe(
[
@ -76,11 +74,8 @@ describe("SkillGuidance", () => {
expect(
yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
).toMatchObject({
_tag: "Updated",
text: "The following skill IDs are no longer available and must not be used: effect.",
})
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
).toMatchObject({ text: "Skill guidance is no longer available. Do not use any previously listed skill." })
}).pipe(Effect.provide(layer(() => skills)))
})
@ -96,17 +91,14 @@ describe("SkillGuidance", () => {
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(Instructions.initialize))
const initialized = yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
skills = [effect, debugging]
const added = yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied)))
expect(added).toMatchObject({
_tag: "Updated",
text: [
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
expect(added.text).toBe(
[
"New skills are available in addition to those previously listed:",
" <skill>",
" <id>debugging</id>",
@ -114,18 +106,13 @@ describe("SkillGuidance", () => {
" <description>Diagnose hard bugs</description>",
" </skill>",
].join("\n"),
})
)
skills = [debugging]
const removed = yield* guidance
.load({ id: agent.id, info: agent })
.pipe(
Effect.flatMap((context) => Instructions.reconcile(context, added._tag === "Updated" ? added.applied : {})),
)
expect(removed).toMatchObject({
_tag: "Updated",
text: "The following skill IDs are no longer available and must not be used: effect.",
})
.pipe(Effect.flatMap((context) => readUpdate(context, added)))
expect(removed.text).toBe("The following skill IDs are no longer available and must not be used: effect.")
}).pipe(Effect.provide(layer(() => skills)))
})
@ -134,17 +121,14 @@ describe("SkillGuidance", () => {
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(Instructions.initialize))
const initialized = yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })]
expect(
yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
).toMatchObject({
_tag: "Updated",
text: expect.stringContaining(
"The available skills have changed. This list supersedes the previous available skills list.",
),
@ -159,12 +143,7 @@ describe("SkillGuidance", () => {
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
{
text: "",
applied: {},
},
)
expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
}).pipe(Effect.provide(layer(() => [effect])))
})
@ -178,12 +157,7 @@ describe("SkillGuidance", () => {
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
{
text: "",
applied: {},
},
)
expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
}).pipe(Effect.provide(layer(() => [effect])))
})
@ -197,9 +171,9 @@ describe("SkillGuidance", () => {
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text,
).toContain("<name>Effect</name>")
expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toContain(
"<name>Effect</name>",
)
}).pipe(Effect.provide(layer(() => [effect])))
})
@ -214,12 +188,7 @@ describe("SkillGuidance", () => {
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
{
text: "",
applied: {},
},
)
expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
}).pipe(Effect.provide(layer(() => [effect])))
})
})