Files
opencode/packages/core/test/session-revert.test.ts

118 lines
5.5 KiB
TypeScript

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 { 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 { tmpdir } 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, Bus.configured({ persist: true })],
[Global.node, tempGlobalLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
describe("Session.revert files", () => {
it.live("undoes and restores a file rename without losing either path", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
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")
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)))
}),
)
})