Files
anomalyco_opencode/packages/opencode/test/session/revert-compact.test.ts
T
Dax Raad 1e98167b0e core: move cross-spawn-spawner to root and remove unused types
The cross-spawn-spawner module has been moved from src/effect/ to src/
to simplify the core package structure. The src/types.d.ts file which
contained unused type declarations has also been removed. All imports
throughout the codebase have been updated to reflect the new location.

This change reduces the package's internal complexity by flattening the
module hierarchy and removing dead code, making future maintenance easier.
2026-04-25 14:30:16 -04:00

640 lines
20 KiB
TypeScript

import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "../../src/session"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionRevert } from "../../src/session/revert"
import { MessageV2 } from "../../src/session/message-v2"
import { Snapshot } from "../../src/snapshot"
import { Log } from "../../src/util"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const env = Layer.mergeAll(
Session.defaultLayer,
SessionRevert.defaultLayer,
Snapshot.defaultLayer,
CrossSpawnSpawner.defaultLayer,
)
const it = testEffect(env)
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: ProviderID.make("openai"), modelID: ModelID.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: ModelID.make("gpt-4"),
providerID: ProviderID.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: ProviderID.make("openai"),
modelID: ModelID.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: MessageV2.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: ModelID.make("gpt-4"),
providerID: ProviderID.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: ProviderID.make("openai"),
modelID: ModelID.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: MessageV2.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: ModelID.make("gpt-4"),
providerID: ProviderID.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: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
},
time: {
created: Date.now(),
},
})
yield* 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: 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: 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 },
),
)
})