mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 02:53:07 +00:00
Separate ID-bound Session policy from host routing. Bind Inbox and Location preparation dependencies at construction, preserve admission and execution semantics, and cover the extracted ownership contracts directly.
123 lines
5.2 KiB
TypeScript
123 lines
5.2 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { Effect, Fiber, Schema, Stream } from "effect"
|
|
import { Database } from "@opencode-ai/core/database/database"
|
|
import { Agent } from "@opencode-ai/core/agent"
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
import { Bus } from "@opencode-ai/core/bus"
|
|
import { Event } from "@opencode-ai/schema/event"
|
|
import { Location } from "@opencode-ai/core/location"
|
|
import { Project } from "@opencode-ai/core/project"
|
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
import { Session } from "@opencode-ai/core/session"
|
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
|
import { testEffect } from "./lib/effect"
|
|
import { globalProjectNode } from "./lib/project"
|
|
|
|
const it = testEffect(
|
|
AppNodeBuilder.build(
|
|
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
|
[
|
|
[Bus.node, Bus.configured({ persist: true })],
|
|
[Project.node, globalProjectNode],
|
|
[SessionExecution.node, SessionExecution.noopLayer],
|
|
],
|
|
),
|
|
)
|
|
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
|
|
|
describe("Session.log", () => {
|
|
it.effect("replays public session events and marks synced at the aggregate watermark", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* Session.Service
|
|
const created = yield* session.create({ location })
|
|
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
|
|
|
|
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
|
|
|
expect(items.map((item) => item.type)).toEqual(["session.created", "session.renamed", "log.synced"])
|
|
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) })
|
|
}),
|
|
)
|
|
|
|
it.effect("continues with live public events when following", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* Session.Service
|
|
const created = yield* session.create({ location })
|
|
const fiber = yield* session
|
|
.log({ sessionID: created.id, after: Event.Seq.make(0), follow: true })
|
|
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
|
yield* Effect.yieldNow
|
|
|
|
yield* session.rename({ sessionID: created.id, title: "renamed live" })
|
|
|
|
const items = Array.from(yield* Fiber.join(fiber))
|
|
expect(items.map((item) => item.type)).toEqual(["log.synced", "session.renamed"])
|
|
}),
|
|
)
|
|
|
|
it.effect("fails with NotFound for an unknown session", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* Session.Service
|
|
const error = yield* Effect.flip(Stream.runCollect(session.log({ sessionID: Session.ID.create() })))
|
|
expect(error._tag).toBe("Session.NotFoundError")
|
|
}),
|
|
)
|
|
|
|
it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () =>
|
|
Effect.gen(function* () {
|
|
const GapEvent = Bus.durable({
|
|
type: "test.session.log.gap",
|
|
durable: { aggregate: "sessionID", version: 1 },
|
|
schema: { sessionID: Session.ID, value: Schema.String },
|
|
})
|
|
const session = yield* Session.Service
|
|
const bus = yield* Bus.Service
|
|
const created = yield* session.create({ location })
|
|
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("one") })
|
|
// Not in the durable manifest, so reads must skip it without failing.
|
|
yield* bus.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
|
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("two") })
|
|
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("three") })
|
|
|
|
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))
|
|
|
|
expect(
|
|
items.map((item): number | string | undefined => (Bus.isSynced(item) ? item.type : item.durable?.seq)),
|
|
).toEqual([3, 4, "log.synced"])
|
|
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(4) })
|
|
}),
|
|
)
|
|
|
|
it.effect("completes with a bare synced marker for a migrated Session with no event sequence", () =>
|
|
Effect.gen(function* () {
|
|
const db = (yield* Database.Service).db
|
|
const session = yield* Session.Service
|
|
const sessionID = Session.ID.make("ses_empty_log")
|
|
yield* db
|
|
.insert(ProjectTable)
|
|
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
|
.onConflictDoNothing()
|
|
.run()
|
|
yield* db
|
|
.insert(SessionTable)
|
|
.values({
|
|
id: sessionID,
|
|
project_id: Project.ID.global,
|
|
slug: "empty-log",
|
|
directory: "/project",
|
|
title: "Empty log",
|
|
version: "test",
|
|
})
|
|
.run()
|
|
|
|
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID })))
|
|
|
|
expect(items).toEqual([{ type: "log.synced", aggregateID: sessionID }])
|
|
}),
|
|
)
|
|
})
|