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.
640 lines
20 KiB
TypeScript
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 },
|
|
),
|
|
)
|
|
})
|