import { describe, expect } from "bun:test" import { DateTime, Effect, Schema } from "effect" import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { ModelV2 } from "@opencode-ai/core/model" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" import { Shell } from "@opencode-ai/schema/shell" import { SessionContextCheckpointTable, SessionInputTable, SessionMessageTable, SessionTable, } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" import { Snapshot } from "@opencode-ai/core/snapshot" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node]))) const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]]) const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") } const encodeMessage = Schema.encodeSync(SessionMessage.Message) const assistantRow = ( id: SessionMessage.ID, seq: number, time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created }, ) => { const { id: _, type, ...data } = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time })) return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data } } describe("SessionProjector", () => { it.effect("projects staged, cleared, and committed reverts", () => Effect.gen(function* () { const db = (yield* Database.Service).db yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .run() yield* db .insert(SessionTable) .values({ id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test", }) .run() const boundary = SessionMessage.ID.make("msg_boundary") yield* db .insert(SessionMessageTable) .values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)]) .run() yield* db .insert(SessionContextCheckpointTable) .values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 }) .run() const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] }, }) expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ messageID: boundary, snapshot: "tree", files: [], }) yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID }) expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull() yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, revert: { messageID: boundary, files: [] }, }) yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID, messageID: boundary, }) expect( (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), ).toEqual([boundary]) // A committed revert resets the context checkpoint so the next turn re-initializes. expect(yield* db.select().from(SessionContextCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined() }), ) it.effect("orders projected messages and context by durable aggregate sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .run() .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test", }) .run() .pipe(Effect.orDie) const events = yield* EventV2.Service yield* events.publish(SessionEvent.PromptAdmitted, { sessionID, inputID: SessionMessage.ID.make("msg_first"), prompt: Prompt.make({ text: "first" }), delivery: "steer", }) yield* events.publish( SessionEvent.PromptPromoted, { sessionID, inputID: SessionMessage.ID.make("msg_first"), }, { id: EventV2.ID.make("evt_z") }, ) yield* events.publish(SessionEvent.PromptAdmitted, { sessionID, inputID: SessionMessage.ID.make("msg_second"), prompt: Prompt.make({ text: "second" }), delivery: "steer", }) yield* events.publish( SessionEvent.PromptPromoted, { sessionID, inputID: SessionMessage.ID.make("msg_second"), }, { id: EventV2.ID.make("evt_a") }, ) const sessions = yield* SessionV2.Service const firstPage = yield* sessions.messages({ sessionID, limit: 1, order: "asc" }) expect(firstPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["first"]) const secondPage = yield* sessions.messages({ sessionID, limit: 1, order: "asc", cursor: { id: firstPage[0]!.id, direction: "next" }, }) expect(secondPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["second"]) expect( (yield* sessions.messages({ sessionID, limit: 1, order: "asc", cursor: { id: secondPage[0]!.id, direction: "previous" }, })).map((message) => (message.type === "user" ? message.text : message.type)), ).toEqual(["first"]) expect( (yield* sessions.context(sessionID)).map((message) => (message.type === "user" ? message.text : message.type)), ).toEqual(["first", "second"]) }).pipe(Effect.provide(sessionsLayer)), ) it.effect("marks an inbox row promoted with the PromptPromoted event sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .run() .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test", }) .run() .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_admitted") const admitted = yield* SessionInput.admit(db, events, { id, sessionID, prompt: Prompt.make({ text: "promote me" }), delivery: "steer", }) if (!admitted) return yield* Effect.die("Prompt admission failed") const event = yield* events.publish(SessionEvent.PromptPromoted, { sessionID, inputID: id, }) expect( yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), ).toMatchObject({ promoted_seq: event.durable?.seq }) }), ) it.effect("projects durable context messages supported by the updater", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .run() .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test", model: previousModel, }) .run() .pipe(Effect.orDie) const events = yield* EventV2.Service yield* events.publish(SessionEvent.AgentSelected, { sessionID, agent: "build", }) yield* events.publish(SessionEvent.ModelSelected, { sessionID, model, }) yield* events.publish(SessionEvent.Synthetic, { sessionID, text: "synthetic context", metadata: { source: "projector-test" }, }) yield* events.publish(SessionEvent.Shell.Started, { sessionID, shell: Shell.Info.make({ id: Shell.ID.make("sh_projector"), status: "running", command: "pwd", cwd: "/project", shell: "/bin/sh", file: "/tmp/sh_projector.out", metadata: {}, time: { started: 0 }, }), }) yield* events.publish(SessionEvent.Shell.Ended, { sessionID, shell: Shell.Info.make({ id: Shell.ID.make("sh_projector"), status: "exited", command: "pwd", cwd: "/project", shell: "/bin/sh", file: "/tmp/sh_projector.out", exit: 0, metadata: {}, time: { started: 0, completed: 1 }, }), output: { output: "/project", cursor: 8, size: 8, truncated: false }, }) yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", }) yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, text: "partial", }) expect( yield* db .select({ id: EventTable.id }) .from(EventTable) .where(eq(EventTable.type, SessionEvent.Compaction.Delta.type)) .all() .pipe(Effect.orDie), ).toEqual([]) expect( yield* db .select({ id: SessionMessageTable.id }) .from(SessionMessageTable) .where(eq(SessionMessageTable.type, "compaction")) .all() .pipe(Effect.orDie), ).toEqual([]) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", text: "summary", recent: "recent context", }) const rows = yield* db .select() .from(SessionMessageTable) .where(eq(SessionMessageTable.session_id, sessionID)) .orderBy(asc(SessionMessageTable.seq)) .all() .pipe(Effect.orDie) const messages = rows.map((row) => Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), ) expect(messages.map((message) => message.type)).toEqual([ "agent-switched", "model-switched", "synthetic", "shell", "compaction", ]) expect(messages.find((message) => message.type === "synthetic")).toMatchObject({ text: "synthetic context", metadata: { source: "projector-test" }, }) expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel }) expect(messages.find((message) => message.type === "shell")).toMatchObject({ shell: { command: "pwd", status: "exited", exit: 0 }, output: { output: "/project", truncated: false }, time: { completed: DateTime.makeUnsafe(0) }, }) expect(messages.find((message) => message.type === "compaction")).toMatchObject({ summary: "summary", recent: "recent context", }) expect( yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie), ).toMatchObject({ agent: "build", model, time_updated: DateTime.toEpochMillis(created), }) }), ) it.effect("rejects distinct creator events that reuse one projected message ID", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .run() .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test", }) .run() .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_creator_collision") const { id: _, type, ...data } = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } }) yield* db .insert(SessionMessageTable) .values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data }) .run() const exit = yield* events .publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: id, agent: "build", model, }) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") expect( yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie), ).toMatchObject({ type: "synthetic" }) }), ) it.effect("does not revive a stale incomplete in-memory assistant projection", () => Effect.gen(function* () { const stale = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", model, content: [], time: { created }, }) const completed = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", model, content: [], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, }) expect( yield* SessionMessageUpdater.memory({ messages: [stale, completed] }).getCurrentAssistant(), ).toBeUndefined() }), ) it.effect("updates only the newest incomplete assistant projection", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .run() .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test", }) .run() .pipe(Effect.orDie) yield* db .insert(SessionMessageTable) .values([ assistantRow(SessionMessage.ID.make("msg_assistant_1"), 0), assistantRow(SessionMessage.ID.make("msg_assistant_2"), 1), ]) .run() .pipe(Effect.orDie) const service = yield* EventV2.Service yield* service.publish(SessionEvent.Step.Ended, { sessionID, assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), finish: "stop", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, }) const rows = yield* db .select() .from(SessionMessageTable) .where(eq(SessionMessageTable.session_id, sessionID)) .orderBy(asc(SessionMessageTable.id)) .all() .pipe(Effect.orDie) const messages = rows.map((row) => Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), ) expect(messages[0]).not.toHaveProperty("time.completed") expect(messages[1]).toMatchObject({ type: "assistant", finish: "stop", time: { completed: DateTime.makeUnsafe(0) }, }) }), ) it.effect("does not revive a stale incomplete assistant projection", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .run() .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test", }) .run() .pipe(Effect.orDie) yield* db .insert(SessionMessageTable) .values([ assistantRow(SessionMessage.ID.make("msg_assistant_stale"), 0), assistantRow(SessionMessage.ID.make("msg_assistant_completed"), 1, { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2), }), ]) .run() .pipe(Effect.orDie) const service = yield* EventV2.Service yield* service.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"), textID: "text-stale", }) const rows = yield* db .select() .from(SessionMessageTable) .where(eq(SessionMessageTable.session_id, sessionID)) .orderBy(asc(SessionMessageTable.id)) .all() .pipe(Effect.orDie) const messages = rows.map((row) => Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), ) expect(messages).toEqual([ SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", model, content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, }), SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", model, content: [], time: { created }, }), ]) }), ) })