import { describe, expect, test, beforeEach, afterEach } from "bun:test" import fs from "fs/promises" import path from "path" import { Session } from "../../src/session" import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionRevert } from "../../src/session/revert" import { SessionCompaction } from "../../src/session/compaction" import { MessageV2 } from "../../src/session/message-v2" import { Snapshot } from "../../src/snapshot" import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" import { MessageID, PartID } from "../../src/session/schema" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) function user(sessionID: string, agent = "default") { return Session.updateMessage({ id: MessageID.ascending(), role: "user" as const, sessionID: sessionID as any, agent, model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") }, time: { created: Date.now() }, }) } function assistant(sessionID: string, parentID: string, dir: string) { return Session.updateMessage({ id: MessageID.ascending(), role: "assistant" as const, sessionID: sessionID as any, mode: "default", agent: "default", path: { cwd: dir, root: dir }, cost: 0, tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: ModelID.make("gpt-4"), providerID: ProviderID.make("openai"), parentID: parentID as any, time: { created: Date.now() }, finish: "end_turn", }) } function text(sessionID: string, messageID: string, content: string) { return Session.updatePart({ id: PartID.ascending(), messageID: messageID as any, sessionID: sessionID as any, type: "text" as const, text: content, }) } function tool(sessionID: string, messageID: string) { return Session.updatePart({ id: PartID.ascending(), messageID: messageID as any, sessionID: sessionID as any, 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 tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 }, } describe("revert + compact workflow", () => { test("should properly handle compact command after revert", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { // Create a session const session = await Session.create({}) const sessionID = session.id // Create a user message const userMsg1 = await Session.updateMessage({ id: MessageID.ascending(), role: "user", sessionID, agent: "default", model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4"), }, time: { created: Date.now(), }, }) // Add a text part to the user message await Session.updatePart({ id: PartID.ascending(), messageID: userMsg1.id, sessionID, type: "text", text: "Hello, please help me", }) // Create an assistant response message const assistantMsg1: MessageV2.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID, mode: "default", agent: "default", path: { cwd: tmp.path, root: tmp.path, }, cost: 0, tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 }, }, modelID: ModelID.make("gpt-4"), providerID: ProviderID.make("openai"), parentID: userMsg1.id, time: { created: Date.now(), }, finish: "end_turn", } await Session.updateMessage(assistantMsg1) // Add a text part to the assistant message await Session.updatePart({ id: PartID.ascending(), messageID: assistantMsg1.id, sessionID, type: "text", text: "Sure, I'll help you!", }) // Create another user message const userMsg2 = await Session.updateMessage({ id: MessageID.ascending(), role: "user", sessionID, agent: "default", model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4"), }, time: { created: Date.now(), }, }) await Session.updatePart({ id: PartID.ascending(), messageID: userMsg2.id, sessionID, type: "text", text: "What's the capital of France?", }) // Create another assistant response const assistantMsg2: MessageV2.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID, mode: "default", agent: "default", path: { cwd: tmp.path, root: tmp.path, }, cost: 0, tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 }, }, modelID: ModelID.make("gpt-4"), providerID: ProviderID.make("openai"), parentID: userMsg2.id, time: { created: Date.now(), }, finish: "end_turn", } await Session.updateMessage(assistantMsg2) await Session.updatePart({ id: PartID.ascending(), messageID: assistantMsg2.id, sessionID, type: "text", text: "The capital of France is Paris.", }) // Verify messages before revert let messages = await Session.messages({ sessionID }) expect(messages.length).toBe(4) // 2 user + 2 assistant messages 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) // Revert the last user message (userMsg2) await SessionRevert.revert({ sessionID, messageID: userMsg2.id, }) // Check that revert state is set let sessionInfo = await Session.get(sessionID) expect(sessionInfo.revert).toBeDefined() const revertMessageID = sessionInfo.revert?.messageID expect(revertMessageID).toBeDefined() // Messages should still be in the list (not removed yet, just marked for revert) messages = await Session.messages({ sessionID }) expect(messages.length).toBe(4) // Now clean up the revert state (this is what the compact endpoint should do) await SessionRevert.cleanup(sessionInfo) // After cleanup, the reverted messages (those after the revert point) should be removed messages = await Session.messages({ sessionID }) const remainingIds = messages.map((m) => m.info.id) // The revert point is somewhere in the message chain, so we should have fewer messages expect(messages.length).toBeLessThan(4) // userMsg2 and assistantMsg2 should be removed (they come after the revert point) expect(remainingIds).not.toContain(userMsg2.id) expect(remainingIds).not.toContain(assistantMsg2.id) // Revert state should be cleared sessionInfo = await Session.get(sessionID) expect(sessionInfo.revert).toBeUndefined() // Clean up await Session.remove(sessionID) }, }) }) test("should properly clean up revert state before creating compaction message", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { // Create a session const session = await Session.create({}) const sessionID = session.id // Create initial messages const userMsg = await Session.updateMessage({ id: MessageID.ascending(), role: "user", sessionID, agent: "default", model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4"), }, time: { created: Date.now(), }, }) await Session.updatePart({ id: PartID.ascending(), messageID: userMsg.id, sessionID, type: "text", text: "Hello", }) const assistantMsg: MessageV2.Assistant = { id: MessageID.ascending(), role: "assistant", sessionID, mode: "default", agent: "default", path: { cwd: tmp.path, root: tmp.path, }, cost: 0, tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 }, }, modelID: ModelID.make("gpt-4"), providerID: ProviderID.make("openai"), parentID: userMsg.id, time: { created: Date.now(), }, finish: "end_turn", } await Session.updateMessage(assistantMsg) await Session.updatePart({ id: PartID.ascending(), messageID: assistantMsg.id, sessionID, type: "text", text: "Hi there!", }) // Revert the user message await SessionRevert.revert({ sessionID, messageID: userMsg.id, }) // Check that revert state is set let sessionInfo = await Session.get(sessionID) expect(sessionInfo.revert).toBeDefined() // Simulate what the compact endpoint does: cleanup revert before creating compaction await SessionRevert.cleanup(sessionInfo) // Verify revert state is cleared sessionInfo = await Session.get(sessionID) expect(sessionInfo.revert).toBeUndefined() // Verify messages are properly cleaned up const messages = await Session.messages({ sessionID }) expect(messages.length).toBe(0) // All messages should be reverted // Clean up await Session.remove(sessionID) }, }) }) test("cleanup with partID removes parts from the revert point onward", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const session = await Session.create({}) const sid = session.id const u1 = await user(sid) const p1 = await text(sid, u1.id, "first part") const p2 = await tool(sid, u1.id) const p3 = await text(sid, u1.id, "third part") // Set revert state pointing at a specific part await Session.setRevert({ sessionID: sid, revert: { messageID: u1.id, partID: p2.id }, summary: { additions: 0, deletions: 0, files: 0 }, }) const info = await Session.get(sid) await SessionRevert.cleanup(info) const msgs = await Session.messages({ sessionID: sid }) expect(msgs.length).toBe(1) // Only the first part should remain (before the revert partID) expect(msgs[0].parts.length).toBe(1) expect(msgs[0].parts[0].id).toBe(p1.id) const cleared = await Session.get(sid) expect(cleared.revert).toBeUndefined() }, }) }) test("cleanup removes messages after revert point but keeps earlier ones", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const session = await Session.create({}) const sid = session.id const u1 = await user(sid) await text(sid, u1.id, "hello") const a1 = await assistant(sid, u1.id, tmp.path) await text(sid, a1.id, "hi back") const u2 = await user(sid) await text(sid, u2.id, "second question") const a2 = await assistant(sid, u2.id, tmp.path) await text(sid, a2.id, "second answer") // Revert from u2 onward await Session.setRevert({ sessionID: sid, revert: { messageID: u2.id }, summary: { additions: 0, deletions: 0, files: 0 }, }) const info = await Session.get(sid) await SessionRevert.cleanup(info) const msgs = await 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) }, }) }) test("cleanup is a no-op when session has no revert state", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const session = await Session.create({}) const sid = session.id const u1 = await user(sid) await text(sid, u1.id, "hello") const info = await Session.get(sid) expect(info.revert).toBeUndefined() await SessionRevert.cleanup(info) const msgs = await Session.messages({ sessionID: sid }) expect(msgs.length).toBe(1) }, }) }) test("restore messages in sequential order", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { await fs.writeFile(path.join(tmp.path, "a.txt"), "a0") await fs.writeFile(path.join(tmp.path, "b.txt"), "b0") await fs.writeFile(path.join(tmp.path, "c.txt"), "c0") const session = await Session.create({}) const sid = session.id const turn = async (file: string, next: string) => { const u = await user(sid) await text(sid, u.id, `${file}:${next}`) const a = await assistant(sid, u.id, tmp.path) const before = await Snapshot.track() if (!before) throw new Error("expected snapshot") await fs.writeFile(path.join(tmp.path, file), next) const after = await Snapshot.track() if (!after) throw new Error("expected snapshot") const patch = await Snapshot.patch(before) await Session.updatePart({ id: PartID.ascending(), messageID: a.id, sessionID: sid, type: "step-start", snapshot: before, }) await Session.updatePart({ id: PartID.ascending(), messageID: a.id, sessionID: sid, type: "step-finish", reason: "stop", snapshot: after, cost: 0, tokens, }) await Session.updatePart({ id: PartID.ascending(), messageID: a.id, sessionID: sid, type: "patch", hash: patch.hash, files: patch.files, }) return u.id } const first = await turn("a.txt", "a1") const second = await turn("b.txt", "b2") const third = await turn("c.txt", "c3") await SessionRevert.revert({ sessionID: sid, messageID: first, }) expect((await Session.get(sid)).revert?.messageID).toBe(first) expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a0") expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b0") expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0") await SessionRevert.revert({ sessionID: sid, messageID: second, }) expect((await Session.get(sid)).revert?.messageID).toBe(second) expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b0") expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0") await SessionRevert.revert({ sessionID: sid, messageID: third, }) expect((await Session.get(sid)).revert?.messageID).toBe(third) expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b2") expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0") await SessionRevert.unrevert({ sessionID: sid, }) expect((await Session.get(sid)).revert).toBeUndefined() expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b2") expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c3") }, }) }) test("restore same file in sequential order", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { await fs.writeFile(path.join(tmp.path, "a.txt"), "a0") const session = await Session.create({}) const sid = session.id const turn = async (next: string) => { const u = await user(sid) await text(sid, u.id, `a.txt:${next}`) const a = await assistant(sid, u.id, tmp.path) const before = await Snapshot.track() if (!before) throw new Error("expected snapshot") await fs.writeFile(path.join(tmp.path, "a.txt"), next) const after = await Snapshot.track() if (!after) throw new Error("expected snapshot") const patch = await Snapshot.patch(before) await Session.updatePart({ id: PartID.ascending(), messageID: a.id, sessionID: sid, type: "step-start", snapshot: before, }) await Session.updatePart({ id: PartID.ascending(), messageID: a.id, sessionID: sid, type: "step-finish", reason: "stop", snapshot: after, cost: 0, tokens, }) await Session.updatePart({ id: PartID.ascending(), messageID: a.id, sessionID: sid, type: "patch", hash: patch.hash, files: patch.files, }) return u.id } const first = await turn("a1") const second = await turn("a2") const third = await turn("a3") expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a3") await SessionRevert.revert({ sessionID: sid, messageID: first, }) expect((await Session.get(sid)).revert?.messageID).toBe(first) expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a0") await SessionRevert.revert({ sessionID: sid, messageID: second, }) expect((await Session.get(sid)).revert?.messageID).toBe(second) expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") await SessionRevert.revert({ sessionID: sid, messageID: third, }) expect((await Session.get(sid)).revert?.messageID).toBe(third) expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a2") await SessionRevert.unrevert({ sessionID: sid, }) expect((await Session.get(sid)).revert).toBeUndefined() expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a3") }, }) }) })