refactor(session): remove revert async facade exports (#22339)

This commit is contained in:
Kit Langton
2026-04-13 16:16:13 -04:00
committed by GitHub
parent 29c202e6ab
commit 67aaecacac
3 changed files with 603 additions and 586 deletions
@@ -551,10 +551,17 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
const session = await Session.get(sessionID) await AppRuntime.runPromise(
await SessionRevert.cleanup(session) Effect.gen(function* () {
const msgs = await Session.messages({ sessionID }) const session = yield* Session.Service
const defaultAgent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.defaultAgent())) const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
yield* revert.cleanup(yield* session.get(sessionID))
const msgs = yield* session.messages({ sessionID })
const defaultAgent = yield* agent.defaultAgent()
let currentAgent = defaultAgent let currentAgent = defaultAgent
for (let i = msgs.length - 1; i >= 0; i--) { for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info const info = msgs[i].info
@@ -563,7 +570,8 @@ export const SessionRoutes = lazy(() =>
break break
} }
} }
await SessionCompaction.create({
yield* compact.create({
sessionID, sessionID,
agent: currentAgent, agent: currentAgent,
model: { model: {
@@ -572,7 +580,9 @@ export const SessionRoutes = lazy(() =>
}, },
auto: body.auto, auto: body.auto,
}) })
await SessionPrompt.loop({ sessionID }) yield* prompt.loop({ sessionID })
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -990,10 +1000,14 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
log.info("revert", c.req.valid("json")) log.info("revert", c.req.valid("json"))
const session = await SessionRevert.revert({ const session = await AppRuntime.runPromise(
SessionRevert.Service.use((svc) =>
svc.revert({
sessionID, sessionID,
...c.req.valid("json"), ...c.req.valid("json"),
}) }),
),
)
return c.json(session) return c.json(session)
}, },
) )
@@ -1023,7 +1037,7 @@ export const SessionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const session = await SessionRevert.unrevert({ sessionID }) const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
return c.json(session) return c.json(session)
}, },
) )
-15
View File
@@ -1,6 +1,5 @@
import z from "zod" import z from "zod"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Snapshot } from "../snapshot" import { Snapshot } from "../snapshot"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
@@ -160,18 +159,4 @@ export namespace SessionRevert {
Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionSummary.defaultLayer),
), ),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function revert(input: RevertInput) {
return runPromise((svc) => svc.revert(input))
}
export async function unrevert(input: { sessionID: SessionID }) {
return runPromise((svc) => svc.unrevert(input))
}
export async function cleanup(session: Session.Info) {
return runPromise((svc) => svc.cleanup(session))
}
} }
@@ -1,35 +1,47 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test" import { describe, expect } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionRevert } from "../../src/session/revert" import { SessionRevert } from "../../src/session/revert"
import { SessionCompaction } from "../../src/session/compaction"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { MessageID, PartID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { tmpdir } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
Log.init({ print: false }) Log.init({ print: false })
function user(sessionID: string, agent = "default") { const env = Layer.mergeAll(
return Session.updateMessage({ 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(), id: MessageID.ascending(),
role: "user" as const, role: "user" as const,
sessionID: sessionID as any, sessionID,
agent, agent,
model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") }, model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") },
time: { created: Date.now() }, time: { created: Date.now() },
}) })
} })
function assistant(sessionID: string, parentID: string, dir: string) { const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, parentID: MessageID, dir: string) {
return Session.updateMessage({ const session = yield* Session.Service
return yield* session.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "assistant" as const, role: "assistant" as const,
sessionID: sessionID as any, sessionID,
mode: "default", mode: "default",
agent: "default", agent: "default",
path: { cwd: dir, root: dir }, path: { cwd: dir, root: dir },
@@ -37,27 +49,29 @@ function assistant(sessionID: string, parentID: string, dir: string) {
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ModelID.make("gpt-4"), modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"), providerID: ProviderID.make("openai"),
parentID: parentID as any, parentID,
time: { created: Date.now() }, time: { created: Date.now() },
finish: "end_turn", finish: "end_turn",
}) })
} })
function text(sessionID: string, messageID: string, content: string) { const text = Effect.fn("test.text")(function* (sessionID: SessionID, messageID: MessageID, content: string) {
return Session.updatePart({ const session = yield* Session.Service
return yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: messageID as any, messageID,
sessionID: sessionID as any, sessionID,
type: "text" as const, type: "text" as const,
text: content, text: content,
}) })
} })
function tool(sessionID: string, messageID: string) { const tool = Effect.fn("test.tool")(function* (sessionID: SessionID, messageID: MessageID) {
return Session.updatePart({ const session = yield* Session.Service
return yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: messageID as any, messageID,
sessionID: sessionID as any, sessionID,
type: "tool" as const, type: "tool" as const,
tool: "bash", tool: "bash",
callID: "call-1", callID: "call-1",
@@ -70,7 +84,10 @@ function tool(sessionID: string, messageID: string) {
time: { start: 0, end: 1 }, 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 = { const tokens = {
input: 0, input: 0,
@@ -80,17 +97,18 @@ const tokens = {
} }
describe("revert + compact workflow", () => { describe("revert + compact workflow", () => {
test("should properly handle compact command after revert", async () => { it.live(
await using tmp = await tmpdir({ git: true }) "should properly handle compact command after revert",
await Instance.provide({ provideTmpdirInstance(
directory: tmp.path, (dir) =>
fn: async () => { Effect.gen(function* () {
// Create a session const session = yield* Session.Service
const session = await Session.create({}) const revert = yield* SessionRevert.Service
const sessionID = session.id
// Create a user message const info = yield* session.create({})
const userMsg1 = await Session.updateMessage({ const sessionID = info.id
const userMsg1 = yield* session.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "user", role: "user",
sessionID, sessionID,
@@ -104,8 +122,7 @@ describe("revert + compact workflow", () => {
}, },
}) })
// Add a text part to the user message yield* session.updatePart({
await Session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: userMsg1.id, messageID: userMsg1.id,
sessionID, sessionID,
@@ -113,7 +130,6 @@ describe("revert + compact workflow", () => {
text: "Hello, please help me", text: "Hello, please help me",
}) })
// Create an assistant response message
const assistantMsg1: MessageV2.Assistant = { const assistantMsg1: MessageV2.Assistant = {
id: MessageID.ascending(), id: MessageID.ascending(),
role: "assistant", role: "assistant",
@@ -121,8 +137,8 @@ describe("revert + compact workflow", () => {
mode: "default", mode: "default",
agent: "default", agent: "default",
path: { path: {
cwd: tmp.path, cwd: dir,
root: tmp.path, root: dir,
}, },
cost: 0, cost: 0,
tokens: { tokens: {
@@ -139,10 +155,9 @@ describe("revert + compact workflow", () => {
}, },
finish: "end_turn", finish: "end_turn",
} }
await Session.updateMessage(assistantMsg1) yield* session.updateMessage(assistantMsg1)
// Add a text part to the assistant message yield* session.updatePart({
await Session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: assistantMsg1.id, messageID: assistantMsg1.id,
sessionID, sessionID,
@@ -150,8 +165,7 @@ describe("revert + compact workflow", () => {
text: "Sure, I'll help you!", text: "Sure, I'll help you!",
}) })
// Create another user message const userMsg2 = yield* session.updateMessage({
const userMsg2 = await Session.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "user", role: "user",
sessionID, sessionID,
@@ -165,7 +179,7 @@ describe("revert + compact workflow", () => {
}, },
}) })
await Session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: userMsg2.id, messageID: userMsg2.id,
sessionID, sessionID,
@@ -173,7 +187,6 @@ describe("revert + compact workflow", () => {
text: "What's the capital of France?", text: "What's the capital of France?",
}) })
// Create another assistant response
const assistantMsg2: MessageV2.Assistant = { const assistantMsg2: MessageV2.Assistant = {
id: MessageID.ascending(), id: MessageID.ascending(),
role: "assistant", role: "assistant",
@@ -181,8 +194,8 @@ describe("revert + compact workflow", () => {
mode: "default", mode: "default",
agent: "default", agent: "default",
path: { path: {
cwd: tmp.path, cwd: dir,
root: tmp.path, root: dir,
}, },
cost: 0, cost: 0,
tokens: { tokens: {
@@ -199,9 +212,9 @@ describe("revert + compact workflow", () => {
}, },
finish: "end_turn", finish: "end_turn",
} }
await Session.updateMessage(assistantMsg2) yield* session.updateMessage(assistantMsg2)
await Session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: assistantMsg2.id, messageID: assistantMsg2.id,
sessionID, sessionID,
@@ -209,64 +222,55 @@ describe("revert + compact workflow", () => {
text: "The capital of France is Paris.", text: "The capital of France is Paris.",
}) })
// Verify messages before revert let messages = yield* session.messages({ sessionID })
let messages = await Session.messages({ sessionID }) expect(messages.length).toBe(4)
expect(messages.length).toBe(4) // 2 user + 2 assistant messages
const messageIds = messages.map((m) => m.info.id) const messageIds = messages.map((m) => m.info.id)
expect(messageIds).toContain(userMsg1.id) expect(messageIds).toContain(userMsg1.id)
expect(messageIds).toContain(userMsg2.id) expect(messageIds).toContain(userMsg2.id)
expect(messageIds).toContain(assistantMsg1.id) expect(messageIds).toContain(assistantMsg1.id)
expect(messageIds).toContain(assistantMsg2.id) expect(messageIds).toContain(assistantMsg2.id)
// Revert the last user message (userMsg2) yield* revert.revert({
await SessionRevert.revert({
sessionID, sessionID,
messageID: userMsg2.id, messageID: userMsg2.id,
}) })
// Check that revert state is set let sessionInfo = yield* session.get(sessionID)
let sessionInfo = await Session.get(sessionID)
expect(sessionInfo.revert).toBeDefined() expect(sessionInfo.revert).toBeDefined()
const revertMessageID = sessionInfo.revert?.messageID expect(sessionInfo.revert?.messageID).toBeDefined()
expect(revertMessageID).toBeDefined()
// Messages should still be in the list (not removed yet, just marked for revert) messages = yield* session.messages({ sessionID })
messages = await Session.messages({ sessionID })
expect(messages.length).toBe(4) expect(messages.length).toBe(4)
// Now clean up the revert state (this is what the compact endpoint should do) yield* revert.cleanup(sessionInfo)
await SessionRevert.cleanup(sessionInfo)
// After cleanup, the reverted messages (those after the revert point) should be removed messages = yield* session.messages({ sessionID })
messages = await Session.messages({ sessionID })
const remainingIds = messages.map((m) => m.info.id) 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) 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(userMsg2.id)
expect(remainingIds).not.toContain(assistantMsg2.id) expect(remainingIds).not.toContain(assistantMsg2.id)
// Revert state should be cleared sessionInfo = yield* session.get(sessionID)
sessionInfo = await Session.get(sessionID)
expect(sessionInfo.revert).toBeUndefined() expect(sessionInfo.revert).toBeUndefined()
// Clean up yield* session.remove(sessionID)
await Session.remove(sessionID) }),
}, { git: true },
}) ),
}) )
test("should properly clean up revert state before creating compaction message", async () => { it.live(
await using tmp = await tmpdir({ git: true }) "should properly clean up revert state before creating compaction message",
await Instance.provide({ provideTmpdirInstance(
directory: tmp.path, (dir) =>
fn: async () => { Effect.gen(function* () {
// Create a session const session = yield* Session.Service
const session = await Session.create({}) const revert = yield* SessionRevert.Service
const sessionID = session.id
// Create initial messages const info = yield* session.create({})
const userMsg = await Session.updateMessage({ const sessionID = info.id
const userMsg = yield* session.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "user", role: "user",
sessionID, sessionID,
@@ -280,7 +284,7 @@ describe("revert + compact workflow", () => {
}, },
}) })
await Session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: userMsg.id, messageID: userMsg.id,
sessionID, sessionID,
@@ -295,8 +299,8 @@ describe("revert + compact workflow", () => {
mode: "default", mode: "default",
agent: "default", agent: "default",
path: { path: {
cwd: tmp.path, cwd: dir,
root: tmp.path, root: dir,
}, },
cost: 0, cost: 0,
tokens: { tokens: {
@@ -313,9 +317,9 @@ describe("revert + compact workflow", () => {
}, },
finish: "end_turn", finish: "end_turn",
} }
await Session.updateMessage(assistantMsg) yield* session.updateMessage(assistantMsg)
await Session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: assistantMsg.id, messageID: assistantMsg.id,
sessionID, sessionID,
@@ -323,157 +327,165 @@ describe("revert + compact workflow", () => {
text: "Hi there!", text: "Hi there!",
}) })
// Revert the user message yield* revert.revert({
await SessionRevert.revert({
sessionID, sessionID,
messageID: userMsg.id, messageID: userMsg.id,
}) })
// Check that revert state is set let sessionInfo = yield* session.get(sessionID)
let sessionInfo = await Session.get(sessionID)
expect(sessionInfo.revert).toBeDefined() expect(sessionInfo.revert).toBeDefined()
// Simulate what the compact endpoint does: cleanup revert before creating compaction yield* revert.cleanup(sessionInfo)
await SessionRevert.cleanup(sessionInfo)
// Verify revert state is cleared sessionInfo = yield* session.get(sessionID)
sessionInfo = await Session.get(sessionID)
expect(sessionInfo.revert).toBeUndefined() expect(sessionInfo.revert).toBeUndefined()
// Verify messages are properly cleaned up const messages = yield* session.messages({ sessionID })
const messages = await Session.messages({ sessionID }) expect(messages.length).toBe(0)
expect(messages.length).toBe(0) // All messages should be reverted
// Clean up yield* session.remove(sessionID)
await Session.remove(sessionID) }),
}, { git: true },
}) ),
}) )
test("cleanup with partID removes parts from the revert point onward", async () => { it.live(
await using tmp = await tmpdir({ git: true }) "cleanup with partID removes parts from the revert point onward",
await Instance.provide({ provideTmpdirInstance(
directory: tmp.path, () =>
fn: async () => { Effect.gen(function* () {
const session = await Session.create({}) const session = yield* Session.Service
const sid = session.id const revert = yield* SessionRevert.Service
const u1 = await user(sid) const info = yield* session.create({})
const p1 = await text(sid, u1.id, "first part") const sid = info.id
const p2 = await tool(sid, u1.id)
const p3 = await text(sid, u1.id, "third part")
// Set revert state pointing at a specific part const u1 = yield* user(sid)
await Session.setRevert({ 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, sessionID: sid,
revert: { messageID: u1.id, partID: p2.id }, revert: { messageID: u1.id, partID: p2.id },
summary: { additions: 0, deletions: 0, files: 0 }, summary: { additions: 0, deletions: 0, files: 0 },
}) })
const info = await Session.get(sid) const state = yield* session.get(sid)
await SessionRevert.cleanup(info) yield* revert.cleanup(state)
const msgs = await Session.messages({ sessionID: sid }) const msgs = yield* session.messages({ sessionID: sid })
expect(msgs.length).toBe(1) 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.length).toBe(1)
expect(msgs[0].parts[0].id).toBe(p1.id) expect(msgs[0].parts[0].id).toBe(p1.id)
const cleared = await Session.get(sid) const cleared = yield* session.get(sid)
expect(cleared.revert).toBeUndefined() expect(cleared.revert).toBeUndefined()
}, }),
}) { git: true },
}) ),
)
test("cleanup removes messages after revert point but keeps earlier ones", async () => { it.live(
await using tmp = await tmpdir({ git: true }) "cleanup removes messages after revert point but keeps earlier ones",
await Instance.provide({ provideTmpdirInstance(
directory: tmp.path, (dir) =>
fn: async () => { Effect.gen(function* () {
const session = await Session.create({}) const session = yield* Session.Service
const sid = session.id const revert = yield* SessionRevert.Service
const u1 = await user(sid) const info = yield* session.create({})
await text(sid, u1.id, "hello") const sid = info.id
const a1 = await assistant(sid, u1.id, tmp.path)
await text(sid, a1.id, "hi back")
const u2 = await user(sid) const u1 = yield* user(sid)
await text(sid, u2.id, "second question") yield* text(sid, u1.id, "hello")
const a2 = await assistant(sid, u2.id, tmp.path) const a1 = yield* assistant(sid, u1.id, dir)
await text(sid, a2.id, "second answer") yield* text(sid, a1.id, "hi back")
// Revert from u2 onward const u2 = yield* user(sid)
await Session.setRevert({ 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, sessionID: sid,
revert: { messageID: u2.id }, revert: { messageID: u2.id },
summary: { additions: 0, deletions: 0, files: 0 }, summary: { additions: 0, deletions: 0, files: 0 },
}) })
const info = await Session.get(sid) const state = yield* session.get(sid)
await SessionRevert.cleanup(info) yield* revert.cleanup(state)
const msgs = await Session.messages({ sessionID: sid }) const msgs = yield* session.messages({ sessionID: sid })
const ids = msgs.map((m) => m.info.id) const ids = msgs.map((m) => m.info.id)
expect(ids).toContain(u1.id) expect(ids).toContain(u1.id)
expect(ids).toContain(a1.id) expect(ids).toContain(a1.id)
expect(ids).not.toContain(u2.id) expect(ids).not.toContain(u2.id)
expect(ids).not.toContain(a2.id) expect(ids).not.toContain(a2.id)
}, }),
}) { git: true },
}) ),
)
test("cleanup is a no-op when session has no revert state", async () => { it.live(
await using tmp = await tmpdir({ git: true }) "cleanup is a no-op when session has no revert state",
await Instance.provide({ provideTmpdirInstance(
directory: tmp.path, () =>
fn: async () => { Effect.gen(function* () {
const session = await Session.create({}) const session = yield* Session.Service
const sid = session.id const revert = yield* SessionRevert.Service
const u1 = await user(sid) const info = yield* session.create({})
await text(sid, u1.id, "hello") const sid = info.id
const info = await Session.get(sid) const u1 = yield* user(sid)
expect(info.revert).toBeUndefined() yield* text(sid, u1.id, "hello")
await SessionRevert.cleanup(info)
const msgs = await Session.messages({ sessionID: sid }) 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) expect(msgs.length).toBe(1)
}, }),
}) { git: true },
}) ),
)
test("restore messages in sequential order", async () => { it.live(
await using tmp = await tmpdir({ git: true }) "restore messages in sequential order",
await Instance.provide({ provideTmpdirInstance(
directory: tmp.path, (dir) =>
fn: async () => { Effect.gen(function* () {
await fs.writeFile(path.join(tmp.path, "a.txt"), "a0") const session = yield* Session.Service
await fs.writeFile(path.join(tmp.path, "b.txt"), "b0") const revert = yield* SessionRevert.Service
await fs.writeFile(path.join(tmp.path, "c.txt"), "c0") const snapshot = yield* Snapshot.Service
const session = await Session.create({}) yield* write(path.join(dir, "a.txt"), "a0")
const sid = session.id yield* write(path.join(dir, "b.txt"), "b0")
yield* write(path.join(dir, "c.txt"), "c0")
const turn = async (file: string, next: string) => { const info = yield* session.create({})
const u = await user(sid) const sid = info.id
await text(sid, u.id, `${file}:${next}`)
const a = await assistant(sid, u.id, tmp.path) const turn = Effect.fn("test.turn")(function* (file: string, next: string) {
const before = await Snapshot.track() 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") if (!before) throw new Error("expected snapshot")
await fs.writeFile(path.join(tmp.path, file), next) yield* write(path.join(dir, file), next)
const after = await Snapshot.track() const after = yield* snapshot.track()
if (!after) throw new Error("expected snapshot") if (!after) throw new Error("expected snapshot")
const patch = await Snapshot.patch(before) const patch = yield* snapshot.patch(before)
await Session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: a.id, messageID: a.id,
sessionID: sid, sessionID: sid,
type: "step-start", type: "step-start",
snapshot: before, snapshot: before,
}) })
await Session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: a.id, messageID: a.id,
sessionID: sid, sessionID: sid,
@@ -483,7 +495,7 @@ describe("revert + compact workflow", () => {
cost: 0, cost: 0,
tokens, tokens,
}) })
await Session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: a.id, messageID: a.id,
sessionID: sid, sessionID: sid,
@@ -492,78 +504,83 @@ describe("revert + compact workflow", () => {
files: patch.files, files: patch.files,
}) })
return u.id return u.id
} })
const first = await turn("a.txt", "a1") const first = yield* turn("a.txt", "a1")
const second = await turn("b.txt", "b2") const second = yield* turn("b.txt", "b2")
const third = await turn("c.txt", "c3") const third = yield* turn("c.txt", "c3")
await SessionRevert.revert({ yield* revert.revert({
sessionID: sid, sessionID: sid,
messageID: first, messageID: first,
}) })
expect((await Session.get(sid)).revert?.messageID).toBe(first) expect((yield* session.get(sid)).revert?.messageID).toBe(first)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a0") expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b0") expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0") expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
await SessionRevert.revert({ yield* revert.revert({
sessionID: sid, sessionID: sid,
messageID: second, messageID: second,
}) })
expect((await Session.get(sid)).revert?.messageID).toBe(second) expect((yield* session.get(sid)).revert?.messageID).toBe(second)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b0") expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0") expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
await SessionRevert.revert({ yield* revert.revert({
sessionID: sid, sessionID: sid,
messageID: third, messageID: third,
}) })
expect((await Session.get(sid)).revert?.messageID).toBe(third) expect((yield* session.get(sid)).revert?.messageID).toBe(third)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b2") expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0") expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
await SessionRevert.unrevert({ yield* revert.unrevert({
sessionID: sid, sessionID: sid,
}) })
expect((await Session.get(sid)).revert).toBeUndefined() expect((yield* session.get(sid)).revert).toBeUndefined()
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b2") expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c3") expect(yield* read(path.join(dir, "c.txt"))).toBe("c3")
}, }),
}) { git: true },
}) ),
)
test("restore same file in sequential order", async () => { it.live(
await using tmp = await tmpdir({ git: true }) "restore same file in sequential order",
await Instance.provide({ provideTmpdirInstance(
directory: tmp.path, (dir) =>
fn: async () => { Effect.gen(function* () {
await fs.writeFile(path.join(tmp.path, "a.txt"), "a0") const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const snapshot = yield* Snapshot.Service
const session = await Session.create({}) yield* write(path.join(dir, "a.txt"), "a0")
const sid = session.id
const turn = async (next: string) => { const info = yield* session.create({})
const u = await user(sid) const sid = info.id
await text(sid, u.id, `a.txt:${next}`)
const a = await assistant(sid, u.id, tmp.path) const turn = Effect.fn("test.turnSame")(function* (next: string) {
const before = await Snapshot.track() 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") if (!before) throw new Error("expected snapshot")
await fs.writeFile(path.join(tmp.path, "a.txt"), next) yield* write(path.join(dir, "a.txt"), next)
const after = await Snapshot.track() const after = yield* snapshot.track()
if (!after) throw new Error("expected snapshot") if (!after) throw new Error("expected snapshot")
const patch = await Snapshot.patch(before) const patch = yield* snapshot.patch(before)
await Session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: a.id, messageID: a.id,
sessionID: sid, sessionID: sid,
type: "step-start", type: "step-start",
snapshot: before, snapshot: before,
}) })
await Session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: a.id, messageID: a.id,
sessionID: sid, sessionID: sid,
@@ -573,7 +590,7 @@ describe("revert + compact workflow", () => {
cost: 0, cost: 0,
tokens, tokens,
}) })
await Session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: a.id, messageID: a.id,
sessionID: sid, sessionID: sid,
@@ -582,40 +599,41 @@ describe("revert + compact workflow", () => {
files: patch.files, files: patch.files,
}) })
return u.id return u.id
} })
const first = await turn("a1") const first = yield* turn("a1")
const second = await turn("a2") const second = yield* turn("a2")
const third = await turn("a3") const third = yield* turn("a3")
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a3") expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
await SessionRevert.revert({ yield* revert.revert({
sessionID: sid, sessionID: sid,
messageID: first, messageID: first,
}) })
expect((await Session.get(sid)).revert?.messageID).toBe(first) expect((yield* session.get(sid)).revert?.messageID).toBe(first)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a0") expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
await SessionRevert.revert({ yield* revert.revert({
sessionID: sid, sessionID: sid,
messageID: second, messageID: second,
}) })
expect((await Session.get(sid)).revert?.messageID).toBe(second) expect((yield* session.get(sid)).revert?.messageID).toBe(second)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1") expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
await SessionRevert.revert({ yield* revert.revert({
sessionID: sid, sessionID: sid,
messageID: third, messageID: third,
}) })
expect((await Session.get(sid)).revert?.messageID).toBe(third) expect((yield* session.get(sid)).revert?.messageID).toBe(third)
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a2") expect(yield* read(path.join(dir, "a.txt"))).toBe("a2")
await SessionRevert.unrevert({ yield* revert.unrevert({
sessionID: sid, sessionID: sid,
}) })
expect((await Session.get(sid)).revert).toBeUndefined() expect((yield* session.get(sid)).revert).toBeUndefined()
expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a3") expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
}, }),
}) { git: true },
}) ),
)
}) })