From 5469155eed51725f06470881f78738ddac3de96e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 12:20:46 -0400 Subject: [PATCH] refactor(session): introduce timeline rewind service --- packages/opencode/src/effect/app-runtime.ts | 2 + .../server/routes/instance/httpapi/server.ts | 2 + packages/opencode/src/session/prompt.ts | 12 +- packages/opencode/src/session/revert.ts | 129 +------------ packages/opencode/src/session/schema.ts | 3 + packages/opencode/src/session/session.ts | 3 +- packages/opencode/src/session/timeline.ts | 159 ++++++++++++++++ packages/opencode/test/session/prompt.test.ts | 4 +- .../test/session/revert-compact.test.ts | 101 +++++++++++ .../test/session/schema-decoding.test.ts | 19 +- .../test/session/session-schema.test.ts | 3 +- .../test/session/snapshot-tool-race.test.ts | 4 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 170 +++++++++--------- 14 files changed, 394 insertions(+), 219 deletions(-) create mode 100644 packages/opencode/src/session/timeline.ts diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index a955cb86dc..52a21f2179 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -30,6 +30,7 @@ import { SessionProcessor } from "@/session/processor" import { SessionCompaction } from "@/session/compaction" import { SessionRevert } from "@/session/revert" import { SessionSummary } from "@/session/summary" +import { SessionTimeline } from "@/session/timeline" import { SessionPrompt } from "@/session/prompt" import { Instruction } from "@/session/instruction" import { LLM } from "@/session/llm" @@ -85,6 +86,7 @@ export const AppLayer = Layer.mergeAll( SessionCompaction.defaultLayer, SessionRevert.defaultLayer, SessionSummary.defaultLayer, + SessionTimeline.defaultLayer, SessionPrompt.defaultLayer, Instruction.defaultLayer, LLM.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 495497ecb4..5ca73fc95f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -41,6 +41,7 @@ import { SessionRevert } from "@/session/revert" import { SessionRunState } from "@/session/run-state" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" +import { SessionTimeline } from "@/session/timeline" import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" import { ShareNext } from "@/share/share-next" @@ -210,6 +211,7 @@ export function createRoutes(corsOptions?: CorsOptions) { SessionRunState.defaultLayer, SessionStatus.defaultLayer, SessionSummary.defaultLayer, + SessionTimeline.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, SyncEvent.defaultLayer, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5cf04719e5..06061e6cf7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4,7 +4,7 @@ import * as EffectZod from "@opencode-ai/core/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import * as Log from "@opencode-ai/core/util/log" -import { SessionRevert } from "./revert" +import { SessionTimeline } from "./timeline" import * as Session from "./session" import { Agent } from "../agent/agent" import { Provider } from "@/provider/provider" @@ -114,7 +114,7 @@ export const layer = Layer.effect( const scope = yield* Scope.Scope const instruction = yield* Instruction.Service const state = yield* SessionRunState.Service - const revert = yield* SessionRevert.Service + const timeline = yield* SessionTimeline.Service const summary = yield* SessionSummary.Service const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service @@ -747,9 +747,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const { msg, part, cwd } = yield* Effect.gen(function* () { const ctx = yield* InstanceState.context const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) - if (session.revert) { - yield* revert.cleanup(session) - } + if (session.revert) yield* timeline.commitPending({ sessionID: session.id }) const agent = yield* agents.get(input.agent) if (!agent) { const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) @@ -1378,7 +1376,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the "SessionPrompt.prompt", )(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) - yield* revert.cleanup(session) + yield* timeline.commitPending({ sessionID: session.id }) const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) @@ -1792,7 +1790,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Session.defaultLayer), - Layer.provide(SessionRevert.defaultLayer), + Layer.provide(SessionTimeline.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provide(Image.defaultLayer), Layer.provide( diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 12c81180eb..19dff7e992 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -1,16 +1,10 @@ import { Effect, Layer, Context, Schema } from "effect" -import { Bus } from "../bus" -import { Snapshot } from "../snapshot" -import { Storage } from "@/storage/storage" -import { SyncEvent } from "../sync" import * as Log from "@opencode-ai/core/util/log" import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" -import { MessageV2 } from "./message-v2" -import { SessionID, MessageID, PartID } from "./schema" -import { SessionRunState } from "./run-state" -import { SessionSummary } from "./summary" +import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema" +import { SessionTimeline } from "./timeline" const log = Log.create({ service: "session.revert" }) @@ -18,6 +12,7 @@ export const RevertInput = Schema.Struct({ sessionID: SessionID, messageID: MessageID, partID: Schema.optional(PartID), + files: Schema.optional(RewindFilePolicy), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type RevertInput = Schema.Schema.Type @@ -32,133 +27,25 @@ export class Service extends Context.Service()("@opencode/Se export const layer = Layer.effect( Service, Effect.gen(function* () { - const sessions = yield* Session.Service - const snap = yield* Snapshot.Service - const storage = yield* Storage.Service - const bus = yield* Bus.Service - const summary = yield* SessionSummary.Service - const state = yield* SessionRunState.Service - const sync = yield* SyncEvent.Service + const timeline = yield* SessionTimeline.Service const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) { - yield* state.assertNotBusy(input.sessionID) - const all = yield* sessions.messages({ sessionID: input.sessionID }) - let lastUser: MessageV2.User | undefined - const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) - - let rev: Session.Info["revert"] - const patches: Snapshot.Patch[] = [] - for (const msg of all) { - if (msg.info.role === "user") lastUser = msg.info - const remaining = [] - for (const part of msg.parts) { - if (rev) { - if (part.type === "patch") patches.push(part) - continue - } - - if (!rev) { - if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) { - const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined - rev = { - messageID: !partID && lastUser ? lastUser.id : msg.info.id, - partID, - } - } - remaining.push(part) - } - } - } - - if (!rev) return session - - rev.snapshot = session.revert?.snapshot ?? (yield* snap.track()) - if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot) - yield* snap.revert(patches) - if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot) - const range = all.filter((msg) => msg.info.id >= rev.messageID) - const diffs = yield* summary.computeDiff({ messages: range }) - yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) - yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) - yield* sessions.setRevert({ - sessionID: input.sessionID, - revert: rev, - summary: { - additions: diffs.reduce((sum, x) => sum + x.additions, 0), - deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), - files: diffs.length, - }, - }) - return yield* sessions.get(input.sessionID).pipe(Effect.orDie) + return yield* timeline.rewind(input) }) const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) { log.info("unreverting", input) - yield* state.assertNotBusy(input.sessionID) - const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) - if (!session.revert) return session - if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot) - yield* sessions.clearRevert(input.sessionID) - return yield* sessions.get(input.sessionID).pipe(Effect.orDie) + return yield* timeline.restore(input) }) const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) { - if (!session.revert) return - const sessionID = session.id - const msgs = yield* sessions.messages({ sessionID }) - const messageID = session.revert.messageID - const remove = [] as MessageV2.WithParts[] - let target: MessageV2.WithParts | undefined - for (const msg of msgs) { - if (msg.info.id < messageID) continue - if (msg.info.id > messageID) { - remove.push(msg) - continue - } - if (session.revert.partID) { - target = msg - continue - } - remove.push(msg) - } - for (const msg of remove) { - yield* sync.run(MessageV2.Event.Removed, { - sessionID, - messageID: msg.info.id, - }) - } - if (session.revert.partID && target) { - const partID = session.revert.partID - const idx = target.parts.findIndex((part) => part.id === partID) - if (idx >= 0) { - const removeParts = target.parts.slice(idx) - target.parts = target.parts.slice(0, idx) - for (const part of removeParts) { - yield* sync.run(MessageV2.Event.PartRemoved, { - sessionID, - messageID: target.info.id, - partID: part.id, - }) - } - } - } - yield* sessions.clearRevert(sessionID) + yield* timeline.commitPending({ sessionID: session.id }) }) return Service.of({ revert, unrevert, cleanup }) }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(SessionSummary.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), - ), -) +export const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(SessionTimeline.defaultLayer))) export * as SessionRevert from "./revert" diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 991c9ccc6b..55fa212693 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -33,3 +33,6 @@ export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( ) export type PartID = Schema.Schema.Type + +export const RewindFilePolicy = Schema.Union([Schema.Literal("revert"), Schema.Literal("keep")]) +export type RewindFilePolicy = Schema.Schema.Type diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f50f8750b3..f27d2e7d1e 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -30,7 +30,7 @@ import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" -import { SessionID, MessageID, PartID } from "./schema" +import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema" import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" @@ -166,6 +166,7 @@ const Time = Schema.Struct({ const Revert = Schema.Struct({ messageID: MessageID, partID: optionalOmitUndefined(PartID), + files: optionalOmitUndefined(RewindFilePolicy), snapshot: optionalOmitUndefined(Schema.String), diff: optionalOmitUndefined(Schema.String), }) diff --git a/packages/opencode/src/session/timeline.ts b/packages/opencode/src/session/timeline.ts new file mode 100644 index 0000000000..4c028d135b --- /dev/null +++ b/packages/opencode/src/session/timeline.ts @@ -0,0 +1,159 @@ +import { Effect, Layer, Context, Schema } from "effect" +import { Bus } from "@/bus" +import { Snapshot } from "@/snapshot" +import { Storage } from "@/storage/storage" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" +import * as Session from "./session" +import { MessageV2 } from "./message-v2" +import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema" +import { SessionRunState } from "./run-state" +import { SessionSummary } from "./summary" + +export const RewindInput = Schema.Struct({ + sessionID: SessionID, + messageID: MessageID, + partID: Schema.optional(PartID), + files: Schema.optional(RewindFilePolicy), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type RewindInput = Schema.Schema.Type + +export interface Interface { + readonly rewind: (input: RewindInput) => Effect.Effect + readonly restore: (input: { sessionID: SessionID }) => Effect.Effect + readonly commitPending: (input: { sessionID: SessionID }) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SessionTimeline") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* Session.Service + const snap = yield* Snapshot.Service + const storage = yield* Storage.Service + const bus = yield* Bus.Service + const summary = yield* SessionSummary.Service + const state = yield* SessionRunState.Service + + const rewind = Effect.fn("SessionTimeline.rewind")(function* (input: RewindInput) { + yield* state.assertNotBusy(input.sessionID) + const all = yield* sessions.messages({ sessionID: input.sessionID }) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + const files = input.files ?? "revert" + let lastUser: MessageV2.User | undefined + let rev: Session.Info["revert"] + const patches: Snapshot.Patch[] = [] + const range: MessageV2.WithParts[] = [] + + for (const msg of all) { + if (msg.info.role === "user") lastUser = msg.info + const remaining = [] + for (const part of msg.parts) { + if (rev) { + if (files === "revert" && part.type === "patch") patches.push(part) + continue + } + + if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) { + const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined + rev = { + messageID: !partID && lastUser ? lastUser.id : msg.info.id, + partID, + ...(files === "keep" && { files }), + } + } + remaining.push(part) + } + if (rev) range.push(msg) + } + + if (!rev) return session + + if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot) + if (files === "revert") { + rev.snapshot = session.revert?.snapshot ?? (yield* snap.track()) + yield* snap.revert(patches) + if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot) + } + const diffs = yield* summary.computeDiff({ messages: range }) + yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) + yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) + yield* sessions.setRevert({ + sessionID: input.sessionID, + revert: rev, + summary: { + additions: diffs.reduce((sum, x) => sum + x.additions, 0), + deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + files: diffs.length, + }, + }) + return yield* sessions.get(input.sessionID).pipe(Effect.orDie) + }) + + const restore = Effect.fn("SessionTimeline.restore")(function* (input: { sessionID: SessionID }) { + yield* state.assertNotBusy(input.sessionID) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + if (!session.revert) return session + if (session.revert.files !== "keep" && session.revert.snapshot) yield* snap.restore(session.revert.snapshot) + yield* sessions.clearRevert(input.sessionID) + return yield* sessions.get(input.sessionID).pipe(Effect.orDie) + }) + + const commitPending = Effect.fn("SessionTimeline.commitPending")(function* (input: { sessionID: SessionID }) { + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + if (!session.revert) return + const sessionID = session.id + const msgs = yield* sessions.messages({ sessionID }) + const messageID = session.revert.messageID + const remove = [] as MessageV2.WithParts[] + let target: MessageV2.WithParts | undefined + for (const msg of msgs) { + if (msg.info.id < messageID) continue + if (msg.info.id > messageID) { + remove.push(msg) + continue + } + if (session.revert.partID) { + target = msg + continue + } + remove.push(msg) + } + for (const msg of remove) { + yield* sessions.removeMessage({ + sessionID, + messageID: msg.info.id, + }) + } + if (session.revert.partID && target) { + const idx = target.parts.findIndex((part) => part.id === session.revert?.partID) + if (idx >= 0) { + for (const part of target.parts.slice(idx)) { + yield* sessions.removePart({ + sessionID, + messageID: target.info.id, + partID: part.id, + }) + } + } + } + yield* sessions.clearRevert(sessionID) + }) + + return Service.of({ rewind, restore, commitPending }) + }), +) + +export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(SessionRunState.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Storage.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(SessionSummary.defaultLayer), + ), +) + +export * as SessionTimeline from "./timeline" diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 98a69fce96..2d751eaa3a 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -30,8 +30,8 @@ import { SessionSummary } from "../../src/session/summary" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" -import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" +import { SessionTimeline } from "../../src/session/timeline" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionV2 } from "../../src/v2/session" @@ -199,7 +199,7 @@ function makeHttp() { return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), + Layer.provide(SessionTimeline.defaultLayer), Layer.provide(Image.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index c70c17d451..6db8bed922 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -5,6 +5,7 @@ import { Effect, Layer } from "effect" import { Session } from "@/session/session" import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionRevert } from "../../src/session/revert" +import { SessionTimeline } from "../../src/session/timeline" import { MessageV2 } from "../../src/session/message-v2" import { Snapshot } from "../../src/snapshot" import * as Log from "@opencode-ai/core/util/log" @@ -18,6 +19,7 @@ void Log.init({ print: false }) const env = Layer.mergeAll( Session.defaultLayer, SessionRevert.defaultLayer, + SessionTimeline.defaultLayer, Snapshot.defaultLayer, CrossSpawnSpawner.defaultLayer, ) @@ -96,6 +98,51 @@ const tokens = { cache: { read: 0, write: 0 }, } +const fileTurn = Effect.fn("test.fileTurn")(function* (input: { + sessionID: SessionID + dir: string + file: string + content: string +}) { + const session = yield* Session.Service + const snapshot = yield* Snapshot.Service + const userMsg = yield* user(input.sessionID) + yield* text(input.sessionID, userMsg.id, `${input.file}:${input.content}`) + const assistantMsg = yield* assistant(input.sessionID, userMsg.id, input.dir) + const before = yield* snapshot.track() + if (!before) throw new Error("expected snapshot") + yield* write(path.join(input.dir, input.file), input.content) + 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: assistantMsg.id, + sessionID: input.sessionID, + type: "step-start", + snapshot: before, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: assistantMsg.id, + sessionID: input.sessionID, + type: "step-finish", + reason: "stop", + snapshot: after, + cost: 0, + tokens, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: assistantMsg.id, + sessionID: input.sessionID, + type: "patch", + hash: patch.hash, + files: patch.files, + }) + return { user: userMsg, assistant: assistantMsg } +}) + describe("revert + compact workflow", () => { it.live( "should properly handle compact command after revert", @@ -636,4 +683,58 @@ describe("revert + compact workflow", () => { { git: true }, ), ) + + it.live( + "timeline rewind can keep file changes while hiding future messages", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const timeline = yield* SessionTimeline.Service + + yield* write(path.join(dir, "a.txt"), "a0") + + const info = yield* session.create({}) + const sid = info.id + const turn = yield* fileTurn({ sessionID: sid, dir, file: "a.txt", content: "a1" }) + + const rewound = yield* timeline.rewind({ sessionID: sid, messageID: turn.user.id, files: "keep" }) + expect(rewound.revert?.messageID).toBe(turn.user.id) + expect(rewound.revert?.files).toBe("keep") + expect(yield* read(path.join(dir, "a.txt"))).toBe("a1") + + yield* timeline.commitPending({ sessionID: rewound.id }) + expect((yield* session.messages({ sessionID: sid })).map((msg) => msg.info.id)).toEqual([]) + expect(yield* read(path.join(dir, "a.txt"))).toBe("a1") + }), + { git: true }, + ), + ) + + it.live( + "timeline restore keeps files for keep-file rewinds", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const session = yield* Session.Service + const timeline = yield* SessionTimeline.Service + + yield* write(path.join(dir, "a.txt"), "a0") + + const info = yield* session.create({}) + const sid = info.id + const turn = yield* fileTurn({ sessionID: sid, dir, file: "a.txt", content: "a1" }) + + yield* timeline.rewind({ sessionID: sid, messageID: turn.user.id, files: "keep" }) + const restored = yield* timeline.restore({ sessionID: sid }) + expect(restored.revert).toBeUndefined() + expect((yield* session.messages({ sessionID: sid })).map((msg) => msg.info.id)).toEqual([ + turn.user.id, + turn.assistant.id, + ]) + expect(yield* read(path.join(dir, "a.txt"))).toBe("a1") + }), + { git: true }, + ), + ) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index e9628ce49f..b2408a2628 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -4,6 +4,7 @@ import { Schema } from "effect" import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" +import { SessionTimeline } from "../../src/session/timeline" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" @@ -218,8 +219,8 @@ describe("Session input schemas", () => { describe("SessionRevert.RevertInput", () => { const decode = decodeUnknown(SessionRevert.RevertInput) - test("messageID is required, partID is optional", () => { - const withPart = { sessionID, messageID, partID } + test("messageID is required, partID and file policy are optional", () => { + const withPart = { sessionID, messageID, partID, files: "keep" as const } expect(decode(withPart)).toEqual(withPart) expect(SessionRevert.RevertInput.zod.parse(withPart)).toEqual(withPart) @@ -232,6 +233,20 @@ describe("SessionRevert.RevertInput", () => { }) }) +describe("SessionTimeline.RewindInput", () => { + const decode = decodeUnknown(SessionTimeline.RewindInput) + + test("accepts optional file policy", () => { + const keep = { sessionID, messageID, files: "keep" as const } + expect(decode(keep)).toEqual(keep) + expect(SessionTimeline.RewindInput.zod.parse(keep)).toEqual(keep) + + const revert = { sessionID, messageID, partID, files: "revert" as const } + expect(decode(revert)).toEqual(revert) + expect(SessionTimeline.RewindInput.zod.parse(revert)).toEqual(revert) + }) +}) + describe("SessionSummary.DiffInput", () => { const decode = decodeUnknown(SessionSummary.DiffInput) diff --git a/packages/opencode/test/session/session-schema.test.ts b/packages/opencode/test/session/session-schema.test.ts index 38531d15b4..0601b7d9f6 100644 --- a/packages/opencode/test/session/session-schema.test.ts +++ b/packages/opencode/test/session/session-schema.test.ts @@ -63,13 +63,14 @@ describe("Session schema", () => { revert: { messageID: MessageID.ascending(), partID: undefined, + files: undefined, snapshot: undefined, diff: undefined, }, }) as Record expect(Object.hasOwn(encoded.summary as Record, "diffs")).toBe(false) - for (const key of ["partID", "snapshot", "diff"]) { + for (const key of ["partID", "files", "snapshot", "diff"]) { expect(Object.hasOwn(encoded.revert as Record, key)).toBe(false) } }) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 251a4acf3f..136d174733 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -19,8 +19,8 @@ import path from "path" import { Session } from "@/session/session" import { LLM } from "../../src/session/llm" import { SessionPrompt } from "../../src/session/prompt" -import { SessionRevert } from "../../src/session/revert" import { SessionSummary } from "../../src/session/summary" +import { SessionTimeline } from "../../src/session/timeline" import { MessageV2 } from "../../src/session/message-v2" import * as Log from "@opencode-ai/core/util/log" import { provideTmpdirServer } from "../fixture/fixture" @@ -150,7 +150,7 @@ function makeHttp() { TestLLMServer.layer, SessionSummary.defaultLayer, SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), + Layer.provide(SessionTimeline.defaultLayer), Layer.provide(Image.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(run), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index bf3201a5c0..1701e6305d 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3844,6 +3844,7 @@ export class Session2 extends HeyApiClient { workspace?: string messageID?: string partID?: string + files?: "revert" | "keep" }, options?: Options, ) { @@ -3857,6 +3858,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "workspace" }, { in: "body", key: "messageID" }, { in: "body", key: "partID" }, + { in: "body", key: "files" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ae7e9767ce..787ddd561d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,6 +5,12 @@ export type ClientOptions = { } export type Event = + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -24,10 +30,6 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -75,8 +77,6 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventServerConnected - | EventGlobalDisposed export type OAuth = { type: "oauth" @@ -103,6 +103,61 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type PermissionRequest = { id: string sessionID: string @@ -280,61 +335,6 @@ export type SessionStatus = type: "busy" } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type Project = { id: string worktree: string @@ -762,6 +762,7 @@ export type Session = { revert?: { messageID: string partID?: string + files?: "revert" | "keep" snapshot?: string diff?: string } @@ -778,6 +779,12 @@ export type GlobalEvent = { project?: string workspace?: string payload: + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -797,10 +804,6 @@ export type GlobalEvent = { | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -848,8 +851,6 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventServerConnected - | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -1450,6 +1451,7 @@ export type GlobalSession = { revert?: { messageID: string partID?: string + files?: "revert" | "keep" snapshot?: string diff?: string } @@ -1913,6 +1915,7 @@ export type SyncEventSessionUpdated = { revert?: { messageID: string partID?: string + files?: "revert" | "keep" snapshot?: string diff?: string } | null @@ -2330,6 +2333,22 @@ export type SyncEventSessionNextCompactionEnded = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -3044,22 +3063,6 @@ export type EventSessionNextCompactionEnded = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type SessionInfo = { id: string parentID?: string @@ -5935,6 +5938,7 @@ export type SessionRevertData = { body?: { messageID: string partID?: string + files?: "revert" | "keep" } path: { sessionID: string