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

@ -1,8 +1,10 @@
{
"version": "7",
"dialect": "sqlite",
"id": "2c759c08-79c8-4179-9e15-d2fea45c9dec",
"prevIds": ["01451b27-1e51-4657-b2d0-4b457dffa3ec"],
"id": "5f0a1db8-d4bf-42c3-becb-96b46fe66bed",
"prevIds": [
"666138ef-82cb-4a9a-a765-e6669a436ff3"
],
"ddl": [
{
"name": "workspace",
@ -49,13 +51,17 @@
"entityType": "tables"
},
{
"name": "instruction_checkpoint",
"name": "instruction_blob",
"entityType": "tables"
},
{
"name": "instruction_entry",
"entityType": "tables"
},
{
"name": "instruction_state",
"entityType": "tables"
},
{
"name": "message",
"entityType": "tables"
@ -786,39 +792,19 @@
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"name": "hash",
"entityType": "columns",
"table": "instruction_checkpoint"
"table": "instruction_blob"
},
{
"type": "text",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "baseline",
"name": "value",
"entityType": "columns",
"table": "instruction_checkpoint"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "snapshot",
"entityType": "columns",
"table": "instruction_checkpoint"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "baseline_seq",
"entityType": "columns",
"table": "instruction_checkpoint"
"table": "instruction_blob"
},
{
"type": "text",
@ -842,7 +828,7 @@
},
{
"type": "text",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@ -850,6 +836,16 @@
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "removed",
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "integer",
"notNull": true,
@ -870,6 +866,56 @@
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "epoch_start",
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "through_seq",
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "initial_values",
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "current_values",
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "text",
"notNull": false,
@ -1180,6 +1226,16 @@
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "fork_seq",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
@ -1501,9 +1557,13 @@
"table": "session_share"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1512,9 +1572,13 @@
"table": "workspace"
},
{
"columns": ["active_account_id"],
"columns": [
"active_account_id"
],
"tableTo": "account",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@ -1523,9 +1587,13 @@
"table": "account_state"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"tableTo": "event_sequence",
"columnsTo": ["aggregate_id"],
"columnsTo": [
"aggregate_id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1534,9 +1602,13 @@
"table": "event"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1545,9 +1617,13 @@
"table": "permission"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1556,20 +1632,13 @@
"table": "project_directory"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_instruction_checkpoint_session_id_session_id_fk",
"entityType": "fks",
"table": "instruction_checkpoint"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1578,9 +1647,28 @@
"table": "instruction_entry"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_instruction_state_session_id_session_id_fk",
"entityType": "fks",
"table": "instruction_state"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1589,9 +1677,13 @@
"table": "message"
},
{
"columns": ["message_id"],
"columns": [
"message_id"
],
"tableTo": "message",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1600,9 +1692,13 @@
"table": "part"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1611,9 +1707,13 @@
"table": "session_message"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1622,9 +1722,13 @@
"table": "session_pending"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1633,9 +1737,13 @@
"table": "session"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1644,133 +1752,183 @@
"table": "session_share"
},
{
"columns": ["email", "url"],
"columns": [
"email",
"url"
],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": ["project_id", "directory"],
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": ["session_id", "key"],
"columns": [
"session_id",
"key"
],
"nameExplicit": false,
"name": "instruction_entry_pk",
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": ["name"],
"columns": [
"name"
],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"hash"
],
"nameExplicit": false,
"name": "instruction_checkpoint_pk",
"table": "instruction_checkpoint",
"name": "instruction_blob_pk",
"table": "instruction_blob",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "instruction_state_pk",
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_input_pk",
"table": "session_pending",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
@ -2079,5 +2237,5 @@
"table": "session"
}
],
"renames": ["session_input->session_pending"]
}
"renames": []
}

View file

@ -8,6 +8,7 @@ import { Location } from "../location"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionEvent } from "../session/event"
import { SessionExecution } from "../session/execution"
import { SessionSchema } from "../session/schema"
import { SessionStore } from "../session/store"
import { AbsolutePath, RelativePath } from "../schema"
@ -73,6 +74,7 @@ const layer = Layer.effect(
const events = yield* EventV2.Service
const project = yield* ProjectV2.Service
const sessions = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* sessions.get(input.sessionID)
@ -86,6 +88,12 @@ const layer = Layer.effect(
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
// A move must not race active execution: a mid-drain relocation would let
// the source Location dispatch a request assembled under stale instructions
// and history. Serialize like removal does — stop the drain, then move.
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const moveChanges = input.moveChanges && source.directory !== destination.directory
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
if (moveChanges && !sourceRepository)
@ -143,5 +151,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, SessionExecution.node],
})

View file

@ -53,5 +53,6 @@ export const migrations = (
import("./migration/20260709025533_drop-todo"),
import("./migration/20260709163752_time_suspended"),
import("./migration/20260709190621_session_pending_table"),
import("./migration/20260710025429_instruction_sync"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,86 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260710025429_instruction_sync",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_seq\` integer;`)
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_instruction_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text,
\`removed\` integer DEFAULT false NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
INSERT INTO \`__new_instruction_entry\`(
\`session_id\`, \`key\`, \`value\`, \`removed\`, \`time_created\`, \`time_updated\`
)
SELECT \`session_id\`, \`key\`, \`value\`, false, \`time_created\`, \`time_updated\`
FROM \`instruction_entry\`;
`)
yield* tx.run(`DROP TABLE \`instruction_entry\`;`)
yield* tx.run(`ALTER TABLE \`__new_instruction_entry\` RENAME TO \`instruction_entry\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(`
CREATE TABLE \`instruction_blob\` (
\`hash\` text PRIMARY KEY,
\`value\` text
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_state\` (
\`session_id\` text PRIMARY KEY,
\`epoch_start\` integer NOT NULL,
\`through_seq\` integer NOT NULL,
\`initial_values\` text NOT NULL,
\`current_values\` text NOT NULL,
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
// Persisted System rows were exclusively pre-beta instruction prose,
// including fork copies whose message IDs no longer match the source event.
yield* tx.run(`DELETE FROM \`session_message\` WHERE \`type\` = 'system';`)
yield* tx.run(`
UPDATE \`session\`
SET \`fork_seq\` = COALESCE(
(
SELECT MIN(\`seq\`) - 1
FROM \`event\`
WHERE \`aggregate_id\` = \`session\`.\`id\` AND \`seq\` > 0
),
(
SELECT \`seq\`
FROM \`event_sequence\`
WHERE \`aggregate_id\` = \`session\`.\`id\`
),
0
)
WHERE \`fork_session_id\` IS NOT NULL;
`)
yield* tx.run(`
UPDATE \`event\`
SET
\`type\` = 'session.forked.2',
\`data\` = json_set(
\`data\`,
'$.parentSeq',
COALESCE(
(SELECT \`fork_seq\` FROM \`session\` WHERE \`id\` = \`event\`.\`aggregate_id\`),
0
)
)
WHERE \`type\` = 'session.forked.1';
`)
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.instructions.updated.1';`)
yield* tx.run(`DROP TABLE \`instruction_checkpoint\`;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -126,25 +126,33 @@ export default {
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_checkpoint\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
CONSTRAINT \`fk_instruction_checkpoint_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
CREATE TABLE \`instruction_blob\` (
\`hash\` text PRIMARY KEY,
\`value\` text
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text NOT NULL,
\`value\` text,
\`removed\` integer DEFAULT false NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_state\` (
\`session_id\` text PRIMARY KEY,
\`epoch_start\` integer NOT NULL,
\`through_seq\` integer NOT NULL,
\`initial_values\` text NOT NULL,
\`current_values\` text NOT NULL,
CONSTRAINT \`fk_instruction_state_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`message\` (
\`id\` text PRIMARY KEY,
@ -198,6 +206,7 @@ export default {
\`parent_id\` text,
\`fork_session_id\` text,
\`fork_message_id\` text,
\`fork_seq\` integer,
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,

View file

@ -137,7 +137,8 @@ export interface Interface {
) => Effect.Effect<Payload<D>>
readonly subscribe: Subscribe
/**
* Durable, ordered, gap-free per-aggregate log read. `follow: false`
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
* inherited prefix before their first child-authored event. `follow: false`
* completes at the end of the log; `follow: true` replays then transitions
* to live. Both modes emit one `Synced` marker at the captured replay
* watermark.
@ -203,7 +204,6 @@ export const layerWith = (options?: LayerOptions) =>
typed: new Map<string, PubSub.PubSub<Payload>>(),
}
const projectors = new Map<string, Subscriber[]>()
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
@ -260,7 +260,7 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
}
const list = projectors.get(event.type) ?? []
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
@ -515,18 +515,6 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
}
const start = events[0]?.seq ?? 0
for (const [index, event] of events.entries()) {
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
)
}
}
for (const event of events) {
yield* replay(event, options)
}
@ -727,9 +715,10 @@ export const layerWith = (options?: LayerOptions) =>
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
Effect.sync(() => {
const list = projectors.get(definition.type) ?? []
const key = definition.durable ? versionedType(definition.type, definition.durable.version) : definition.type
const list = projectors.get(key) ?? []
list.push((event) => projector(event as Payload<D>))
projectors.set(definition.type, list)
projectors.set(key, list)
})
return Service.of({

View file

@ -31,15 +31,17 @@ const layer = Layer.effect(
const global = yield* Global.Service
const location = yield* Location.Service
const source = (value: ReadonlyArray<File> | Instructions.Unavailable) =>
Instructions.make({
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
Instructions.make<ReadonlyArray<File>>({
key,
codec: Schema.toCodecJson(Files),
load: Effect.succeed(value),
baseline: render,
update: (_previous, current) =>
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
removed: () => "Previously loaded instructions no longer apply.",
read: Effect.succeed(value),
render: {
initial: render,
changed: (_previous, current) =>
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
removed: () => "Previously loaded instructions no longer apply.",
},
})
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
@ -82,11 +84,7 @@ const layer = Layer.effect(
load: () =>
observe().pipe(
Effect.map((files) =>
files === Instructions.unavailable
? source(files)
: files.length === 0
? Instructions.empty
: source(files),
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
),
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),

View file

@ -15,29 +15,34 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const environment = [
"<env>",
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
"</env>",
].join("\n")
const instructions = Instructions.combine([
Instructions.make({
key: Instructions.Key.make("core/environment"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(environment),
baseline: (environment) =>
["Here is some useful information about the environment you are running in:", environment].join("\n"),
update: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"),
read: Effect.sync(() =>
[
"<env>",
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
"</env>",
].join("\n"),
),
render: {
initial: (environment) =>
["Here is some useful information about the environment you are running in:", environment].join("\n"),
changed: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"),
},
}),
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
baseline: (date) => `Today's date: ${date}`,
update: (_previous, date) => `Today's date is now: ${date}`,
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
render: {
initial: (date) => `Today's date: ${date}`,
changed: (_previous, date) => `Today's date is now: ${date}`,
},
}),
])

View file

@ -1,80 +1,70 @@
export * as Instructions from "./index"
import { Effect, Option, Schema } from "effect"
import { createHash } from "crypto"
import { Instruction } from "@opencode-ai/schema/instruction"
import { Data, Effect, Option, Schema } from "effect"
/**
* Models privileged instructions as independently refreshable typed sources.
*
* `Source<A>` describes how to observe, compare, and render one value. `make`
* closes over `A`, producing opaque `Instructions` that compose uniformly with
* instructions built from other value types.
*
* The durable `Applied` record tracks what the model was last told, per source:
* it is the model's current belief. Interpreters uphold one invariant
* `reconcile` never rewrites the baseline; it only narrates drift as update
* text. Only `rebaseline` (compaction) and `initialize` (first step) produce
* baseline text.
*
* Returning `unavailable` means observation failed temporarily. It differs from
* removing a source from the instructions: the model's prior belief stands.
* `reconcile` retains the applied value silently, and `rebaseline` restates the
* belief by rendering the last-applied value instead of a live observation.
*
* @module
*/
export const Key = Instruction.Key
export type Key = Instruction.Key
export const Hash = Instruction.Hash
export type Hash = Instruction.Hash
export const Values = Instruction.Values
export type Values = Instruction.Values
export const Delta = Instruction.Delta
export type Delta = Instruction.Delta
/** Stable namespaced identity for one independently refreshable instruction source. */
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe(
Schema.brand("Instructions.Key"),
)
export type Key = typeof Key.Type
type NonValue = Data.TaggedEnum<{ Unavailable: {}; Removed: {} }>
const NonValue = Data.taggedEnum<NonValue>()
/** Indicates that a source could not be observed without treating it as removed. */
export const unavailable = Symbol.for("@opencode/Instructions.Unavailable")
/** The read failed temporarily; the stored value stands. */
export const unavailable = NonValue.Unavailable()
export type Unavailable = typeof unavailable
/** Defines one typed source before its value type is hidden by `make`. */
export interface Source<A> {
/** An observed absence: the source exists but its value is gone. */
export const removed = NonValue.Removed()
export type Removed = typeof removed
/**
* One composable instruction source over canonical JSON the same
* representation that is hashed, stored, and replayed. `make` builds one from
* a typed definition; renderers returning `undefined` skip (undecodable or
* unrenderable historical values).
*/
export interface Source {
readonly key: Key
readonly codec: Schema.Codec<A, Schema.Json, never, never>
readonly load: Effect.Effect<A | Unavailable>
readonly baseline: (current: A) => string
readonly update: (previous: A, current: A) => string
readonly removed?: (previous: A) => string
readonly read: Effect.Effect<Schema.Json | Unavailable | Removed>
readonly initial: (value: Schema.Json) => string | undefined
readonly changed: (previous: Schema.Json, current: Schema.Json) => string | undefined
readonly removed: (previous: Schema.Json) => string | undefined
}
const InstructionsTypeId: unique symbol = Symbol.for("@opencode/Instructions")
/** Opaque carrier for composable instruction sources. */
export interface Instructions {
readonly [InstructionsTypeId]: ReadonlyArray<PackedSource>
export declare namespace Source {
/** The typed definition supplied when constructing a source. */
export interface Definition<A> {
readonly key: Key
readonly codec: Schema.Codec<A, Schema.Json>
readonly read: Effect.Effect<A | Unavailable | Removed>
readonly render: {
readonly initial: (current: A) => string
readonly changed: (previous: A, current: A) => string
readonly removed?: (previous: A) => string
}
}
}
/** The value last applied to the model for one admitted source. */
export const AppliedSource = Schema.Struct({
value: Schema.Json,
removed: Schema.optional(Schema.NonEmptyString),
})
export type AppliedSource = typeof AppliedSource.Type
/** Ordered sources; identical values render identical bytes. */
export type Instructions = ReadonlyArray<Source>
/** Durable record of what the model currently believes, per source. */
export const Applied = Schema.Record(Key, AppliedSource)
export type Applied = Readonly<Record<string, AppliedSource>>
export type ReadResult = ReadonlyArray<{
readonly key: Key
readonly value: Schema.Json | Unavailable | Removed
}>
/** A rendered baseline together with the applied values it was rendered from. */
export interface Baseline {
readonly text: string
readonly applied: Applied
export interface Admission {
readonly delta: Delta
readonly blobs: Readonly<Record<string, Schema.Json>>
}
export interface Updated {
readonly _tag: "Updated"
readonly text: string
readonly applied: Applied
}
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
"Instructions.InitializationBlocked",
{ keys: Schema.Array(Key) },
@ -92,71 +82,140 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
}
}
interface PackedSource {
readonly key: Key
readonly load: Effect.Effect<Observed | Unavailable>
/** Restates the model's belief from a last-applied value when the source cannot be observed. */
readonly recall: (stored: AppliedSource) => string | undefined
}
export const empty: Instructions = []
interface Observed {
readonly applied: AppliedSource
readonly baseline: () => string
/** `undefined` means unchanged. An undecodable previous value re-renders the baseline (treat-as-new). */
readonly update: (previous: AppliedSource) => string | undefined
}
interface Entry {
readonly key: Key
readonly recall: PackedSource["recall"]
readonly observed: Observed | Unavailable
}
/** The identity instruction set. */
export const empty = instructions([])
/** Closes a typed source into instructions that compose with differently typed sources. */
export function make<A>(source: Source<A>): Instructions {
/** Closes a typed definition into one `Source`, so differently typed sources compose. */
export function make<A>(source: Source.Definition<A>): Instructions {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const equivalent = Schema.toEquivalence(source.codec)
const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value))
return instructions([
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
const decodeValue = (value: Schema.Json) => Option.getOrUndefined(decode(value))
return [
{
key: source.key,
recall: (stored) =>
Option.match(decode(stored.value), {
onNone: () => undefined,
onSome: baseline,
}),
load: source.load.pipe(
read: source.read.pipe(
Effect.map((value) => {
if (isUnavailable(value)) return value
return {
applied: {
value: encode(value),
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
},
baseline: () => baseline(value),
update: (previous) =>
Option.match(decode(previous.value), {
onNone: () => baseline(value),
onSome: (decoded) =>
equivalent(decoded, value)
? undefined
: requireText(source.key, "update", source.update(decoded, value)),
}),
} satisfies Observed
if (isUnavailable(value)) return unavailable
if (isRemoved(value)) return removed
return encode(value)
}),
),
initial: (value) => {
const decoded = decodeValue(value)
return decoded === undefined ? undefined : initial(decoded)
},
changed: (previous, current) => {
const before = decodeValue(previous)
const after = decodeValue(current)
if (after === undefined) return undefined
if (before === undefined) return initial(after)
return requireText(source.key, "changed", source.render.changed(before, after))
},
removed: (previous) => {
const decoded = decodeValue(previous)
return decoded === undefined || source.render.removed === undefined
? undefined
: requireText(source.key, "removed", source.render.removed(decoded))
},
},
])
]
}
export function combine(values: ReadonlyArray<Instructions>): Instructions {
const sources = values.flat()
const keys = new Set<Key>()
for (const source of sources) {
if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key })
keys.add(source.key)
}
return sources
}
export function read(value: Instructions): Effect.Effect<ReadResult> {
return Effect.forEach(
value,
(source) => source.read.pipe(Effect.map((observed) => ({ key: source.key, value: observed }))),
{ concurrency: "unbounded" },
)
}
export function diff(observed: ReadResult, previous?: Values): Effect.Effect<Admission, InitializationBlocked> {
const blocked = previous ? [] : observed.flatMap((entry) => (isUnavailable(entry.value) ? [entry.key] : []))
if (blocked.length > 0) return Effect.fail(new InitializationBlocked({ keys: blocked }))
const delta: Record<string, Hash | Instruction.Removed> = {}
const blobs: Record<string, Schema.Json> = {}
for (const entry of observed) {
if (isUnavailable(entry.value)) continue
if (isRemoved(entry.value)) {
if (previous && Object.hasOwn(previous, entry.key)) delta[entry.key] = Instruction.removed
continue
}
const next = hash(entry.value)
if (previous?.[entry.key] === next) continue
delta[entry.key] = next
blobs[next] = entry.value
}
return Effect.succeed({ delta, blobs })
}
export function renderInitial(value: Instructions, values: Readonly<Record<string, Schema.Json>>) {
return render(
value.flatMap((source) => {
if (!Object.hasOwn(values, source.key)) return []
const text = source.initial(values[source.key])
return text === undefined ? [] : [text]
}),
)
}
export function renderUpdate(
value: Instructions,
previous: Readonly<Record<string, Schema.Json>>,
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
) {
return render(
value.flatMap((source) => {
if (!Object.hasOwn(delta, source.key)) return []
const current = delta[source.key]
if (Option.isNone(current)) {
if (!Object.hasOwn(previous, source.key)) return []
const text = source.removed(previous[source.key])
return text === undefined ? [] : [text]
}
const next = current.value
const text = Object.hasOwn(previous, source.key)
? source.changed(previous[source.key], next)
: source.initial(next)
return text === undefined ? [] : [text]
}),
)
}
export function hash(value: Schema.Json) {
return Hash.make(createHash("sha256").update(canonical(value)).digest("hex"))
}
export function applyDelta(
values: Readonly<Record<string, Schema.Json>>,
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
): Readonly<Record<string, Schema.Json>> {
const result: Record<string, Schema.Json> = { ...values }
for (const [key, value] of Object.entries(delta)) {
if (Option.isNone(value)) delete result[key]
else result[key] = value.value
}
return result
}
export function applyHashDelta(values: Values, delta: Delta): Values {
const result: Record<string, Hash> = { ...values }
for (const [key, value] of Object.entries(delta)) {
if (value === Instruction.removed) delete result[key]
else result[key] = value
}
return result
}
/**
* Keyed three-way diff for list-shaped sources rendering delta updates.
* `changed` compares two values sharing a key; entries equal under it are dropped.
*/
export function diffByKey<A>(
previous: ReadonlyArray<A>,
current: ReadonlyArray<A>,
@ -179,129 +238,31 @@ export function diffByKey<A>(
}
}
/** Combines instructions in order and rejects duplicate source keys immediately. */
export function combine(values: ReadonlyArray<Instructions>): Instructions {
const sources = values.flatMap((value) => value[InstructionsTypeId])
assertUniqueKeys(sources)
return instructions(sources)
}
const observe = (value: Instructions) =>
Effect.forEach(
value[InstructionsTypeId],
(source) =>
source.load.pipe(Effect.map((observed): Entry => ({ key: source.key, recall: source.recall, observed }))),
{ concurrency: "unbounded" },
)
/** Creates the first baseline. Blocks rather than admit a baseline missing an unobservable source. */
export function initialize(value: Instructions): Effect.Effect<Baseline, InitializationBlocked> {
return observe(value).pipe(
Effect.flatMap((entries) => {
const blocked = entries.flatMap((entry) => (entry.observed === unavailable ? [entry.key] : []))
if (blocked.length > 0) return new InitializationBlocked({ keys: blocked })
const parts: string[] = []
const applied: Record<string, AppliedSource> = {}
for (const entry of entries) {
if (entry.observed === unavailable) continue
parts.push(entry.observed.baseline())
applied[entry.key] = entry.observed.applied
}
return Effect.succeed({ text: render(parts), applied })
}),
)
}
/** Narrates drift between current source values and the model's beliefs. Never rewrites the baseline. */
export function reconcile(value: Instructions, previous: Applied): Effect.Effect<ReconcileResult> {
return observe(value).pipe(
Effect.map((entries): ReconcileResult => {
const updates: string[] = []
const applied: Record<string, AppliedSource> = {}
for (const entry of entries) {
const stored = get(previous, entry.key)
if (entry.observed === unavailable) {
// The prior belief stands while the source cannot be observed.
if (stored) applied[entry.key] = stored
continue
}
if (!stored) {
updates.push(entry.observed.baseline())
applied[entry.key] = entry.observed.applied
continue
}
const text = entry.observed.update(stored)
if (text === undefined) {
applied[entry.key] = stored
continue
}
updates.push(text)
applied[entry.key] = entry.observed.applied
}
const keys = new Set<string>(entries.map((entry) => entry.key))
for (const key of Object.keys(previous).sort()) {
if (keys.has(key)) continue
const removed = previous[key].removed
// An unannounced removal retains the belief; it clears at the next rebaseline.
if (removed === undefined) applied[key] = previous[key]
else updates.push(removed)
}
if (updates.length === 0) return { _tag: "Unchanged" }
return { _tag: "Updated", text: render(updates), applied }
}),
)
}
/** Rebuilds the baseline, restating unobservable sources from the model's last-applied beliefs. */
export function rebaseline(value: Instructions, previous: Applied): Effect.Effect<Baseline> {
return observe(value).pipe(
Effect.map((entries): Baseline => {
const parts: string[] = []
const applied: Record<string, AppliedSource> = {}
for (const entry of entries) {
if (entry.observed !== unavailable) {
parts.push(entry.observed.baseline())
applied[entry.key] = entry.observed.applied
continue
}
const stored = get(previous, entry.key)
if (!stored) continue
const text = entry.recall(stored)
// An undecodable belief cannot be restated; the source re-announces when observable again.
if (text === undefined) continue
parts.push(text)
applied[entry.key] = stored
}
return { text: render(parts), applied }
}),
)
}
function instructions(sources: ReadonlyArray<PackedSource>): Instructions {
return { [InstructionsTypeId]: sources }
}
function render(parts: ReadonlyArray<string>) {
return parts.join("\n\n")
}
function get(applied: Applied, key: Key) {
return Object.hasOwn(applied, key) ? applied[key] : undefined
}
// Reference-equality guards: `A` in a typed source may itself be JSON shaped
// like these singletons, so identity, never structure, discriminates.
function isUnavailable(value: unknown): value is Unavailable {
return value === unavailable
}
function isRemoved(value: unknown): value is Removed {
return value === removed
}
function canonical(value: Schema.Json): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
if (value !== null && typeof value === "object")
return `{${Object.entries(value)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
.join(",")}}`
return JSON.stringify(value)
}
function requireText(key: Key, kind: string, text: string) {
if (text.length === 0) throw new Error(`Instruction source ${key} rendered an empty ${kind}`)
return text
}
function assertUniqueKeys(sources: ReadonlyArray<PackedSource>) {
const keys = new Set<Key>()
for (const source of sources) {
if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key })
keys.add(source.key)
}
}

View file

@ -70,8 +70,19 @@ export const layer = Layer.effect(
load: Effect.fn("McpGuidance.load")(function* (selection) {
const agent = selection.info
if (!agent) return Instructions.empty
const source = (value: ReadonlyArray<Summary> | Instructions.Removed) =>
Instructions.make<ReadonlyArray<Summary>>({
key: Instructions.Key.make("core/mcp-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
read: Effect.succeed(value),
render: {
initial: render,
changed: update,
removed: () => "MCP server instructions are no longer available.",
},
})
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
return Instructions.empty
return source(Instructions.removed)
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
})
@ -88,15 +99,8 @@ export const layer = Layer.effect(
)
})
.map((item) => ({ server: item.server, instructions: item.instructions }))
if (visible.length === 0) return Instructions.empty
return Instructions.make({
key: Instructions.Key.make("core/mcp-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(visible),
baseline: render,
update,
removed: () => "MCP server instructions are no longer available.",
})
.toSorted((a, b) => a.server.localeCompare(b.server))
return source(visible.length === 0 ? Instructions.removed : visible)
}),
})
}),

View file

@ -74,14 +74,16 @@ const layer = Layer.effect(
description: reference.description,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
if (available.length === 0) return Instructions.empty
return Instructions.make({
return Instructions.make<ReadonlyArray<typeof Summary.Type>>({
key: Instructions.Key.make("core/reference-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(available),
baseline: render,
update,
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
read: Effect.succeed(available.length === 0 ? Instructions.removed : available),
render: {
initial: render,
changed: update,
removed: () =>
"Project reference guidance is no longer available. Do not use previously listed references.",
},
})
}),
})

View file

@ -194,7 +194,7 @@ export interface Interface {
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
/**
* Durable, ordered, gap-free session log read. Replays public durable
* Durable, ordered session log read. Replays public durable
* session events after the exclusive `after` cursor, emits a `Synced`
* marker at the captured replay watermark, then continues live when `follow`
* is set.
@ -384,9 +384,11 @@ const layer = Layer.effect(
if (input.messageID && !boundary)
return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID })
const sessionID = SessionSchema.ID.create()
const parentSeq = boundary ? boundary.seq - 1 : yield* EventV2.latestSequence(db, parent.id)
yield* events.publish(SessionEvent.Forked, {
sessionID,
parentID: parent.id,
parentSeq,
from: input.messageID,
})
return yield* result.get(sessionID).pipe(Effect.orDie)

View file

@ -159,7 +159,7 @@ const select = (
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = messages
.filter((message) => message.type !== "compaction")
.filter((message) => message.type !== "compaction" && message.type !== "system")
.flatMap((message) => {
const text = serialize(message)
return text ? [{ message, text }] : []

View file

@ -1,10 +1,12 @@
import { and, asc, desc, eq, gt, gte, ne, or, sql } from "drizzle-orm"
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { InstructionCheckpointTable, SessionMessageTable } from "./sql"
import { Instructions } from "../instructions/index"
import { InstructionState } from "./instruction-state"
import { SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
@ -31,7 +33,6 @@ const messageRows = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
compaction: { readonly seq: number } | undefined,
baselineSeq?: number,
) {
const rows = yield* db
.select()
@ -39,20 +40,7 @@ const messageRows = Effect.fnUntraced(function* (
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
// Keep system updates visible in the gap between a completed compaction
// and the next prepared step's rebaseline, when their content is not yet
// folded into a new baseline.
compaction
? or(
gte(SessionMessageTable.seq, compaction.seq),
baselineSeq === undefined
? undefined
: and(eq(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
)
: undefined,
baselineSeq === undefined
? undefined
: or(ne(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
),
)
.orderBy(asc(SessionMessageTable.seq))
@ -73,30 +61,32 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
)
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const [epoch, compaction] = yield* Effect.all(
[
db
.select({ baselineSeq: InstructionCheckpointTable.baseline_seq })
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
latestCompaction(db, sessionID),
],
{ concurrency: "unbounded" },
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)),
decodeMessageRow,
)
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
instructions: Instructions.Instructions,
) {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq)
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
return yield* db
.transaction(() =>
Effect.gen(function* () {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
const messages = yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
return {
initial: assembled.initial,
entries: [...messages, ...assembled.updates].toSorted((a, b) => a.seq - b.seq),
}
}),
)
.pipe(Effect.orDie)
})
/** Returns the session's sole user message, or `undefined` once a second one exists. */

View file

@ -1,130 +0,0 @@
export * as InstructionCheckpoint from "./instruction-checkpoint"
import { eq } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
import type { Database } from "../database/database"
import { EventV2 } from "../event"
import { Instructions } from "../instructions/index"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionSchema } from "./schema"
import { InstructionCheckpointTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decodeApplied = Schema.decodeUnknownOption(Instructions.Applied)
/**
* Loads or creates the session's durable instruction checkpoint, narrating any
* drift since the model was last told as a chronological update. Completed
* compaction rebaselines; nothing else rewrites the baseline. Runs before
* input promotion so a blocked first step leaves pending inputs untouched.
*/
export const prepare = Effect.fn("InstructionCheckpoint.prepare")(function* (
db: DatabaseService,
events: EventV2.Interface,
instructions: Effect.Effect<Instructions.Instructions>,
sessionID: SessionSchema.ID,
) {
const [value, stored, compaction] = yield* Effect.all(
[instructions, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
{ concurrency: "unbounded" },
)
if (!stored) {
const baseline = yield* Instructions.initialize(value)
const baselineSeq = yield* insert(db, sessionID, baseline)
return { baseline: baseline.text, baselineSeq }
}
// The applied record is comparison state only; an undecodable one heals by
// treating every source as new, re-announcing baselines as updates.
const applied = Option.getOrElse(decodeApplied(stored.snapshot), () => ({}))
if (compaction !== undefined && compaction.seq > stored.baseline_seq) {
const baseline = yield* Instructions.rebaseline(value, applied)
yield* rewrite(db, sessionID, compaction.seq, baseline)
return { baseline: baseline.text, baselineSeq: compaction.seq }
}
const result = yield* Instructions.reconcile(value, applied)
if (result._tag === "Unchanged") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
yield* events.publish(
SessionEvent.InstructionsUpdated,
{ sessionID, text: result.text },
{ commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) },
)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
})
export const reset = Effect.fn("InstructionCheckpoint.reset")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
yield* db
.delete(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
})
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
})
const insert = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baseline: Instructions.Baseline,
) {
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
yield* db
.insert(InstructionCheckpointTable)
.values({
session_id: sessionID,
baseline: baseline.text,
snapshot: baseline.applied,
baseline_seq: baselineSeq,
})
.run()
.pipe(Effect.orDie)
return baselineSeq
})
const rewrite = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
baseline: Instructions.Baseline,
) {
const updated = yield* db
.update(InstructionCheckpointTable)
.set({
baseline: baseline.text,
snapshot: baseline.applied,
baseline_seq: baselineSeq,
})
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.returning({ sessionID: InstructionCheckpointTable.session_id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new Error("Instruction checkpoint not found"))
})
const advance = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
applied: Instructions.Applied,
) {
const updated = yield* db
.update(InstructionCheckpointTable)
.set({ snapshot: applied })
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.returning({ sessionID: InstructionCheckpointTable.session_id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new Error("Instruction checkpoint not found"))
})

View file

@ -1,6 +1,6 @@
export * as InstructionEntry from "./instruction-entry"
import { and, asc, eq } from "drizzle-orm"
import { and, asc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Database } from "../database/database"
@ -13,6 +13,8 @@ export const Key = InstructionEntry.Key
export type Key = typeof Key.Type
export const Info = InstructionEntry.Info
export type Info = typeof Info.Type
export const MaxValueBytes = InstructionEntry.MaxValueBytes
export const ValueTooLargeError = InstructionEntry.ValueTooLargeError
export interface Interface {
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
@ -20,7 +22,7 @@ export interface Interface {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) => Effect.Effect<void>
}) => Effect.Effect<void, InstructionEntry.ValueTooLargeError>
readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect<void>
/** Produces one Instructions source per stored entry, keyed `api/<key>`. */
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.Instructions>
@ -35,18 +37,20 @@ const renderBlock = (key: Key, value: Schema.Json) =>
// Rendering stays mechanism-neutral: the model sees session context, not how
// it was attached. Only chronological updates and removals carry narration.
const source = (entry: Info) =>
Instructions.make({
const source = (entry: Info & { readonly removed: boolean }) =>
Instructions.make<Schema.Json>({
key: Instructions.Key.make(`api/${entry.key}`),
codec: Schema.toCodecJson(Schema.Json),
load: Effect.succeed(entry.value),
baseline: (value) => renderBlock(entry.key, value),
update: (_previous, value) =>
[
`The context under "${entry.key}" changed and supersedes the previous value:`,
renderBlock(entry.key, value),
].join("\n"),
removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`,
read: Effect.succeed(entry.removed ? Instructions.removed : entry.value),
render: {
initial: (value) => renderBlock(entry.key, value),
changed: (_previous, value) =>
[
`The context under "${entry.key}" changed and supersedes the previous value:`,
renderBlock(entry.key, value),
].join("\n"),
removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`,
},
})
const layer = Layer.effect(
@ -54,15 +58,27 @@ const layer = Layer.effect(
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("InstructionEntry.list")(function* (sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
const rows = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, includeRemoved: boolean) {
return yield* db
.select({
key: InstructionEntryTable.key,
value: InstructionEntryTable.value,
removed: InstructionEntryTable.removed,
})
.from(InstructionEntryTable)
.where(eq(InstructionEntryTable.session_id, sessionID))
.where(
and(
eq(InstructionEntryTable.session_id, sessionID),
includeRemoved ? undefined : eq(InstructionEntryTable.removed, false),
),
)
.orderBy(asc(InstructionEntryTable.key))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ key: row.key, value: row.value }))
})
const list = Effect.fn("InstructionEntry.list")(function* (sessionID: SessionSchema.ID) {
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("InstructionEntry.put")(function* (input: {
@ -70,12 +86,24 @@ const layer = Layer.effect(
readonly key: Key
readonly value: Schema.Json
}) {
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
if (actualBytes > MaxValueBytes)
yield* new ValueTooLargeError({
actualBytes,
maxBytes: MaxValueBytes,
message: `Instruction entry value is ${actualBytes} bytes; the limit is ${MaxValueBytes} bytes`,
})
const changed =
input.value === null
? isNotNull(InstructionEntryTable.value)
: or(isNull(InstructionEntryTable.value), ne(InstructionEntryTable.value, input.value))
yield* db
.insert(InstructionEntryTable)
.values({ session_id: input.sessionID, key: input.key, value: input.value })
.values({ session_id: input.sessionID, key: input.key, value: input.value, removed: false })
.onConflictDoUpdate({
target: [InstructionEntryTable.session_id, InstructionEntryTable.key],
set: { value: input.value, time_updated: Date.now() },
set: { value: input.value, removed: false, time_updated: Date.now() },
setWhere: or(eq(InstructionEntryTable.removed, true), changed),
})
.run()
.pipe(Effect.orDie)
@ -86,15 +114,21 @@ const layer = Layer.effect(
readonly key: Key
}) {
yield* db
.delete(InstructionEntryTable)
.where(and(eq(InstructionEntryTable.session_id, input.sessionID), eq(InstructionEntryTable.key, input.key)))
.update(InstructionEntryTable)
.set({ value: null, removed: true, time_updated: Date.now() })
.where(
and(
eq(InstructionEntryTable.session_id, input.sessionID),
eq(InstructionEntryTable.key, input.key),
eq(InstructionEntryTable.removed, false),
),
)
.run()
.pipe(Effect.orDie)
})
const load = Effect.fn("InstructionEntry.load")(function* (sessionID: SessionSchema.ID) {
const entries = yield* list(sessionID)
return Instructions.combine(entries.map(source))
return Instructions.combine((yield* rows(sessionID, true)).map(source))
})
return Service.of({ list, put, remove, load })

View file

@ -0,0 +1,338 @@
export * as InstructionState from "./instruction-state"
import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm"
import { DateTime, Effect, Option, Schema } from "effect"
import type { Database } from "../database/database"
import { EventV2 } from "../event"
import { EventTable } from "../event/sql"
import { Instructions } from "../instructions/index"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data)
export const prepare = Effect.fn("InstructionState.prepare")(function* (
db: DatabaseService,
events: EventV2.Interface,
instructions: Instructions.Instructions,
sessionID: SessionSchema.ID,
) {
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
concurrency: "unbounded",
})
const admission = yield* Instructions.diff(observed, stored?.current_values)
if (!stored || Object.keys(admission.delta).length > 0) {
yield* events.publish(
SessionEvent.InstructionsUpdated,
{ sessionID, delta: admission.delta },
{
commit: () => insertBlobs(db, admission.blobs),
},
)
}
})
export const apply = Effect.fn("InstructionState.apply")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
seq: number,
delta: Instructions.Delta,
) {
const stored = yield* find(db, sessionID)
const current = Instructions.applyHashDelta(stored?.current_values ?? {}, delta)
if (!stored) {
yield* db
.insert(InstructionStateTable)
.values({
session_id: sessionID,
epoch_start: seq,
through_seq: seq,
initial_values: current,
current_values: current,
})
.run()
.pipe(Effect.orDie)
return
}
yield* db
.update(InstructionStateTable)
.set({ through_seq: seq, current_values: current })
.where(eq(InstructionStateTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
})
export const advanceEpoch = Effect.fn("InstructionState.advanceEpoch")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
epochStart: number,
) {
yield* db
.update(InstructionStateTable)
.set({
epoch_start: epochStart,
through_seq: epochStart,
initial_values: sql`${InstructionStateTable.current_values}`,
})
.where(eq(InstructionStateTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
})
export const reset = Effect.fn("InstructionState.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
yield* db
.delete(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
})
export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const folded = fold(yield* instructionEvents(db, sessionID))
if (!folded) {
yield* reset(db, sessionID)
return undefined
}
const state = {
session_id: sessionID,
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
}
yield* db
.insert(InstructionStateTable)
.values(state)
.onConflictDoUpdate({
target: InstructionStateTable.session_id,
set: {
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
},
})
.run()
.pipe(Effect.orDie)
return state
})
export const assemble = Effect.fn("InstructionState.assemble")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
) {
const state = yield* find(db, sessionID)
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
const updates = rows.map((row) => ({
row,
delta: decodeInstructionsUpdated(row.data).delta,
}))
const blobs = yield* loadBlobs(db, [
...Object.values(state.initial_values),
...updates.flatMap((update) =>
Object.values(update.delta).filter((hash): hash is Instructions.Hash => hash !== "removed"),
),
])
const valuesAtStart = dereference(state.initial_values, blobs)
let values = valuesAtStart
const result: Array<{ readonly seq: number; readonly message: SessionMessage.System }> = []
for (const update of updates) {
const delta = dereferenceDelta(update.delta, blobs)
const text = Instructions.renderUpdate(instructions, values, delta)
if (text.length > 0)
result.push({
seq: update.row.seq,
message: SessionMessage.System.make({
id: SessionMessage.ID.fromEvent(EventV2.ID.make(update.row.id)),
type: "system",
text,
time: { created: DateTime.makeUnsafe(update.row.created) },
}),
})
values = Instructions.applyDelta(values, delta)
}
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result }
})
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
})
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const stored = yield* find(db, sessionID)
if (!stored) return yield* rebuild(db, sessionID)
const latest = yield* db
.select({ seq: EventTable.seq })
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
.orderBy(desc(EventTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!latest || latest.seq <= stored.through_seq) return stored
return yield* rebuild(db, sessionID)
})
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
const rows = Object.entries(blobs).map(([hash, value]) => ({ hash: Instructions.Hash.make(hash), value }))
if (rows.length === 0) return
yield* db.insert(InstructionBlobTable).values(rows).onConflictDoNothing().run().pipe(Effect.orDie)
})
const loadBlobs = Effect.fnUntraced(function* (db: DatabaseService, values: ReadonlyArray<Instructions.Hash>) {
const hashes = [...new Set(values)]
const batches = Array.from({ length: Math.ceil(hashes.length / 500) }, (_, index) =>
hashes.slice(index * 500, (index + 1) * 500),
)
const rows = (yield* Effect.forEach(
batches,
(batch) =>
db.select().from(InstructionBlobTable).where(inArray(InstructionBlobTable.hash, batch)).all().pipe(Effect.orDie),
{ concurrency: 4 },
)).flat()
const blobs = new Map(rows.map((row) => [row.hash, row.value]))
for (const hash of hashes) {
if (!blobs.has(hash)) return yield* Effect.die(new Error(`Instruction blob not found: ${hash}`))
}
return blobs
})
function dereference(values: Instructions.Values, blobs: ReadonlyMap<Instructions.Hash, Schema.Json>) {
return Object.fromEntries(Object.entries(values).map(([key, hash]) => [key, requireBlob(blobs, hash)])) as Readonly<
Record<string, Schema.Json>
>
}
function dereferenceDelta(delta: Instructions.Delta, blobs: ReadonlyMap<Instructions.Hash, Schema.Json>) {
return Object.fromEntries(
Object.entries(delta).map(([key, hash]) => [
key,
hash === "removed" ? Option.none() : Option.some(requireBlob(blobs, hash)),
]),
) as Readonly<Record<string, Option.Option<Schema.Json>>>
}
function requireBlob(blobs: ReadonlyMap<Instructions.Hash, Schema.Json>, hash: Instructions.Hash) {
const value = blobs.get(hash)
if (value === undefined) throw new Error(`Instruction blob not found: ${hash}`)
return value
}
const instructionEventType = EventV2.versionedType(
SessionEvent.InstructionsUpdated.type,
SessionEvent.InstructionsUpdated.durable.version,
)
const compactionEventType = EventV2.versionedType(
SessionEvent.Compaction.Ended.type,
SessionEvent.Compaction.Ended.durable.version,
)
const movedEventType = EventV2.versionedType(SessionEvent.Moved.type, SessionEvent.Moved.durable.version)
const revertedEventType = EventV2.versionedType(
SessionEvent.RevertEvent.Committed.type,
SessionEvent.RevertEvent.Committed.durable.version,
)
const relevantEventTypes = [instructionEventType, compactionEventType, movedEventType, revertedEventType]
type InstructionEventRow = typeof EventTable.$inferSelect
const instructionEvents = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
return yield* eventRows(db, sessionID, relevantEventTypes)
})
const instructionUpdatesAfter = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
after: number,
) {
return yield* eventRows(db, sessionID, [instructionEventType], after)
})
const eventRows = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
types: ReadonlyArray<string>,
after?: number,
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
const segments = (yield* lineage(db, sessionID)).filter(
(segment) => after === undefined || segment.through === undefined || segment.through > after,
)
return (yield* Effect.forEach(segments, (segment) =>
db
.select()
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, segment.sessionID),
inArray(EventTable.type, types),
segment.through === undefined ? undefined : lte(EventTable.seq, segment.through),
after === undefined ? undefined : gt(EventTable.seq, after),
),
)
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie),
)).flat()
})
const lineage = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
through?: number,
): Effect.fn.Return<ReadonlyArray<{ readonly sessionID: SessionSchema.ID; readonly through?: number }>> {
const session = yield* db
.select({ parentID: SessionTable.fork_session_id, forkSeq: SessionTable.fork_seq })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
const inherited =
session?.parentID && session.forkSeq !== null
? yield* lineage(
db,
session.parentID,
through === undefined ? session.forkSeq : Math.min(session.forkSeq, through),
)
: []
return [...inherited, { sessionID, ...(through === undefined ? {} : { through }) }]
})
function fold(rows: ReadonlyArray<InstructionEventRow>) {
return rows.reduce<
| {
readonly epochStart: number
readonly throughSeq: number
readonly initial: Instructions.Values
readonly current: Instructions.Values
}
| undefined
>((state, row) => {
if (row.type === movedEventType || row.type === revertedEventType) return undefined
if (row.type === compactionEventType)
return state
? { epochStart: row.seq, throughSeq: row.seq, initial: state.current, current: state.current }
: undefined
if (row.type !== instructionEventType) return state
const delta = decodeInstructionsUpdated(row.data).delta
const current = Instructions.applyHashDelta(state?.current ?? {}, delta)
return state
? { ...state, throughSeq: row.seq, current }
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
}, undefined)
}

View file

@ -178,16 +178,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.instructions.updated": (event) =>
adapter.appendMessage(
SessionMessage.System.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "system",
text: event.data.text,
metadata: event.metadata,
time: { created: event.created },
}),
),
"session.instructions.updated": () => Effect.void,
"session.synthetic": (event) => {
return adapter.appendMessage(
SessionMessage.Synthetic.make({

View file

@ -13,22 +13,18 @@ import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionPending } from "./pending"
import { WorkspaceV2 } from "../workspace"
import { InstructionCheckpoint } from "./instruction-checkpoint"
import {
MessageTable,
PartTable,
InstructionCheckpointTable,
SessionPendingTable,
SessionMessageTable,
SessionTable,
} from "./sql"
import { InstructionState } from "./instruction-state"
import { MessageTable, PartTable, SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
import { Slug } from "../util/slug"
import { Money } from "@opencode-ai/schema/money"
type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
type MessageEvent = Exclude<
CurrentDurableEvent,
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type | typeof SessionEvent.InstructionsUpdated.Type
>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
@ -212,6 +208,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
parent_id: null,
fork_session_id: event.data.parentID,
fork_message_id: event.data.from,
fork_seq: event.data.parentSeq,
project_id: parent.project_id,
workspace_id: parent.workspace_id,
slug: Slug.create(),
@ -236,23 +233,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
// The fork inherits the parent's transcript, so it inherits the context
// checkpoint that transcript was built against: copied message seqs keep
// folding at the same baseline horizon.
const checkpoint = yield* db
.select()
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, event.data.parentID))
.get()
.pipe(Effect.orDie)
if (checkpoint) {
yield* db
.insert(InstructionCheckpointTable)
.values({ ...checkpoint, session_id: event.data.sessionID })
.run()
.pipe(Effect.orDie)
}
let cursor = -1
while (copiedSeq !== undefined) {
const rows = yield* db
@ -334,7 +314,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
cursor = rows.at(-1)!.seq
}
if (copiedSeq !== undefined) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq)
yield* EventV2.reserveSequence(db, event.data.sessionID, event.data.parentSeq)
yield* InstructionState.rebuild(db, event.data.sessionID)
})
function run(db: DatabaseService, event: MessageEvent) {
@ -522,7 +503,7 @@ const layer = Layer.effectDiscard(
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* InstructionCheckpoint.reset(db, event.data.sessionID)
yield* InstructionState.reset(db, event.data.sessionID)
}),
)
yield* events.project(SessionV1.Event.Deleted, (event) =>
@ -688,7 +669,9 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.InstructionsUpdated, (event) =>
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
)
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
@ -722,6 +705,7 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
if (event.data.reason === "manual")
@ -793,7 +777,7 @@ const layer = Layer.effectDiscard(
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* InstructionCheckpoint.reset(db, event.data.sessionID)
yield* InstructionState.reset(db, event.data.sessionID)
}),
)
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe(

View file

@ -29,7 +29,7 @@ import { InstructionEntry } from "../instruction-entry"
import { QuestionTool } from "../../tool/question"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { InstructionCheckpoint } from "../instruction-checkpoint"
import { InstructionState } from "../instruction-state"
import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event"
import { SessionHistory } from "../history"
@ -112,6 +112,8 @@ const layer = Layer.effect(
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return session
})
const isCurrentLocation = (session: SessionSchema.Info) =>
session.location.directory === location.directory && session.location.workspaceID === location.workspaceID
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
sessionID: SessionSchema.ID,
@ -160,20 +162,15 @@ const layer = Layer.effect(
assistantMessageID?: SessionMessage.ID,
) {
const session = yield* getSession(sessionID)
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
if (!isCurrentLocation(session)) return yield* Effect.interrupt
yield* plugins.flush
const agent = yield* agents.select(session.agent)
const agentInfo = agent.info
if (!agentInfo) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
// Establish what the model knows before admitting what the user said, so
// a blocked first step leaves pending inputs untouched.
const checkpoint = yield* InstructionCheckpoint.prepare(
db,
events,
loadInstructions(agent, session.id),
session.id,
)
const instructions = yield* loadInstructions(agent, session.id)
yield* InstructionState.prepare(db, events, instructions, session.id)
let currentStep = step
if (promotion) {
let promoted = 0
@ -187,8 +184,8 @@ const layer = Layer.effect(
const resolved = yield* models.resolve(session)
const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
const context = entries.map((entry) => entry.message)
const history = yield* SessionHistory.entriesForRunner(db, session.id, instructions)
const context = history.entries.map((entry) => entry.message)
const compactionInput = { sessionID: session.id, messages: context, model }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
@ -201,7 +198,7 @@ const layer = Layer.effect(
const request = LLM.request({
model,
providerOptions: { openai: { promptCacheKey } },
system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), checkpoint.baseline]
system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), history.initial]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [

View file

@ -11,7 +11,7 @@ import type { SessionSchema } from "./schema"
import type { MessageID, PartID, SessionV1 } from "../v1/session"
import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { Instructions } from "../instructions/index"
import type { Instruction } from "@opencode-ai/schema/instruction"
import type { Session } from "@opencode-ai/schema/session"
import type { SyntheticData, UserData } from "@opencode-ai/schema/session-pending"
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
@ -33,6 +33,7 @@ export const SessionTable = sqliteTable(
parent_id: text().$type<SessionSchema.ID>(),
fork_session_id: text().$type<SessionSchema.ID>(),
fork_message_id: text().$type<SessionMessage.ID>(),
fork_seq: integer(),
slug: text().notNull(),
directory: directoryColumn().notNull(),
path: pathColumn(),
@ -159,18 +160,25 @@ export const InstructionEntryTable = sqliteTable(
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
key: text().notNull(),
value: text({ mode: "json" }).notNull().$type<Schema.Json>(),
value: text({ mode: "json" }).$type<Schema.Json>(),
removed: integer({ mode: "boolean" }).notNull().default(false),
...Timestamps,
},
(table) => [primaryKey({ columns: [table.session_id, table.key] })],
)
export const InstructionCheckpointTable = sqliteTable("instruction_checkpoint", {
export const InstructionBlobTable = sqliteTable("instruction_blob", {
hash: text().$type<Instruction.Hash>().primaryKey(),
value: text({ mode: "json" }).$type<Schema.Json>(),
})
export const InstructionStateTable = sqliteTable("instruction_state", {
session_id: text()
.$type<SessionSchema.ID>()
.primaryKey()
.references(() => SessionTable.id, { onDelete: "cascade" }),
baseline: text().notNull(),
snapshot: text({ mode: "json" }).notNull().$type<Instructions.Applied>(),
baseline_seq: integer().notNull(),
epoch_start: integer().notNull(),
through_seq: integer().notNull(),
initial_values: text({ mode: "json" }).notNull().$type<Instruction.Values>(),
current_values: text({ mode: "json" }).notNull().$type<Instruction.Values>(),
})

View file

@ -3,7 +3,6 @@ export * as SkillGuidance from "./guidance"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
import { SkillV2 } from "../skill"
import { Instructions } from "../instructions/index"
@ -73,8 +72,6 @@ const layer = Layer.effect(
const agent = selection.info
if (!agent) return Instructions.empty
const permitted = SkillV2.available(yield* skills.list(), agent)
if (permitted.length === 0 && PermissionV2.evaluate("skill", "*", agent.permissions).effect === "deny")
return Instructions.empty
const available = permitted
.flatMap((skill) =>
skill.description === undefined || skill.autoinvoke === false
@ -82,13 +79,15 @@ const layer = Layer.effect(
: [{ id: skill.id, name: skill.name, description: skill.description }],
)
.toSorted((a, b) => a.id.localeCompare(b.id))
return Instructions.make({
return Instructions.make<ReadonlyArray<Summary>>({
key: Instructions.Key.make("core/skill-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(available),
baseline: render,
update,
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
read: Effect.succeed(available.length === 0 ? Instructions.removed : available),
render: {
initial: render,
changed: update,
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
},
})
}),
})

View file

@ -102,7 +102,7 @@ export const Plugin = {
const resolved = yield* fs.resolve(target.canonical)
const root = yield* fs.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already
// supplied by the core/instructions baseline) is dropped by the dirname filter.
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: type === "directory" ? resolved : dirname(resolved),

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])))
})
})