diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 2c44e47c9..46cf2feb8 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -61,6 +61,7 @@ export function hints(template: string) { export const Default = { INIT: "init", REVIEW: "review", + UNDO: "undo", DREAM: "dream", DISTILL: "distill", GOAL: "goal", @@ -123,6 +124,13 @@ export const layer = Layer.effect( subtask: true, hints: hints(PROMPT_REVIEW), } + commands[Default.UNDO] = { + name: Default.UNDO, + description: "undo the latest message and its file changes", + source: "command", + template: "", + hints: [], + } commands[Default.DREAM] = { name: Default.DREAM, description: "manually consolidate project memory from memory files and raw trajectory", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 33afb0f48..12159c7f7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -3501,6 +3501,32 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } + if (input.command === Command.Default.UNDO) { + const session = yield* sessions.get(input.sessionID) + const target = (yield* sessions.messages({ sessionID: input.sessionID, agentID: "*" })) + .filter((msg) => { + if (msg.info.role !== "user") return false + return !session.revert?.messageID || msg.info.id < session.revert.messageID + }) + .at(-1) + if (!target) { + const error = new NamedError.Unknown({ message: "Nothing to undo." }) + yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + yield* revert.revert({ + sessionID: input.sessionID, + messageID: target.info.id, + }) + const result = yield* lastAssistant(input.sessionID) + yield* bus.publish(Command.Event.Executed, { + name: input.command, + sessionID: input.sessionID, + arguments: input.arguments, + messageID: result.info.id, + }) + return result + } const agentName = cmd.agent ?? input.agent ?? (yield* agents.defaultAgent()) // /goal — set or clear a session-level stop-condition goal. The condition diff --git a/packages/opencode/test/session/command-undo.test.ts b/packages/opencode/test/session/command-undo.test.ts new file mode 100644 index 000000000..d245e5ea6 --- /dev/null +++ b/packages/opencode/test/session/command-undo.test.ts @@ -0,0 +1,144 @@ +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer } from "effect" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { Snapshot } from "../../src/snapshot" +import { Log } from "../../src/util" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +void Log.init({ print: false }) + +const env = Layer.mergeAll( + SessionPrompt.defaultLayer, + Session.defaultLayer, + Snapshot.defaultLayer, + CrossSpawnSpawner.defaultLayer, +) + +const it = testEffect(env) + +const tokens = { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, +} + +const write = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text)) +const read = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8")) + +const user = Effect.fn("test.user")(function* (sessionID: SessionID) { + const session = yield* Session.Service + return yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user" as const, + sessionID, + agent: "default", + 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, + 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, + }) +}) + +describe("session undo command", () => { + it.live( + "reverts file changes from the latest turn", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const snapshot = yield* Snapshot.Service + const file = path.join(dir, "note.txt") + + yield* write(file, "before") + + const info = yield* session.create({}) + const u = yield* user(info.id) + yield* text(info.id, u.id, "change note") + const a = yield* assistant(info.id, u.id, dir) + const before = yield* snapshot.track() + if (!before) throw new Error("expected snapshot") + yield* write(file, "after") + 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: info.id, + type: "step-start", + snapshot: before, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: info.id, + type: "step-finish", + reason: "stop", + snapshot: after, + cost: 0, + tokens, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: a.id, + sessionID: info.id, + type: "patch", + hash: patch.hash, + files: patch.files, + }) + + expect(yield* read(file)).toBe("after") + + const result = yield* prompt.command({ + sessionID: info.id, + command: "undo", + arguments: "", + }) + + expect(result.info.id).toBe(a.id) + expect((yield* session.get(info.id)).revert?.messageID).toBe(u.id) + expect(yield* read(file)).toBe("before") + }), + { git: true }, + ), + 30000, + ) +})