| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638 |
- import { describe, expect } from "bun:test"
- import { SessionV1 } from "@opencode-ai/core/v1/session"
- import { LayerNode } from "@opencode-ai/core/effect/layer-node"
- import { SessionProjector } from "@opencode-ai/core/session/projector"
- import fs from "fs/promises"
- import path from "path"
- import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
- import { Effect } from "effect"
- import { Session } from "@/session/session"
- import { SessionRevert } from "../../src/session/revert"
- import { MessageV2 } from "../../src/session/message-v2"
- import { Snapshot } from "../../src/snapshot"
- import { MessageID, PartID, SessionID } from "../../src/session/schema"
- import { provideTmpdirInstance } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- import { ProviderV2 } from "@opencode-ai/core/provider"
- import { ModelV2 } from "@opencode-ai/core/model"
- const it = testEffect(
- LayerNode.compile(
- LayerNode.group([Session.node, SessionRevert.node, Snapshot.node, SessionProjector.node, CrossSpawnSpawner.node]),
- ),
- )
- const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") {
- const session = yield* Session.Service
- return yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "user" as const,
- sessionID,
- agent,
- model: { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-4") },
- time: { created: Date.now() },
- })
- })
- const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, parentID: MessageID, dir: string) {
- const session = yield* Session.Service
- return yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "assistant" as const,
- sessionID,
- mode: "default",
- agent: "default",
- path: { cwd: dir, root: dir },
- cost: 0,
- tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
- modelID: ModelV2.ID.make("gpt-4"),
- providerID: ProviderV2.ID.make("openai"),
- parentID,
- time: { created: Date.now() },
- finish: "end_turn",
- })
- })
- const text = Effect.fn("test.text")(function* (sessionID: SessionID, messageID: MessageID, content: string) {
- const session = yield* Session.Service
- return yield* session.updatePart({
- id: PartID.ascending(),
- messageID,
- sessionID,
- type: "text" as const,
- text: content,
- })
- })
- const tool = Effect.fn("test.tool")(function* (sessionID: SessionID, messageID: MessageID) {
- const session = yield* Session.Service
- return yield* session.updatePart({
- id: PartID.ascending(),
- messageID,
- sessionID,
- type: "tool" as const,
- tool: "bash",
- callID: "call-1",
- state: {
- status: "completed" as const,
- input: {},
- output: "done",
- title: "",
- metadata: {},
- time: { start: 0, end: 1 },
- },
- })
- })
- const read = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8"))
- const write = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text))
- const tokens = {
- input: 0,
- output: 0,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- }
- describe("revert + compact workflow", () => {
- it.live(
- "should properly handle compact command after revert",
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const session = yield* Session.Service
- const revert = yield* SessionRevert.Service
- const info = yield* session.create({})
- const sessionID = info.id
- const userMsg1 = yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID,
- agent: "default",
- model: {
- providerID: ProviderV2.ID.make("openai"),
- modelID: ModelV2.ID.make("gpt-4"),
- },
- time: {
- created: Date.now(),
- },
- })
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: userMsg1.id,
- sessionID,
- type: "text",
- text: "Hello, please help me",
- })
- const assistantMsg1: SessionV1.Assistant = {
- id: MessageID.ascending(),
- role: "assistant",
- sessionID,
- mode: "default",
- agent: "default",
- path: {
- cwd: dir,
- root: dir,
- },
- cost: 0,
- tokens: {
- output: 0,
- input: 0,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- },
- modelID: ModelV2.ID.make("gpt-4"),
- providerID: ProviderV2.ID.make("openai"),
- parentID: userMsg1.id,
- time: {
- created: Date.now(),
- },
- finish: "end_turn",
- }
- yield* session.updateMessage(assistantMsg1)
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: assistantMsg1.id,
- sessionID,
- type: "text",
- text: "Sure, I'll help you!",
- })
- const userMsg2 = yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID,
- agent: "default",
- model: {
- providerID: ProviderV2.ID.make("openai"),
- modelID: ModelV2.ID.make("gpt-4"),
- },
- time: {
- created: Date.now(),
- },
- })
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: userMsg2.id,
- sessionID,
- type: "text",
- text: "What's the capital of France?",
- })
- const assistantMsg2: SessionV1.Assistant = {
- id: MessageID.ascending(),
- role: "assistant",
- sessionID,
- mode: "default",
- agent: "default",
- path: {
- cwd: dir,
- root: dir,
- },
- cost: 0,
- tokens: {
- output: 0,
- input: 0,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- },
- modelID: ModelV2.ID.make("gpt-4"),
- providerID: ProviderV2.ID.make("openai"),
- parentID: userMsg2.id,
- time: {
- created: Date.now(),
- },
- finish: "end_turn",
- }
- yield* session.updateMessage(assistantMsg2)
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: assistantMsg2.id,
- sessionID,
- type: "text",
- text: "The capital of France is Paris.",
- })
- let messages = yield* session.messages({ sessionID })
- expect(messages.length).toBe(4)
- const messageIds = messages.map((m) => m.info.id)
- expect(messageIds).toContain(userMsg1.id)
- expect(messageIds).toContain(userMsg2.id)
- expect(messageIds).toContain(assistantMsg1.id)
- expect(messageIds).toContain(assistantMsg2.id)
- yield* revert.revert({
- sessionID,
- messageID: userMsg2.id,
- })
- let sessionInfo = yield* session.get(sessionID)
- expect(sessionInfo.revert).toBeDefined()
- expect(sessionInfo.revert?.messageID).toBeDefined()
- messages = yield* session.messages({ sessionID })
- expect(messages.length).toBe(4)
- yield* revert.cleanup(sessionInfo)
- messages = yield* session.messages({ sessionID })
- const remainingIds = messages.map((m) => m.info.id)
- expect(messages.length).toBeLessThan(4)
- expect(remainingIds).not.toContain(userMsg2.id)
- expect(remainingIds).not.toContain(assistantMsg2.id)
- sessionInfo = yield* session.get(sessionID)
- expect(sessionInfo.revert).toBeUndefined()
- yield* session.remove(sessionID)
- }),
- { git: true },
- ),
- )
- it.live(
- "should properly clean up revert state before creating compaction message",
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const session = yield* Session.Service
- const revert = yield* SessionRevert.Service
- const info = yield* session.create({})
- const sessionID = info.id
- const userMsg = yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID,
- agent: "default",
- model: {
- providerID: ProviderV2.ID.make("openai"),
- modelID: ModelV2.ID.make("gpt-4"),
- },
- time: {
- created: Date.now(),
- },
- })
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: userMsg.id,
- sessionID,
- type: "text",
- text: "Hello",
- })
- const assistantMsg: SessionV1.Assistant = {
- id: MessageID.ascending(),
- role: "assistant",
- sessionID,
- mode: "default",
- agent: "default",
- path: {
- cwd: dir,
- root: dir,
- },
- cost: 0,
- tokens: {
- output: 0,
- input: 0,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- },
- modelID: ModelV2.ID.make("gpt-4"),
- providerID: ProviderV2.ID.make("openai"),
- parentID: userMsg.id,
- time: {
- created: Date.now(),
- },
- finish: "end_turn",
- }
- yield* session.updateMessage(assistantMsg)
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: assistantMsg.id,
- sessionID,
- type: "text",
- text: "Hi there!",
- })
- yield* revert.revert({
- sessionID,
- messageID: userMsg.id,
- })
- let sessionInfo = yield* session.get(sessionID)
- expect(sessionInfo.revert).toBeDefined()
- yield* revert.cleanup(sessionInfo)
- sessionInfo = yield* session.get(sessionID)
- expect(sessionInfo.revert).toBeUndefined()
- const messages = yield* session.messages({ sessionID })
- expect(messages.length).toBe(0)
- yield* session.remove(sessionID)
- }),
- { git: true },
- ),
- )
- it.live(
- "cleanup with partID removes parts from the revert point onward",
- provideTmpdirInstance(
- () =>
- Effect.gen(function* () {
- const session = yield* Session.Service
- const revert = yield* SessionRevert.Service
- const info = yield* session.create({})
- const sid = info.id
- const u1 = yield* user(sid)
- const p1 = yield* text(sid, u1.id, "first part")
- const p2 = yield* tool(sid, u1.id)
- yield* text(sid, u1.id, "third part")
- yield* session.setRevert({
- sessionID: sid,
- revert: { messageID: u1.id, partID: p2.id },
- summary: { additions: 0, deletions: 0, files: 0 },
- })
- const state = yield* session.get(sid)
- yield* revert.cleanup(state)
- const msgs = yield* session.messages({ sessionID: sid })
- expect(msgs.length).toBe(1)
- expect(msgs[0].parts.length).toBe(1)
- expect(msgs[0].parts[0].id).toBe(p1.id)
- const cleared = yield* session.get(sid)
- expect(cleared.revert).toBeUndefined()
- }),
- { git: true },
- ),
- )
- it.live(
- "cleanup removes messages after revert point but keeps earlier ones",
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const session = yield* Session.Service
- const revert = yield* SessionRevert.Service
- const info = yield* session.create({})
- const sid = info.id
- const u1 = yield* user(sid)
- yield* text(sid, u1.id, "hello")
- const a1 = yield* assistant(sid, u1.id, dir)
- yield* text(sid, a1.id, "hi back")
- const u2 = yield* user(sid)
- yield* text(sid, u2.id, "second question")
- const a2 = yield* assistant(sid, u2.id, dir)
- yield* text(sid, a2.id, "second answer")
- yield* session.setRevert({
- sessionID: sid,
- revert: { messageID: u2.id },
- summary: { additions: 0, deletions: 0, files: 0 },
- })
- const state = yield* session.get(sid)
- yield* revert.cleanup(state)
- const msgs = yield* session.messages({ sessionID: sid })
- const ids = msgs.map((m) => m.info.id)
- expect(ids).toContain(u1.id)
- expect(ids).toContain(a1.id)
- expect(ids).not.toContain(u2.id)
- expect(ids).not.toContain(a2.id)
- }),
- { git: true },
- ),
- )
- it.live(
- "cleanup is a no-op when session has no revert state",
- provideTmpdirInstance(
- () =>
- Effect.gen(function* () {
- const session = yield* Session.Service
- const revert = yield* SessionRevert.Service
- const info = yield* session.create({})
- const sid = info.id
- const u1 = yield* user(sid)
- yield* text(sid, u1.id, "hello")
- const state = yield* session.get(sid)
- expect(state.revert).toBeUndefined()
- yield* revert.cleanup(state)
- const msgs = yield* session.messages({ sessionID: sid })
- expect(msgs.length).toBe(1)
- }),
- { git: true },
- ),
- )
- it.live(
- "restore messages in sequential order",
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const session = yield* Session.Service
- const revert = yield* SessionRevert.Service
- const snapshot = yield* Snapshot.Service
- yield* write(path.join(dir, "a.txt"), "a0")
- yield* write(path.join(dir, "b.txt"), "b0")
- yield* write(path.join(dir, "c.txt"), "c0")
- const info = yield* session.create({})
- const sid = info.id
- const turn = Effect.fn("test.turn")(function* (file: string, next: string) {
- const u = yield* user(sid)
- yield* text(sid, u.id, `${file}:${next}`)
- const a = yield* assistant(sid, u.id, dir)
- const before = yield* snapshot.track()
- if (!before) throw new Error("expected snapshot")
- yield* write(path.join(dir, file), next)
- const after = yield* snapshot.track()
- if (!after) throw new Error("expected snapshot")
- const patch = yield* snapshot.patch(before)
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: a.id,
- sessionID: sid,
- type: "step-start",
- snapshot: before,
- })
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: a.id,
- sessionID: sid,
- type: "step-finish",
- reason: "stop",
- snapshot: after,
- cost: 0,
- tokens,
- })
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: a.id,
- sessionID: sid,
- type: "patch",
- hash: patch.hash,
- files: patch.files,
- })
- return u.id
- })
- const first = yield* turn("a.txt", "a1")
- const second = yield* turn("b.txt", "b2")
- const third = yield* turn("c.txt", "c3")
- yield* revert.revert({
- sessionID: sid,
- messageID: first,
- })
- expect((yield* session.get(sid)).revert?.messageID).toBe(first)
- expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
- expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
- expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
- yield* revert.revert({
- sessionID: sid,
- messageID: second,
- })
- expect((yield* session.get(sid)).revert?.messageID).toBe(second)
- expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
- expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
- expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
- yield* revert.revert({
- sessionID: sid,
- messageID: third,
- })
- expect((yield* session.get(sid)).revert?.messageID).toBe(third)
- expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
- expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
- expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
- yield* revert.unrevert({
- sessionID: sid,
- })
- expect((yield* session.get(sid)).revert).toBeUndefined()
- expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
- expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
- expect(yield* read(path.join(dir, "c.txt"))).toBe("c3")
- }),
- { git: true },
- ),
- )
- it.live(
- "restore same file in sequential order",
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const session = yield* Session.Service
- const revert = yield* SessionRevert.Service
- const snapshot = yield* Snapshot.Service
- yield* write(path.join(dir, "a.txt"), "a0")
- const info = yield* session.create({})
- const sid = info.id
- const turn = Effect.fn("test.turnSame")(function* (next: string) {
- const u = yield* user(sid)
- yield* text(sid, u.id, `a.txt:${next}`)
- const a = yield* assistant(sid, u.id, dir)
- const before = yield* snapshot.track()
- if (!before) throw new Error("expected snapshot")
- yield* write(path.join(dir, "a.txt"), next)
- const after = yield* snapshot.track()
- if (!after) throw new Error("expected snapshot")
- const patch = yield* snapshot.patch(before)
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: a.id,
- sessionID: sid,
- type: "step-start",
- snapshot: before,
- })
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: a.id,
- sessionID: sid,
- type: "step-finish",
- reason: "stop",
- snapshot: after,
- cost: 0,
- tokens,
- })
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: a.id,
- sessionID: sid,
- type: "patch",
- hash: patch.hash,
- files: patch.files,
- })
- return u.id
- })
- const first = yield* turn("a1")
- const second = yield* turn("a2")
- const third = yield* turn("a3")
- expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
- yield* revert.revert({
- sessionID: sid,
- messageID: first,
- })
- expect((yield* session.get(sid)).revert?.messageID).toBe(first)
- expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
- yield* revert.revert({
- sessionID: sid,
- messageID: second,
- })
- expect((yield* session.get(sid)).revert?.messageID).toBe(second)
- expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
- yield* revert.revert({
- sessionID: sid,
- messageID: third,
- })
- expect((yield* session.get(sid)).revert?.messageID).toBe(third)
- expect(yield* read(path.join(dir, "a.txt"))).toBe("a2")
- yield* revert.unrevert({
- sessionID: sid,
- })
- expect((yield* session.get(sid)).revert).toBeUndefined()
- expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
- }),
- { git: true },
- ),
- )
- })
|