run: replay session history on interactive resume (#26880)
This commit is contained in:
@@ -51,6 +51,8 @@ type RunRuntimeInput = {
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
replay?: boolean
|
||||
replayLimit?: number
|
||||
demo?: RunInput["demo"]
|
||||
}
|
||||
|
||||
@@ -67,6 +69,8 @@ type RunLocalInput = {
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
replay?: boolean
|
||||
replayLimit?: number
|
||||
demo?: RunInput["demo"]
|
||||
}
|
||||
|
||||
@@ -490,6 +494,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
directory: ctx.directory,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
replay: input.replay,
|
||||
replayLimit: input.replayLimit,
|
||||
limits: () => state.limits,
|
||||
footer,
|
||||
trace: log,
|
||||
@@ -722,6 +728,8 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
|
||||
files: input.files,
|
||||
initialInput: input.initialInput,
|
||||
thinking: input.thinking,
|
||||
replay: input.replay,
|
||||
replayLimit: input.replayLimit,
|
||||
demo: input.demo,
|
||||
resolveSession: () => {
|
||||
if (session) {
|
||||
@@ -774,6 +782,8 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
|
||||
files: input.files,
|
||||
initialInput: input.initialInput,
|
||||
thinking: input.thinking,
|
||||
replay: input.replay,
|
||||
replayLimit: input.replayLimit,
|
||||
demo: input.demo,
|
||||
boot: async () => ({
|
||||
sdk: input.sdk,
|
||||
|
||||
188
packages/opencode/src/cli/cmd/run/session-replay.ts
Normal file
188
packages/opencode/src/cli/cmd/run/session-replay.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||
import { messagePrompt, type SessionMessages } from "./session.shared"
|
||||
import type { FooterPatch, StreamCommit } from "./types"
|
||||
|
||||
type ReplayInput = {
|
||||
messages: SessionMessages
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}
|
||||
|
||||
export type SessionReplay = {
|
||||
data: SessionData
|
||||
commits: StreamCommit[]
|
||||
patch?: FooterPatch
|
||||
}
|
||||
|
||||
type ReplayMessage = {
|
||||
commits: StreamCommit[]
|
||||
patch?: FooterPatch
|
||||
}
|
||||
|
||||
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
|
||||
return reduceSessionData({
|
||||
data,
|
||||
event,
|
||||
sessionID,
|
||||
thinking,
|
||||
limits,
|
||||
})
|
||||
}
|
||||
|
||||
function mergePatch(left: FooterPatch | undefined, right: FooterPatch | undefined) {
|
||||
if (!left) {
|
||||
return right
|
||||
}
|
||||
|
||||
if (!right) {
|
||||
return left
|
||||
}
|
||||
|
||||
return {
|
||||
...left,
|
||||
...right,
|
||||
}
|
||||
}
|
||||
|
||||
function active(data: SessionData) {
|
||||
return data.part.size > 0 || data.tools.size > 0
|
||||
}
|
||||
|
||||
function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
|
||||
if (active(data)) {
|
||||
if (!patch) {
|
||||
return {
|
||||
phase: "running",
|
||||
} satisfies FooterPatch
|
||||
}
|
||||
|
||||
return {
|
||||
...patch,
|
||||
phase: "running",
|
||||
} satisfies FooterPatch
|
||||
}
|
||||
|
||||
if (data.permissions.length > 0 || data.questions.length > 0) {
|
||||
if (!patch) {
|
||||
return {
|
||||
phase: "idle",
|
||||
} satisfies FooterPatch
|
||||
}
|
||||
|
||||
return {
|
||||
...patch,
|
||||
phase: "idle",
|
||||
} satisfies FooterPatch
|
||||
}
|
||||
|
||||
if (!patch) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...patch,
|
||||
phase: "idle",
|
||||
status: "",
|
||||
} satisfies FooterPatch
|
||||
}
|
||||
|
||||
function replayMessage(
|
||||
data: SessionData,
|
||||
message: SessionMessages[number],
|
||||
thinking: boolean,
|
||||
limits: Record<string, number>,
|
||||
): ReplayMessage {
|
||||
if (message.info.role === "user") {
|
||||
const prompt = messagePrompt(message)
|
||||
if (!prompt.text.trim()) {
|
||||
return {
|
||||
commits: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
commits: [
|
||||
{
|
||||
kind: "user",
|
||||
text: prompt.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: message.info.id,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const commits: StreamCommit[] = []
|
||||
let patch: FooterPatch | undefined
|
||||
|
||||
const info = apply(
|
||||
data,
|
||||
{
|
||||
id: `bootstrap:message:${message.info.id}`,
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: message.info.sessionID,
|
||||
info: message.info,
|
||||
},
|
||||
},
|
||||
message.info.sessionID,
|
||||
thinking,
|
||||
limits,
|
||||
)
|
||||
commits.push(...info.commits)
|
||||
patch = mergePatch(patch, info.footer?.patch)
|
||||
|
||||
for (const part of message.parts) {
|
||||
const next = apply(
|
||||
data,
|
||||
{
|
||||
id: `bootstrap:part:${part.id}`,
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: part.sessionID,
|
||||
part,
|
||||
time: 0,
|
||||
},
|
||||
},
|
||||
message.info.sessionID,
|
||||
thinking,
|
||||
limits,
|
||||
)
|
||||
patch = mergePatch(patch, next.footer?.patch)
|
||||
commits.push(...next.commits)
|
||||
}
|
||||
|
||||
return {
|
||||
commits,
|
||||
patch,
|
||||
}
|
||||
}
|
||||
|
||||
export function replaySession(input: ReplayInput): SessionReplay {
|
||||
const data = createSessionData()
|
||||
const commits: StreamCommit[] = []
|
||||
let patch: FooterPatch | undefined
|
||||
|
||||
bootstrapSessionData({
|
||||
data,
|
||||
messages: input.messages,
|
||||
permissions: input.permissions,
|
||||
questions: input.questions,
|
||||
})
|
||||
|
||||
for (const message of input.messages) {
|
||||
const next = replayMessage(data, message, input.thinking, input.limits)
|
||||
commits.push(...next.commits)
|
||||
patch = mergePatch(patch, next.patch)
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
commits,
|
||||
patch: replayPatch(data, patch),
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function fileSource(
|
||||
}
|
||||
}
|
||||
|
||||
function prompt(msg: SessionMessages[number]): RunPrompt {
|
||||
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
|
||||
const parts: RunPrompt["parts"] = []
|
||||
let text = msg.parts
|
||||
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
||||
@@ -135,7 +135,7 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: prompt(msg),
|
||||
prompt: messagePrompt(msg),
|
||||
provider: msg.info.model.providerID,
|
||||
model: msg.info.model.modelID,
|
||||
variant: msg.info.model.variant,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
reduceSessionData,
|
||||
type SessionData,
|
||||
} from "./session-data"
|
||||
import { replaySession } from "./session-replay"
|
||||
import {
|
||||
bootstrapSubagentCalls,
|
||||
bootstrapSubagentData,
|
||||
@@ -66,6 +67,8 @@ type StreamInput = {
|
||||
directory?: string
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
replay?: boolean
|
||||
replayLimit?: number
|
||||
limits: () => Record<string, number>
|
||||
footer: FooterApi
|
||||
trace?: Trace
|
||||
@@ -432,7 +435,12 @@ function createLayer(input: StreamInput) {
|
||||
blockerTick: 0,
|
||||
blockers: new Map(),
|
||||
}
|
||||
let booting = true
|
||||
const buffered: Event[] = []
|
||||
const replayedParts = new Set<string>()
|
||||
const recovering = new Set<string>()
|
||||
const tracked = (sessionID: string | undefined) =>
|
||||
sessionID === input.sessionID || (!!sessionID && state.subagent.tabs.has(sessionID))
|
||||
const currentSubagentState = () => {
|
||||
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
|
||||
state.selectedSubagent = undefined
|
||||
@@ -550,11 +558,11 @@ function createLayer(input: StreamInput) {
|
||||
}
|
||||
})
|
||||
|
||||
const messages = (sessionID: string, limit: number) =>
|
||||
const messages = (sessionID: string, limit?: number) =>
|
||||
Effect.promise(() =>
|
||||
input.sdk.session.messages({
|
||||
sessionID,
|
||||
limit,
|
||||
...(typeof limit === "number" ? { limit } : {}),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.map((item) => item.data ?? []),
|
||||
@@ -596,7 +604,14 @@ function createLayer(input: StreamInput) {
|
||||
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
|
||||
const [messagesList, children, permissions, questions] = yield* Effect.all(
|
||||
[
|
||||
messages(input.sessionID, SUBAGENT_BOOTSTRAP_LIMIT),
|
||||
messages(
|
||||
input.sessionID,
|
||||
input.replay
|
||||
? (input.replayLimit === undefined
|
||||
? undefined
|
||||
: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT))
|
||||
: SUBAGENT_BOOTSTRAP_LIMIT,
|
||||
),
|
||||
Effect.promise(() =>
|
||||
input.sdk.session.children({
|
||||
sessionID: input.sessionID,
|
||||
@@ -619,12 +634,51 @@ function createLayer(input: StreamInput) {
|
||||
},
|
||||
)
|
||||
|
||||
bootstrapSessionData({
|
||||
data: state.data,
|
||||
messages: messagesList,
|
||||
permissions: permissions.filter((item) => item.sessionID === input.sessionID),
|
||||
questions: questions.filter((item) => item.sessionID === input.sessionID),
|
||||
})
|
||||
const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID)
|
||||
const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID)
|
||||
const history = input.replay
|
||||
? replaySession({
|
||||
messages: messagesList,
|
||||
permissions: sessionPermissions,
|
||||
questions: sessionQuestions,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
: undefined
|
||||
const replay = history && input.replayLimit !== undefined && messagesList.length > input.replayLimit
|
||||
? replaySession({
|
||||
messages: messagesList.slice(-input.replayLimit),
|
||||
permissions: sessionPermissions,
|
||||
questions: sessionQuestions,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
: history
|
||||
|
||||
replayedParts.clear()
|
||||
if (history) {
|
||||
state.data = history.data
|
||||
}
|
||||
|
||||
if (!history) {
|
||||
bootstrapSessionData({
|
||||
data: state.data,
|
||||
messages: messagesList,
|
||||
permissions: sessionPermissions,
|
||||
questions: sessionQuestions,
|
||||
})
|
||||
}
|
||||
|
||||
if (replay) {
|
||||
for (const [partID] of replay.data.text) {
|
||||
if (!replay.data.part.has(partID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
replayedParts.add(partID)
|
||||
}
|
||||
}
|
||||
|
||||
bootstrapSubagentData({
|
||||
data: state.subagent,
|
||||
messages: messagesList,
|
||||
@@ -632,6 +686,7 @@ function createLayer(input: StreamInput) {
|
||||
permissions,
|
||||
questions,
|
||||
})
|
||||
clearFinishedSubagents(state.subagent)
|
||||
|
||||
for (const request of [
|
||||
...state.data.permissions,
|
||||
@@ -642,9 +697,29 @@ function createLayer(input: StreamInput) {
|
||||
seedBlocker(request.id)
|
||||
}
|
||||
|
||||
if (replay) {
|
||||
const activeCommitIDs = new Set([...state.data.part.keys(), ...state.data.tools])
|
||||
for (const commit of replay.commits) {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
|
||||
if (commit.partID && activeCommitIDs.has(commit.partID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined))
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = currentSubagentState()
|
||||
traceTabs(input.trace, [], snapshot.tabs)
|
||||
syncFooter([], undefined, snapshot)
|
||||
syncFooter([], replay?.patch, snapshot)
|
||||
if (replay) {
|
||||
yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined))
|
||||
}
|
||||
|
||||
booting = false
|
||||
yield* drainBuffered()
|
||||
|
||||
const sessions = [...state.subagent.tabs.keys()]
|
||||
if (sessions.length === 0) {
|
||||
@@ -738,6 +813,86 @@ function createLayer(input: StreamInput) {
|
||||
})
|
||||
}
|
||||
|
||||
const applyEvent = Effect.fn("RunStreamTransport.applyEvent")(function* (event: Event) {
|
||||
if (event.type === "message.part.delta" && event.properties.sessionID === input.sessionID) {
|
||||
if (replayedParts.has(event.properties.partID)) {
|
||||
const seen = state.data.text.get(event.properties.partID) ?? ""
|
||||
if (seen.endsWith(event.properties.delta)) {
|
||||
return
|
||||
}
|
||||
|
||||
replayedParts.delete(event.properties.partID)
|
||||
}
|
||||
}
|
||||
|
||||
trackBlocker(event)
|
||||
|
||||
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
|
||||
const next = reduceSessionData({
|
||||
data: state.data,
|
||||
event,
|
||||
sessionID: input.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
state.data = next.data
|
||||
|
||||
if (
|
||||
event.type === "message.part.updated" &&
|
||||
event.properties.part.sessionID === input.sessionID &&
|
||||
event.properties.part.type === "tool" &&
|
||||
event.properties.part.tool === "question" &&
|
||||
event.properties.part.state.status === "running" &&
|
||||
state.data.questions.length === 0
|
||||
) {
|
||||
yield* recoverQuestion(event.properties.part.id).pipe(
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
|
||||
const changed = reduceSubagentData({
|
||||
data: state.subagent,
|
||||
event,
|
||||
sessionID: input.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
if (changed && prev) {
|
||||
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
|
||||
}
|
||||
releaseBlocker(event)
|
||||
|
||||
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
|
||||
|
||||
touch(event)
|
||||
yield* mark(event)
|
||||
})
|
||||
|
||||
const drainBuffered = Effect.fn("RunStreamTransport.drainBuffered")(function* () {
|
||||
let pending = buffered.splice(0)
|
||||
while (pending.length > 0) {
|
||||
const next: Event[] = []
|
||||
let changed = false
|
||||
for (const event of pending) {
|
||||
if (!tracked(sid(event))) {
|
||||
next.push(event)
|
||||
continue
|
||||
}
|
||||
|
||||
changed = true
|
||||
yield* applyEvent(event)
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
buffered.push(...next)
|
||||
return
|
||||
}
|
||||
|
||||
pending = next
|
||||
}
|
||||
})
|
||||
|
||||
const watch = Effect.fn("RunStreamTransport.watch")(() =>
|
||||
Stream.fromAsyncIterable(events.stream, (error) =>
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
@@ -762,53 +917,25 @@ function createLayer(input: StreamInput) {
|
||||
}
|
||||
|
||||
const sessionID = sid(event)
|
||||
if (sessionID !== input.sessionID && (!sessionID || !state.subagent.tabs.has(sessionID))) {
|
||||
if (booting) {
|
||||
if (sessionID) {
|
||||
input.trace?.write("recv.event", event)
|
||||
buffered.push(event)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!tracked(sessionID)) {
|
||||
if (sessionID) {
|
||||
input.trace?.write("recv.event", event)
|
||||
buffered.push(event)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
input.trace?.write("recv.event", event)
|
||||
trackBlocker(event)
|
||||
|
||||
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
|
||||
const next = reduceSessionData({
|
||||
data: state.data,
|
||||
event,
|
||||
sessionID: input.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
state.data = next.data
|
||||
|
||||
if (
|
||||
event.type === "message.part.updated" &&
|
||||
event.properties.part.sessionID === input.sessionID &&
|
||||
event.properties.part.type === "tool" &&
|
||||
event.properties.part.tool === "question" &&
|
||||
event.properties.part.state.status === "running" &&
|
||||
state.data.questions.length === 0
|
||||
) {
|
||||
yield* recoverQuestion(event.properties.part.id).pipe(
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
|
||||
const changed = reduceSubagentData({
|
||||
data: state.subagent,
|
||||
event,
|
||||
sessionID: input.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
if (changed && prev) {
|
||||
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
|
||||
}
|
||||
releaseBlocker(event)
|
||||
|
||||
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
|
||||
|
||||
touch(event)
|
||||
yield* mark(event)
|
||||
yield* applyEvent(event)
|
||||
yield* drainBuffered()
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),
|
||||
@@ -823,8 +950,8 @@ function createLayer(input: StreamInput) {
|
||||
),
|
||||
)
|
||||
|
||||
yield* bootstrap()
|
||||
yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
|
||||
yield* bootstrap()
|
||||
|
||||
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
|
||||
if (closed || next.signal?.aborted || input.footer.isClosed) {
|
||||
|
||||
@@ -420,9 +420,26 @@ function ensureBlockerTab(
|
||||
title: string | undefined,
|
||||
kind: "permission" | "question",
|
||||
) {
|
||||
if (data.tabs.has(sessionID)) {
|
||||
const current = data.tabs.get(sessionID)
|
||||
if (current) {
|
||||
ensureDetail(data, sessionID)
|
||||
return false
|
||||
if (current.status !== "running") {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = {
|
||||
...current,
|
||||
description: kind === "permission" ? "Pending permission" : "Pending question",
|
||||
status: "running" as const,
|
||||
title: current.title ?? title,
|
||||
lastUpdatedAt: Date.now(),
|
||||
}
|
||||
if (sameSubagentTab(current, next)) {
|
||||
return false
|
||||
}
|
||||
|
||||
data.tabs.set(sessionID, next)
|
||||
return true
|
||||
}
|
||||
|
||||
data.tabs.set(sessionID, {
|
||||
|
||||
@@ -52,6 +52,8 @@ export type RunInput = {
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
resume?: boolean
|
||||
replay?: boolean
|
||||
replayLimit?: number
|
||||
agent: string | undefined
|
||||
model: PromptModel | undefined
|
||||
variant: string | undefined
|
||||
|
||||
Reference in New Issue
Block a user