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
|
|
@ -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