feat(core): replace instruction checkpoints with value-delta sync (#36254)
This commit is contained in:
parent
768a69bbbd
commit
96a9731947
57 changed files with 2053 additions and 1278 deletions
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }] : []
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
338
packages/core/src/session/instruction-state.ts
Normal file
338
packages/core/src/session/instruction-state.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
43
packages/core/test/lib/instructions.ts
Normal file
43
packages/core/test/lib/instructions.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) }))))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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])))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue