refactor(session): introduce timeline rewind service

This commit is contained in:
Kit Langton 2026-05-10 12:20:46 -04:00
parent 11030c627b
commit 5469155eed
14 changed files with 394 additions and 219 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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<typeof RevertInput>
@ -32,133 +27,25 @@ export class Service extends Context.Service<Service, Interface>()("@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"

View file

@ -33,3 +33,6 @@ export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
)
export type PartID = Schema.Schema.Type<typeof PartID>
export const RewindFilePolicy = Schema.Union([Schema.Literal("revert"), Schema.Literal("keep")])
export type RewindFilePolicy = Schema.Schema.Type<typeof RewindFilePolicy>

View file

@ -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),
})

View file

@ -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<typeof RewindInput>
export interface Interface {
readonly rewind: (input: RewindInput) => Effect.Effect<Session.Info>
readonly restore: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
readonly commitPending: (input: { sessionID: SessionID }) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@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"

View file

@ -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),

View file

@ -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 },
),
)
})

View file

@ -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)

View file

@ -63,13 +63,14 @@ describe("Session schema", () => {
revert: {
messageID: MessageID.ascending(),
partID: undefined,
files: undefined,
snapshot: undefined,
diff: undefined,
},
}) as Record<string, unknown>
expect(Object.hasOwn(encoded.summary as Record<string, unknown>, "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<string, unknown>, key)).toBe(false)
}
})

View file

@ -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),

View file

@ -3844,6 +3844,7 @@ export class Session2 extends HeyApiClient {
workspace?: string
messageID?: string
partID?: string
files?: "revert" | "keep"
},
options?: Options<never, ThrowOnError>,
) {
@ -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" },
],
},
],

View file

@ -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