mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-25 06:35:35 +00:00
refactor(session): introduce timeline rewind service
This commit is contained in:
parent
11030c627b
commit
5469155eed
14 changed files with 394 additions and 219 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
159
packages/opencode/src/session/timeline.ts
Normal file
159
packages/opencode/src/session/timeline.ts
Normal 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"
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue