import { describe, expect } from "bun:test" import { Effect, Layer, 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/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" 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 { SessionStore } from "@opencode-ai/core/session/store" import { SessionTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), ) const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), [ [ProjectV2.node, projects], [SessionExecution.node, SessionExecution.noopLayer], ], ), ) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const GapEvent = EventV2.define({ type: "test.session.history.gap", durable: { aggregate: "sessionID", version: 1 }, schema: { sessionID: SessionV2.ID, value: Schema.String }, }) describe("SessionV2.history", () => { it.effect("returns an exhausted page for a migrated Session with no event sequence", () => Effect.gen(function* () { const db = (yield* Database.Service).db const session = yield* SessionV2.Service const sessionID = SessionV2.ID.make("ses_empty_history") yield* db .insert(ProjectTable) .values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .onConflictDoNothing() .run() yield* db .insert(SessionTable) .values({ id: sessionID, project_id: ProjectV2.ID.global, slug: "empty-history", directory: "/project", title: "Empty history", version: "test", }) .run() const first = yield* session.history({ sessionID, limit: 10 }) expect(first).toEqual({ events: [], hasMore: false }) }), ) it.effect("treats after as an exclusive aggregate sequence", () => Effect.gen(function* () { const session = yield* SessionV2.Service const created = yield* session.create({ location }) yield* session.switchAgent({ sessionID: created.id, agent: "one" }) yield* session.switchAgent({ sessionID: created.id, agent: "two" }) const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 }) expect(page.events.map((event) => event.durable?.seq)).toEqual([2]) expect(page.hasMore).toBe(false) }), ) it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () => Effect.gen(function* () { const session = yield* SessionV2.Service const events = yield* EventV2.Service const created = yield* session.create({ location }) yield* session.switchAgent({ sessionID: created.id, agent: "one" }) yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" }) yield* session.switchAgent({ sessionID: created.id, agent: "two" }) yield* session.switchAgent({ sessionID: created.id, agent: "three" }) const first = yield* session.history({ sessionID: created.id, limit: 2 }) const after = first.events.at(-1)?.durable?.seq const second = yield* session.history({ sessionID: created.id, after, limit: 2, }) const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq) expect(first.hasMore).toBe(true) expect(second.hasMore).toBe(false) expect(sequence).toEqual([1, 3, 4]) expect(new Set(sequence).size).toBe(sequence.length) }), ) it.effect("includes events committed between pages", () => Effect.gen(function* () { const session = yield* SessionV2.Service const created = yield* session.create({ location }) yield* session.switchAgent({ sessionID: created.id, agent: "one" }) yield* session.switchAgent({ sessionID: created.id, agent: "two" }) const first = yield* session.history({ sessionID: created.id, limit: 1 }) yield* session.switchAgent({ sessionID: created.id, agent: "later" }) const second = yield* session.history({ sessionID: created.id, after: first.events.at(-1)?.durable?.seq, limit: 10, }) expect(first.hasMore).toBe(true) expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3]) expect(second.hasMore).toBe(false) }), ) it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () => Effect.gen(function* () { const session = yield* SessionV2.Service const created = yield* session.create({ location }) yield* session.switchAgent({ sessionID: created.id, agent: "one" }) yield* session.switchAgent({ sessionID: created.id, agent: "two" }) const exact = yield* session.history({ sessionID: created.id, limit: 2 }) const oneMore = yield* session.history({ sessionID: created.id, limit: 1 }) const exhausted = yield* session.history({ sessionID: created.id, after: oneMore.events.at(-1)?.durable?.seq, limit: 1, }) expect(exact.events).toHaveLength(2) expect(exact.hasMore).toBe(false) expect(oneMore.events).toHaveLength(1) expect(oneMore.hasMore).toBe(true) expect(exhausted.events).toHaveLength(1) expect(exhausted.hasMore).toBe(false) }), ) it.effect("fails with NotFoundError for a missing Session", () => Effect.gen(function* () { const session = yield* SessionV2.Service const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip) expect(error._tag).toBe("Session.NotFoundError") }), ) })