| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425 |
- import { describe, expect } from "bun:test"
- import { and, asc, eq } from "drizzle-orm"
- import { Effect, Schema } from "effect"
- import { Database } from "@opencode-ai/core/database/database"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@opencode-ai/util/effect/layer-node"
- import { EventV2 } from "@opencode-ai/core/event"
- import { EventTable } from "@opencode-ai/core/event/sql"
- import { Instructions } from "@opencode-ai/core/instructions"
- import { Project } from "@opencode-ai/core/project"
- import { ProjectTable } from "@opencode-ai/core/project/sql"
- import { AbsolutePath } from "@opencode-ai/core/schema"
- import { InstructionState } from "@opencode-ai/core/session/instruction-state"
- import { SessionProjector } from "@opencode-ai/core/session/projector"
- import { SessionSchema } from "@opencode-ai/core/session/schema"
- import { InstructionBlobTable, InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
- import { testEffect } from "./lib/effect"
- const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
- const source = (name: string, read: Effect.Effect<string | Instructions.Unavailable | Instructions.Removed>) =>
- Instructions.make({
- key: Instructions.Key.make(name),
- codec: Schema.toCodecJson(Schema.String),
- read,
- render: {
- initial: String,
- changed: (_previous, current) => current,
- removed: (previous) => `Removed ${previous}`,
- },
- })
- const setup = (sessionID: SessionSchema.ID) =>
- Effect.gen(function* () {
- const { db } = yield* Database.Service
- yield* db
- .insert(ProjectTable)
- .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
- .onConflictDoNothing()
- .run()
- .pipe(Effect.orDie)
- yield* db
- .insert(SessionTable)
- .values({
- id: sessionID,
- project_id: Project.ID.global,
- slug: "instruction-state-test",
- directory: "/project",
- title: "Instruction state test",
- version: "test",
- })
- .run()
- .pipe(Effect.orDie)
- return { db, events: yield* EventV2.Service }
- })
- const instructionEvents = (db: Database.Interface["db"], sessionID: SessionSchema.ID) =>
- db
- .select()
- .from(EventTable)
- .where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, "session.instructions.updated.2")))
- .orderBy(asc(EventTable.seq))
- .all()
- .pipe(Effect.orDie)
- const preview = (db: Database.Interface["db"], sessionID: SessionSchema.ID, instructions: Instructions.Instructions) =>
- Instructions.read(instructions).pipe(
- Effect.flatMap((observed) => InstructionState.preview(db, sessionID, instructions, observed)),
- )
- describe("InstructionState", () => {
- it.effect("observes each source once without publishing events or inserting blobs", () =>
- Effect.gen(function* () {
- const sessionID = SessionSchema.ID.make("ses_instruction_observe")
- const { db, events } = yield* setup(sessionID)
- const reads = { first: 0, second: 0 }
- const instructions = Instructions.combine([
- source(
- "test/first",
- Effect.sync(() => {
- reads.first++
- return "first"
- }),
- ),
- source(
- "test/second",
- Effect.sync(() => {
- reads.second++
- return "second"
- }),
- ),
- ])
- const published: EventV2.Payload[] = []
- const unsubscribe = yield* events.listen((event) =>
- Effect.sync(() => {
- if (event.type === "session.instructions.updated") published.push(event)
- }),
- )
- const observation = yield* InstructionState.observe(db, instructions, sessionID)
- yield* unsubscribe
- expect(reads).toEqual({ first: 1, second: 1 })
- expect(observation).toEqual({
- sessionID,
- initial: true,
- current: {
- "test/first": Instructions.hash("first"),
- "test/second": Instructions.hash("second"),
- },
- delta: {
- "test/first": Instructions.hash("first"),
- "test/second": Instructions.hash("second"),
- },
- blobs: {
- [Instructions.hash("first")]: "first",
- [Instructions.hash("second")]: "second",
- },
- })
- expect(published).toEqual([])
- expect(yield* instructionEvents(db, sessionID)).toEqual([])
- expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
- }),
- )
- it.effect("commits initial metadata and changed and removed deltas without rereading sources", () =>
- Effect.gen(function* () {
- const sessionID = SessionSchema.ID.make("ses_instruction_commit")
- const { db, events } = yield* setup(sessionID)
- let current = "initial"
- let retired: string | Instructions.Removed = "retired"
- let reads = 0
- const instructions = Instructions.combine([
- source(
- "test/current",
- Effect.sync(() => {
- reads++
- return current
- }),
- ),
- source(
- "test/retired",
- Effect.sync(() => {
- reads++
- return retired
- }),
- ),
- ])
- const published: EventV2.Payload[] = []
- const unsubscribe = yield* events.listen((event) =>
- Effect.sync(() => {
- if (event.type === "session.instructions.updated") published.push(event)
- }),
- )
- const initial = yield* InstructionState.observe(db, instructions, sessionID)
- expect(reads).toBe(2)
- yield* InstructionState.commit(db, events, initial)
- expect(reads).toBe(2)
- current = "changed"
- retired = Instructions.removed
- const changed = yield* InstructionState.observe(db, instructions, sessionID)
- expect(reads).toBe(4)
- expect(changed).toMatchObject({
- sessionID,
- initial: false,
- current: { "test/current": Instructions.hash("changed") },
- delta: {
- "test/current": Instructions.hash("changed"),
- "test/retired": "removed",
- },
- blobs: { [Instructions.hash("changed")]: "changed" },
- })
- yield* InstructionState.commit(db, events, changed)
- expect(reads).toBe(4)
- yield* unsubscribe
- expect(published).toHaveLength(2)
- expect(published[0]?.metadata).toEqual({ instructions: { initial: true } })
- expect(published[1]?.metadata).toBeUndefined()
- expect((yield* instructionEvents(db, sessionID)).map((event) => event.data.delta)).toEqual([
- {
- "test/current": Instructions.hash("initial"),
- "test/retired": Instructions.hash("retired"),
- },
- {
- "test/current": Instructions.hash("changed"),
- "test/retired": "removed",
- },
- ])
- expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({
- initial_values: {
- "test/current": Instructions.hash("initial"),
- "test/retired": Instructions.hash("retired"),
- },
- current_values: { "test/current": Instructions.hash("changed") },
- })
- expect(
- Object.fromEntries(
- (yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).map((row) => [row.hash, row.value]),
- ),
- ).toEqual({
- [Instructions.hash("initial")]: "initial",
- [Instructions.hash("retired")]: "retired",
- [Instructions.hash("changed")]: "changed",
- })
- }),
- )
- it.effect("keeps no-op observations free of events and blobs", () =>
- Effect.gen(function* () {
- const sessionID = SessionSchema.ID.make("ses_instruction_noop")
- const { db, events } = yield* setup(sessionID)
- const instructions = source("test/context", Effect.succeed("unchanged"))
- yield* InstructionState.prepare(db, events, instructions, sessionID)
- const beforeEvents = yield* instructionEvents(db, sessionID)
- const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
- const observation = yield* InstructionState.observe(db, instructions, sessionID)
- expect(observation).toEqual({
- sessionID,
- initial: false,
- current: { "test/context": Instructions.hash("unchanged") },
- delta: {},
- blobs: {},
- })
- yield* InstructionState.commit(db, events, observation)
- expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
- expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
- }),
- )
- it.effect("assembles a fresh private update without repairing a missing cache", () =>
- Effect.gen(function* () {
- const sessionID = SessionSchema.ID.make("ses_instruction_generate")
- const { db, events } = yield* setup(sessionID)
- let value = "Initial context"
- const instructions = source(
- "test/context",
- Effect.sync(() => value),
- )
- yield* InstructionState.prepare(db, events, instructions, sessionID)
- yield* db
- .delete(InstructionStateTable)
- .where(eq(InstructionStateTable.session_id, sessionID))
- .run()
- .pipe(Effect.orDie)
- value = "Changed context"
- const beforeEvents = yield* instructionEvents(db, sessionID)
- const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
- const assembled = yield* preview(db, sessionID, instructions)
- expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" })
- expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
- expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
- expect(
- yield* db
- .select()
- .from(InstructionStateTable)
- .where(eq(InstructionStateTable.session_id, sessionID))
- .get()
- .pipe(Effect.orDie),
- ).toBeUndefined()
- }),
- )
- it.effect("reads through a stale cache without repairing it", () =>
- Effect.gen(function* () {
- const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
- const { db, events } = yield* setup(sessionID)
- let value = "Initial context"
- const instructions = source(
- "test/context",
- Effect.sync(() => value),
- )
- yield* InstructionState.prepare(db, events, instructions, sessionID)
- value = "Committed update"
- yield* InstructionState.prepare(db, events, instructions, sessionID)
- yield* db
- .update(InstructionStateTable)
- .set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
- .where(eq(InstructionStateTable.session_id, sessionID))
- .run()
- .pipe(Effect.orDie)
- value = "Private update"
- const beforeEvents = yield* instructionEvents(db, sessionID)
- const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
- const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
- const assembled = yield* preview(db, sessionID, instructions)
- expect(assembled.initial).toBe("Initial context")
- expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"])
- expect(assembled.update).toBe("Private update")
- expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
- expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
- expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
- }),
- )
- it.effect("assembles initial instructions without persisting a baseline", () =>
- Effect.gen(function* () {
- const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
- const { db } = yield* setup(sessionID)
- const instructions = source("test/context", Effect.succeed("Initial context"))
- expect(yield* preview(db, sessionID, instructions)).toEqual({
- initial: "Initial context",
- updates: [],
- update: "",
- })
- expect(yield* instructionEvents(db, sessionID)).toEqual([])
- expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
- expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
- }),
- )
- it.effect("retains a committed value when fresh instructions are unavailable", () =>
- Effect.gen(function* () {
- const sessionID = SessionSchema.ID.make("ses_instruction_generate_unavailable")
- const { db, events } = yield* setup(sessionID)
- let value: string | Instructions.Unavailable = "Committed context"
- const instructions = source(
- "test/context",
- Effect.sync(() => value),
- )
- yield* InstructionState.prepare(db, events, instructions, sessionID)
- value = Instructions.unavailable
- const beforeEvents = yield* instructionEvents(db, sessionID)
- const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
- const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
- expect(yield* preview(db, sessionID, instructions)).toEqual({
- initial: "Committed context",
- updates: [],
- update: "",
- })
- expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
- expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
- expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
- }),
- )
- it.effect("blocks an unavailable initial instruction without persisting a baseline", () =>
- Effect.gen(function* () {
- const sessionID = SessionSchema.ID.make("ses_instruction_generate_blocked")
- const { db } = yield* setup(sessionID)
- const instructions = source("test/context", Effect.succeed(Instructions.unavailable))
- const error = yield* preview(db, sessionID, instructions).pipe(Effect.flip)
- expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
- expect(error.keys).toEqual([Instructions.Key.make("test/context")])
- expect(yield* instructionEvents(db, sessionID)).toEqual([])
- expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
- expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
- }),
- )
- it.effect("keeps prepare equivalent to observe followed by commit", () =>
- Effect.gen(function* () {
- const observedSessionID = SessionSchema.ID.make("ses_instruction_composed")
- const preparedSessionID = SessionSchema.ID.make("ses_instruction_prepared")
- const { db, events } = yield* setup(observedSessionID)
- yield* setup(preparedSessionID)
- let value: string | Instructions.Removed = "initial"
- let observedReads = 0
- let preparedReads = 0
- const observedInstructions = source(
- "test/context",
- Effect.sync(() => {
- observedReads++
- return value
- }),
- )
- const preparedInstructions = source(
- "test/context",
- Effect.sync(() => {
- preparedReads++
- return value
- }),
- )
- for (const next of ["initial", "changed", "changed", Instructions.removed] as const) {
- value = next
- yield* InstructionState.observe(db, observedInstructions, observedSessionID).pipe(
- Effect.flatMap((observation) => InstructionState.commit(db, events, observation)),
- )
- yield* InstructionState.prepare(db, events, preparedInstructions, preparedSessionID)
- }
- expect(observedReads).toBe(4)
- expect(preparedReads).toBe(4)
- expect((yield* instructionEvents(db, observedSessionID)).map((event) => event.data.delta)).toEqual(
- (yield* instructionEvents(db, preparedSessionID)).map((event) => event.data.delta),
- )
- const states = yield* db.select().from(InstructionStateTable).orderBy(asc(InstructionStateTable.session_id)).all()
- expect(states).toHaveLength(2)
- expect(
- states.map((state) => ({
- epoch_start: state.epoch_start,
- through_seq: state.through_seq,
- initial_values: state.initial_values,
- current_values: state.current_values,
- })),
- ).toEqual([
- {
- epoch_start: 0,
- through_seq: 2,
- initial_values: { "test/context": Instructions.hash("initial") },
- current_values: {},
- },
- {
- epoch_start: 0,
- through_seq: 2,
- initial_values: { "test/context": Instructions.hash("initial") },
- current_values: {},
- },
- ])
- }),
- )
- })
|