import { $ } from "bun" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect } from "effect" import { Agent } from "@opencode-ai/core/agent" import { Bus } from "@opencode-ai/core/bus" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { Model } from "@opencode-ai/core/model" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { Provider } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInbox } from "@opencode-ai/core/session/inbox" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionRevert } from "@opencode-ai/core/session/revert" import { Snapshot } from "@opencode-ai/core/snapshot" import { Money } from "@opencode-ai/schema/money" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Global } from "@opencode-ai/util/global" import { tempGlobalLayer } from "./fixture/global" import { tmpdirScoped } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]), [ Bus.node.replace(Bus.configured({ persist: true })), Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) describe("Session.revert files", () => { it.live( "undoes and restores a file rename without losing either path", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped() const directory = path.join(tmp.path, "project") const original = path.join(directory, "old name.txt") const renamed = path.join(directory, "new name.txt") yield* Effect.promise(async () => { await fs.mkdir(directory) await Bun.write(original, "Preserve this content.\n") await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated content.\n") await $`git init -q`.cwd(directory).quiet() await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet() }) const session = yield* Session.Service const database = yield* Database.Service const bus = yield* Bus.Service const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } }) const prompt = yield* session.prompt({ sessionID: created.id, text: "Rename the file", resume: false }) yield* SessionInbox.promote(database.db, bus, created.id, "steer") const services = LocationServiceMap.Service.get(created.location) const revert = yield* SessionRevert.Service.pipe(Effect.provide(services)) expect(yield* SessionRevert.Service.pipe(Effect.provide(services))).toBe(revert) yield* Effect.gen(function* () { const plugins = yield* PluginSupervisor.Service yield* plugins.flush const snapshot = yield* Snapshot.Service const before = yield* snapshot.capture() if (!before) throw new Error("Initial snapshot missing") const assistantMessageID = SessionMessage.ID.create() yield* bus.publish(SessionEvent.Step.Started, { sessionID: created.id, assistantMessageID, agent: Agent.defaultID, model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") }, snapshot: before, }) yield* Effect.promise(() => fs.rename(original, renamed)) const after = yield* snapshot.capture() if (!after) throw new Error("Renamed snapshot missing") yield* bus.publish(SessionEvent.Step.Ended, { sessionID: created.id, assistantMessageID, finish: "stop", cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, snapshot: after, files: yield* snapshot.files({ from: before, to: after }), }) yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n")) const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id }) expect({ original: yield* Effect.promise(() => Bun.file(original).exists()), renamed: yield* Effect.promise(() => Bun.file(renamed).exists()), }).toEqual({ original: true, renamed: false }) expect(yield* Effect.promise(() => Bun.file(original).text())).toBe("Preserve this content.\n") expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([ ["new name.txt", "deleted"], ["old name.txt", "added"], ]) expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe( "Keep this later edit.\n", ) yield* session.revert.clear(created.id) expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false) expect(yield* Effect.promise(() => Bun.file(renamed).text())).toBe("Preserve this content.\n") expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe( "Keep this later edit.\n", ) expect((yield* session.get(created.id)).revert).toBeUndefined() }).pipe(Effect.provide(LocationServiceMap.Service.get(created.location))) }), // Real Location/plugin startup and Git snapshots can exceed five seconds under CI load. { timeout: 15_000 }, ) })