chore: sync native provider core stack

This commit is contained in:
Shoubhit Dash 2026-06-25 20:37:23 +05:30
commit 5e12dbdbfb
446 changed files with 22760 additions and 7855 deletions

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/public"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"

View file

@ -1,10 +1,14 @@
import { describe, expect } from "bun:test"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Event } from "@opencode-ai/schema/event"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { Database } from "@opencode-ai/core/database/database"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath, DateTimeUtcFromMillis } from "@opencode-ai/core/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
@ -16,10 +20,6 @@ const locationLayer = Layer.succeed(
location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }),
),
)
const eventLayer = Layer.mergeAll(EventV2.defaultLayer, Database.defaultLayer)
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(eventLayer)
const Message = EventV2.define({
type: "test.message",
schema: {
@ -70,18 +70,16 @@ const VersionedMessage = EventV2.define({
},
})
const SyncTimestamp = EventV2.define({
type: "test.timestamp",
durable: {
version: 1,
aggregate: "id",
},
schema: {
id: Schema.String,
timestamp: DateTimeUtcFromMillis,
},
const DurableMessage = SessionV1.Event.MessageRemoved
const durableData = (sessionID: Session.ID, text: string) => ({
sessionID,
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
})
const eventLayer = Layer.mergeAll(EventV2.layerWith().pipe(Layer.provide(Database.defaultLayer)), Database.defaultLayer)
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(eventLayer)
describe("EventV2", () => {
it.effect("publishes events with the current location", () =>
Effect.gen(function* () {
@ -122,26 +120,21 @@ describe("EventV2", () => {
}),
)
it.effect("stores definitions in the exported registry", () =>
Effect.sync(() => {
expect(EventV2.registry.get(Message.type)).toBe(Message)
}),
)
it.effect("keeps the latest sync definition in the registry", () =>
it.effect("selects the latest durable definition independent of declaration order", () =>
Effect.sync(() => {
const latest = EventV2.define({
type: "test.out-of-order",
durable: { version: 2, aggregate: "id" },
schema: { id: Schema.String },
})
EventV2.define({
const historical = EventV2.define({
type: "test.out-of-order",
durable: { version: 1, aggregate: "id" },
schema: { id: Schema.String },
})
expect(EventV2.registry.get("test.out-of-order")).toBe(latest)
expect(Event.latest([latest, historical]).get("test.out-of-order")).toBe(latest)
expect(Event.latest([historical, latest]).get("test.out-of-order")).toBe(latest)
}),
)
@ -363,19 +356,19 @@ describe("EventV2", () => {
it.effect("replays durable aggregate events after a sequence and tails new events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
const aggregateID = Session.ID.create()
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
const fiber = yield* events
.durable({ aggregateID, after: 0 })
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
yield* events.publish(DurableMessage, durableData(aggregateID, "two"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[1, { id: aggregateID, text: "one" }],
[2, { id: aggregateID, text: "two" }],
[1, durableData(aggregateID, "one")],
[2, durableData(aggregateID, "two")],
])
}),
)
@ -383,20 +376,15 @@ describe("EventV2", () => {
it.effect("catches durable aggregate events published during replay handoff", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
const aggregateID = Session.ID.create()
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
expect(
Array.from(yield* Fiber.join(fiber)).map((event) => [
event.durable?.seq,
(event.data as { text: string }).text,
]),
).toEqual([
[0, "zero"],
[1, "one"],
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "zero")],
[1, durableData(aggregateID, "one")],
])
}),
)
@ -415,16 +403,16 @@ describe("EventV2", () => {
yield* Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Deferred.await(readStarted)
pause = false
yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" })
yield* events.publish(DurableMessage, durableData(aggregateID, "during handoff"))
yield* Deferred.succeed(continueRead, undefined)
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, { id: aggregateID, text: "during handoff" }],
[0, durableData(aggregateID, "during handoff")],
])
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
}),
@ -433,7 +421,7 @@ describe("EventV2", () => {
it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
const count = 64
const fiber = yield* events
.durable({ aggregateID })
@ -441,11 +429,11 @@ describe("EventV2", () => {
yield* Effect.yieldNow
for (let index = 0; index < count; index++) {
yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) })
yield* events.publish(DurableMessage, durableData(aggregateID, String(index)))
}
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
Array.from({ length: count }, (_, index) => [index, { id: aggregateID, text: String(index) }]),
Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
)
}),
)
@ -453,14 +441,14 @@ describe("EventV2", () => {
it.effect("omits live-only events from durable aggregate streams", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* events.publish(Message, { text: "live only" })
yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" })
yield* events.publish(DurableMessage, durableData(aggregateID, "durable"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([SyncMessage.type])
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
}),
)
@ -487,23 +475,23 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
yield* events.project(SyncMessage, (event) =>
yield* events.project(DurableMessage, (event) =>
Effect.sync(() => {
received.push(event)
}),
)
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
yield* events.replay({
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "hello" },
data: durableData(aggregateID, "hello"),
})
expect(received[0]?.type).toBe(SyncMessage.type)
expect(received[0]?.data).toEqual({ id: aggregateID, text: "hello" })
expect(received[0]?.type).toBe(DurableMessage.type)
expect(received[0]?.data).toEqual(durableData(aggregateID, "hello"))
}),
)
@ -511,14 +499,14 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
yield* events.replay({
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "replayed" },
data: durableData(aggregateID, "replayed"),
})
const rows = yield* db
.select()
@ -538,11 +526,11 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const envelopeAggregateID = EventV2.ID.create()
const payloadAggregateID = EventV2.ID.create()
const envelopeAggregateID = Session.ID.create()
const payloadAggregateID = Session.ID.create()
const received = new Array<EventV2.Payload>()
yield* events.publish(SyncMessage, { id: payloadAggregateID, text: "seed" })
yield* events.project(SyncMessage, (event) =>
yield* events.publish(DurableMessage, durableData(payloadAggregateID, "seed"))
yield* events.project(DurableMessage, (event) =>
Effect.sync(() => {
received.push(event)
}),
@ -551,10 +539,10 @@ describe("EventV2", () => {
const exit = yield* events
.replay({
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID: envelopeAggregateID,
data: { id: payloadAggregateID, text: "replayed" },
data: durableData(payloadAggregateID, "replayed"),
})
.pipe(Effect.exit)
const rows = yield* db
@ -580,22 +568,22 @@ describe("EventV2", () => {
it.effect("replay defects on sequence mismatch", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
yield* events.replay({
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "first" },
data: durableData(aggregateID, "first"),
})
const exit = yield* events
.replay({
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 5,
aggregateID,
data: { id: aggregateID, text: "bad" },
data: durableData(aggregateID, "bad"),
})
.pipe(Effect.exit)
@ -606,9 +594,9 @@ describe("EventV2", () => {
it.effect("replay decodes synchronized transformed values before projection", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const received = new Array<typeof SyncTimestamp.Type>()
yield* events.project(SyncTimestamp, (event) =>
const aggregateID = Session.ID.create()
const received = new Array<typeof SessionEvent.ContextUpdated.Type>()
yield* events.project(SessionEvent.ContextUpdated, (event) =>
Effect.sync(() => {
received.push(event)
}),
@ -616,10 +604,10 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncTimestamp.type, 1),
type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, timestamp: 0 },
data: { sessionID: aggregateID, messageID: "msg_context", timestamp: 0, text: "context" },
})
expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0))
@ -646,21 +634,21 @@ describe("EventV2", () => {
it.effect("replayAll validates contiguous aggregate events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
const source = yield* events.replayAll([
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "one" },
data: durableData(aggregateID, "one"),
},
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
data: { id: aggregateID, text: "two" },
data: durableData(aggregateID, "two"),
},
])
@ -672,38 +660,38 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
const one = yield* events.replayAll([
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "one" },
data: durableData(aggregateID, "one"),
},
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
data: { id: aggregateID, text: "two" },
data: durableData(aggregateID, "two"),
},
])
const two = yield* events.replayAll([
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 2,
aggregateID,
data: { id: aggregateID, text: "three" },
data: durableData(aggregateID, "three"),
},
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 3,
aggregateID,
data: { id: aggregateID, text: "four" },
data: durableData(aggregateID, "four"),
},
])
const rows = yield* db
@ -723,10 +711,10 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" })
const aggregateID = Session.ID.create()
yield* events.publish(DurableMessage, durableData(aggregateID, "seed"))
yield* events.claim(aggregateID, "owner-a")
yield* events.project(SyncMessage, (event) =>
yield* events.project(DurableMessage, (event) =>
Effect.sync(() => {
received.push(event)
}),
@ -735,10 +723,10 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
data: { id: aggregateID, text: "ignored" },
data: durableData(aggregateID, "ignored"),
},
{ ownerID: "owner-b" },
)
@ -750,14 +738,14 @@ describe("EventV2", () => {
it.effect("strict owner fences exact replay", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
const id = EventV2.ID.create()
const replayed = {
id,
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "owned" },
data: durableData(aggregateID, "owned"),
}
yield* events.replay(replayed, { ownerID: "owner-a" })
@ -771,11 +759,11 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const aggregateID = EventV2.ID.create()
const published = yield* events.publish(SyncMessage, { id: aggregateID, text: "owned" })
const aggregateID = Session.ID.create()
const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned"))
const replayed = {
id: published.id,
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: published.durable!.seq,
aggregateID,
data: published.data,
@ -792,7 +780,7 @@ describe("EventV2", () => {
expect(row?.ownerID).toBe("owner-a")
const exit = yield* events
.replay(
{ ...replayed, id: EventV2.ID.create(), seq: 1, data: { id: aggregateID, text: "conflict" } },
{ ...replayed, id: EventV2.ID.create(), seq: 1, data: durableData(aggregateID, "conflict") },
{ ownerID: "owner-b", strictOwner: true },
)
.pipe(Effect.exit)
@ -804,15 +792,15 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
yield* events.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "owned" },
data: durableData(aggregateID, "owned"),
},
{ ownerID: "owner-1" },
)
@ -831,26 +819,26 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "local" })
const aggregateID = Session.ID.create()
yield* events.publish(DurableMessage, durableData(aggregateID, "local"))
yield* events.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
data: { id: aggregateID, text: "claimed" },
data: durableData(aggregateID, "claimed"),
},
{ ownerID: "owner-1" },
)
yield* events.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 2,
aggregateID,
data: { id: aggregateID, text: "fenced" },
data: durableData(aggregateID, "fenced"),
},
{ ownerID: "owner-2" },
)
@ -875,14 +863,14 @@ describe("EventV2", () => {
it.effect("strict replay rejects an owner conflict instead of silently skipping it", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
yield* events.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "claimed" },
data: durableData(aggregateID, "claimed"),
},
{ ownerID: "owner-1" },
)
@ -891,10 +879,10 @@ describe("EventV2", () => {
.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
data: { id: aggregateID, text: "conflict" },
data: durableData(aggregateID, "conflict"),
},
{ ownerID: "owner-2", strictOwner: true },
)
@ -908,14 +896,14 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
yield* events.listen((event) => Effect.sync(() => received.push(event)))
const replayed = {
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "replayed" },
data: durableData(aggregateID, "replayed"),
}
yield* events.replay(replayed, { publish: true })
@ -929,19 +917,19 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
const replayed = {
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "original" },
data: durableData(aggregateID, "original"),
}
yield* events.listen((event) => Effect.sync(() => received.push(event)))
yield* events.replay(replayed, { publish: true })
const exit = yield* events
.replay({ ...replayed, data: { id: aggregateID, text: "divergent" } }, { publish: true })
.replay({ ...replayed, data: durableData(aggregateID, "divergent") }, { publish: true })
.pipe(Effect.exit)
expect(String(exit)).toContain("Replay diverged")
@ -952,23 +940,23 @@ describe("EventV2", () => {
it.effect("rejects an event ID reused at another aggregate position", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
const id = EventV2.ID.create()
yield* events.replay({
id,
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "first" },
data: durableData(aggregateID, "first"),
})
const exit = yield* events
.replay({
id,
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
data: { id: aggregateID, text: "second" },
data: durableData(aggregateID, "second"),
})
.pipe(Effect.exit)
@ -980,27 +968,27 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const aggregateID = EventV2.ID.create()
const aggregateID = Session.ID.create()
const received = new Array<EventV2.Payload>()
yield* events.listen((event) => Effect.sync(() => received.push(event)))
yield* events.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "first" },
data: durableData(aggregateID, "first"),
},
{ ownerID: "owner-1" },
)
yield* events.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
data: { id: aggregateID, text: "ignored" },
data: durableData(aggregateID, "ignored"),
},
{ ownerID: "owner-2", publish: true },
)
@ -1047,10 +1035,10 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" })
const aggregateID = Session.ID.create()
yield* events.publish(DurableMessage, durableData(aggregateID, "seed"))
yield* events.remove(aggregateID)
yield* events.project(SyncMessage, (event) =>
yield* events.project(DurableMessage, (event) =>
Effect.sync(() => {
received.push(event)
}),
@ -1058,13 +1046,13 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "replayed" },
data: durableData(aggregateID, "replayed"),
})
expect(received[0]?.data).toEqual({ id: aggregateID, text: "replayed" })
expect(received[0]?.data).toEqual(durableData(aggregateID, "replayed"))
}),
)
})

View file

@ -4,7 +4,7 @@ import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { branch, commit, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@ -16,14 +16,16 @@ describe("Git", () => {
withRemote((fixture) =>
Effect.gen(function* () {
const git = yield* Git.Service
const target = path.join(fixture.root, "checkout")
const result = yield* git.clone({ remote: fixture.remote, target })
const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
expect(result.exitCode).toBe(0)
expect(yield* git.origin(target)).toBe(fixture.remote)
expect(yield* git.head(target)).toBeString()
expect(yield* git.branch(target)).toBe("main")
expect(yield* git.remoteHead(target)).toBe("origin/main")
expect(yield* git.remote.get(repository)).toBe(fixture.remote)
expect(yield* git.history.head(repository)).toBeString()
expect(yield* git.history.branch(repository)).toBe("main")
expect(yield* git.history.defaultRemoteBranch(repository)).toBe("main")
expect(repository.worktree).toBe(target)
expect(repository.gitDirectory).toBe(AbsolutePath.make(path.join(target, ".git")))
expect(repository.commonDirectory).toBe(repository.gitDirectory)
expect(yield* read(path.join(target, "README.md"))).toBe("one\n")
}),
),
@ -33,19 +35,19 @@ describe("Git", () => {
withRemote((fixture) =>
Effect.gen(function* () {
const git = yield* Git.Service
const target = path.join(fixture.root, "checkout")
yield* git.clone({ remote: fixture.remote, target })
const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
yield* Effect.promise(() => commit(fixture.source, "two\n", "second"))
expect((yield* git.fetch(target)).exitCode).toBe(0)
expect((yield* git.reset(target, "origin/main")).exitCode).toBe(0)
yield* git.sync.fetchRemotes(repository)
yield* git.sync.resetHard(repository, "origin/main")
expect(yield* read(path.join(target, "README.md"))).toBe("two\n")
yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n"))
expect((yield* git.fetchBranch(target, "feature/docs")).exitCode).toBe(0)
expect((yield* git.checkout(target, "feature/docs")).exitCode).toBe(0)
expect((yield* git.reset(target, "origin/feature/docs")).exitCode).toBe(0)
expect(yield* git.branch(target)).toBe("feature/docs")
yield* git.sync.fetchBranch(repository, { branch: "feature/docs" })
yield* git.sync.checkoutRemoteBranch(repository, { branch: "feature/docs" })
yield* git.sync.resetHard(repository, "origin/feature/docs")
expect(yield* git.history.branch(repository)).toBe("feature/docs")
expect(yield* read(path.join(target, "README.md"))).toBe("feature\n")
}),
),
@ -90,17 +92,72 @@ describe("Git worktrees", () => {
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
)
const git = yield* Git.Service
const repo = { directory, store: AbsolutePath.make(path.join(directory, ".git")) }
const repo = yield* git.repo.discover(directory)
if (!repo) throw new Error("Repository not found")
yield* git.worktreeCreate({ repo, directory: worktree })
yield* git.worktree.create({ repository: repo, directory: worktree })
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true)
const linked = yield* git.find(worktree)
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
expect(linked?.store).toBe(repo.store)
expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(true)
const linked = yield* git.repo.discover(worktree)
expect(linked?.worktree).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
expect(linked?.commonDirectory).toBe(repo.commonDirectory)
expect(linked?.gitDirectory).not.toBe(repo.gitDirectory)
if (!linked) throw new Error("Linked worktree not found")
yield* git.worktreeRemove({ repo: linked, directory: worktree, force: false })
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
yield* git.worktree.remove({ repository: linked, directory: worktree, force: false })
expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(false)
}),
)
})
describe("Git trees", () => {
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await initRepo(root.path)
await fs.mkdir(path.join(root.path, "scope"))
await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "one\n")
await fs.writeFile(path.join(root.path, "outside.txt"), "outside\n")
await $`git add .`.cwd(root.path).quiet()
await $`git commit -m initial`.cwd(root.path).quiet()
})
const git = yield* Git.Service
const source = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!source) throw new Error("Repository not found")
const storage = AbsolutePath.make(path.join(root.path, ".snapshot"))
const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
const before = yield* git.tree.write(repository)
yield* Effect.promise(async () => {
await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "two\n")
await fs.writeFile(path.join(root.path, "scope", "added.txt"), "added\n")
await fs.writeFile(path.join(root.path, "outside.txt"), "changed outside\n")
})
yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
const after = yield* git.tree.write(repository)
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
RelativePath.make("scope/added.txt"),
RelativePath.make("scope/tracked.txt"),
])
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
expect(diffs.map((item) => [item.path, item.status])).toEqual([
[RelativePath.make("scope/added.txt"), "added"],
[RelativePath.make("scope/tracked.txt"), "modified"],
])
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
expect(preview).toHaveLength(1)
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
yield* git.tree.restore({ repository, files })
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
expect(yield* read(path.join(root.path, "outside.txt"))).toBe("changed outside\n")
}),
)
})

View file

@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { SessionV1 as Wire } from "@opencode-ai/schema/session-v1"
import { SessionV1 } from "../src/v1/session"
describe("legacy event schema compatibility", () => {
test("Core references canonical SessionV1 definitions", () => {
expect(SessionV1.Event.Created).toBe(Wire.Event.Created)
expect(SessionV1.Event.PartUpdated).toBe(Wire.Event.PartUpdated)
})
test("Core retains NamedError constructor identity", () => {
const error = new SessionV1.APIError({ message: "failed", isRetryable: false })
expect(error).toBeInstanceOf(SessionV1.APIError)
expect(error.toObject()).toEqual({ name: "APIError", data: { message: "failed", isRetryable: false } })
})
})

View file

@ -2,7 +2,7 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
import { Tool } from "@opencode-ai/core/public"
import { Tool } from "@opencode-ai/core/tool/tool"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"

View file

@ -14,6 +14,7 @@ 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 { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
@ -28,6 +29,7 @@ const project = Project.layer.pipe(
Layer.provide(ProjectDirectories.defaultLayer),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(project),

View file

@ -11,6 +11,7 @@ import { Project } 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 { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
@ -23,6 +24,7 @@ const current = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),

View file

@ -163,7 +163,7 @@ describe("OpencodePlugin", () => {
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("disabled"))).enabled,
).toBe(false)
expect(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"))).toBeUndefined()
expect(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"))).toBeDefined()
expect(authorization).toContain("Bearer secret")
}),
({ server }) => Effect.promise(() => server.stop(true)),

View file

@ -1,76 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
import { testEffect } from "./lib/effect"
const it = testEffect(OpenCode.layer)
describe("public native OpenCode API", () => {
it.effect("exposes only the intentional Session capabilities", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"])
expect(Object.keys(opencode.sessions).sort()).toEqual([
"context",
"create",
"events",
"get",
"interrupt",
"list",
"message",
"messages",
"prompt",
"switchModel",
])
expect(Session.ID.create()).toStartWith("ses_")
expect(Session.MessageID.create()).toStartWith("msg_")
expect(yield* opencode.sessions.list()).toBeArray()
yield* opencode.tools.register({
public_tool: Tool.make({
description: "Public tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
}),
)
it.effect("records model selection without resolving the Location catalog", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_deferred")
const model = Schema.decodeUnknownSync(Model.Ref)({
id: "missing",
providerID: "missing",
variant: "unknown",
})
yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }),
})
yield* opencode.sessions.switchModel({ sessionID, model })
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
}),
)
it.effect("preserves the typed not-found error for a missing Session", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_missing")
const error = yield* opencode.sessions
.switchModel({
sessionID,
model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }),
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Session.NotFoundError)
if (error instanceof Session.NotFoundError) expect(error.sessionID).toBe(sessionID)
}),
)
})

View file

@ -1,13 +0,0 @@
import { describe, expect, it } from "bun:test"
import { Tool } from "@opencode-ai/core/public"
import { Effect } from "effect"
describe("public Tool API", () => {
it("keeps the public registration capability narrow", () => {
const tools = {
register: () => Effect.void,
} satisfies Tool.Interface
expect(Object.keys(tools)).toEqual(["register"])
})
})

View file

@ -13,6 +13,7 @@ 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 { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@ -34,6 +35,7 @@ const projects = Layer.succeed(
}),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),

View file

@ -10,6 +10,7 @@ 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 { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
@ -20,6 +21,7 @@ import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
import { Snapshot } from "@opencode-ai/core/snapshot"
const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer))
const sessionID = SessionV2.ID.make("ses_projector_test")
@ -41,6 +43,58 @@ const assistantRow = (
}
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()
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
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, timestamp: DateTime.makeUnsafe(2) })
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull()
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
timestamp: DateTime.makeUnsafe(3),
revert: { messageID: boundary, files: [] },
})
yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID,
messageID: boundary,
timestamp: DateTime.makeUnsafe(4),
})
expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
).toEqual([boundary])
}),
)
it.effect("orders projected messages and context by durable aggregate sequence", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
@ -110,6 +164,7 @@ describe("SessionProjector", () => {
}).pipe(
Effect.provide(
SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(Project.defaultLayer),

View file

@ -9,6 +9,7 @@ import { Project } 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 { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@ -39,6 +40,7 @@ const execution = Layer.succeed(
}),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),

View file

@ -12,6 +12,8 @@ import { Project } 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 { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@ -71,6 +73,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(Snapshot.noopLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(EventV2.defaultLayer),
@ -99,6 +102,7 @@ const execution = Layer.effect(
}),
).pipe(Layer.provide(runner))
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),

View file

@ -125,3 +125,12 @@ test("old success event data containing result still decodes", () => {
})
expect(decoded.result).toMatchObject({ type: "content" })
})
test("step finish records settlement without publishing step ended", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false)
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
})

View file

@ -19,6 +19,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { QuestionV2 } from "@opencode-ai/core/question"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInput } from "@opencode-ai/core/session/input"
@ -230,6 +232,7 @@ const config = Layer.succeed(
}),
)
const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(Snapshot.noopLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(EventV2.defaultLayer),
@ -258,6 +261,7 @@ const execution = Layer.effect(
}),
).pipe(Layer.provide(runner))
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),

View file

@ -0,0 +1,189 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
describe("Snapshot", () => {
testEffect(Layer.empty).live("captures and restores Location-scoped changes", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const location = path.join(project, "scope")
yield* Effect.promise(async () => {
await fs.mkdir(location, { recursive: true })
await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
await fs.writeFile(path.join(project, "outside.txt"), "outside\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
})
const layer = snapshotLayer(tmp.path, location)
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
expect(before).toBeDefined()
if (!before) return
yield* Effect.promise(async () => {
await fs.writeFile(path.join(location, "tracked.txt"), "two\n")
await fs.writeFile(path.join(location, "added.txt"), "added\n")
await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n")
})
const after = yield* snapshot.capture()
expect(after).toBeDefined()
if (!after) return
expect(yield* snapshot.files({ from: before, to: after })).toEqual([
RelativePath.make("scope/added.txt"),
RelativePath.make("scope/tracked.txt"),
])
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
const preview = yield* snapshot.preview({ files: plan, context: 1 })
expect(preview).toHaveLength(1)
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
yield* snapshot.restore({ files: plan })
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n")
}).pipe(Effect.provide(layer))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
expect(
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
return yield* snapshot.capture()
}).pipe(Effect.provide(snapshotLayer(tmp.path, tmp.path))),
).toBeUndefined()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const linked = path.join(tmp.path, "linked")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet()
})
const capture = (directory: string) =>
Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
return yield* snapshot.capture()
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
expect(yield* capture(project)).toBeDefined()
expect(yield* capture(linked)).toBeDefined()
const projectID = yield* Effect.gen(function* () {
return (yield* Location.Service).project.id
}).pipe(
Effect.provide(
Location.layer(Location.Ref.make({ directory: AbsolutePath.make(project) })).pipe(
Layer.provide(Project.defaultLayer),
),
),
)
expect(
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
).toBeDefined()
expect(
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
).toBeDefined()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
expect(before).toBeDefined()
if (!before) return
yield* Effect.promise(async () => {
await fs.writeFile(path.join(project, "tracked.txt"), "two\n")
await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n")
})
yield* snapshot.checkout(before)
expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n")
expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n")
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
})
function snapshotLayer(data: string, directory: string) {
const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
Layer.provide(Project.defaultLayer),
)
return Snapshot.layer.pipe(
Layer.provide(location),
Layer.provide(Config.locationLayer.pipe(Layer.provide(location))),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Global.layerWith({ data, config: path.join(data, "config") })),
)
}
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
}