add run --replay mode (#30239)

This commit is contained in:
Simon Klee
2026-06-01 15:11:45 +02:00
committed by GitHub
parent 2e33aa5187
commit cb4e002655
16 changed files with 1292 additions and 58 deletions
+77 -4
View File
@@ -14,13 +14,14 @@
// 4. runs the prompt queue until the footer closes.
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { MessageID } from "@/session/schema"
import { createRunDemo } from "./demo"
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
import { trace } from "./trace"
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
import type { RunInput, RunPrompt, RunProvider } from "./types"
import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types"
/** @internal Exported for testing */
export { pickVariant, resolveVariant } from "./variant.shared"
@@ -114,6 +115,7 @@ type RuntimeState = {
activeVariant: string | undefined
sessionID: string
history: RunPrompt[]
localRows: LocalReplayRow[]
sessionTitle?: string
agent: string | undefined
switching?: Promise<void>
@@ -139,6 +141,9 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
}
const REPLAY_RESIZE_DELAY = 250
const LOCAL_REPLAY_ROW_LIMIT = 100
async function resolveExitTitle(
ctx: BootContext,
input: RunRuntimeInput,
@@ -196,6 +201,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
sessionID: ctx.sessionID,
history: [...session.history],
localRows: [],
sessionTitle: ctx.sessionTitle,
agent: ctx.agent,
}
@@ -374,6 +380,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
},
})
const footer = shell.footer
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
}
const loadCatalog = async (): Promise<void> => {
if (footer.isClosed) {
@@ -510,6 +519,36 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
return next
}
let replayResizeTimer: ReturnType<typeof setTimeout> | undefined
const offResize = input.replay
? shell.onResize(() => {
if (replayResizeTimer) {
clearTimeout(replayResizeTimer)
}
replayResizeTimer = setTimeout(() => {
replayResizeTimer = undefined
if (footer.isClosed || !state.stream) {
return
}
void state.stream
.then((item) =>
item.handle.replayOnResize({
localRows: () => state.localRows,
reset: () =>
shell.resetForReplay({
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
history: state.history,
}),
}),
)
.catch(() => {})
}, REPLAY_RESIZE_DELAY)
})
: () => {}
const runQueue = async () => {
let includeFiles = true
if (state.demo) {
@@ -525,6 +564,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
onSend: (prompt) => {
state.shown = true
state.history.push(prompt)
if (prompt.mode !== "shell") {
rememberLocal({
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: prompt.messageID,
})
}
},
onNewSession: createSession
? async () => {
@@ -545,6 +593,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
state.sessionTitle = created.sessionTitle
state.agent = created.agent ?? state.agent
state.history = []
state.localRows = []
includeFiles = true
state.demo = input.demo
? createRunDemo({
@@ -598,12 +647,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
status: "failed to start new session",
},
})
footer.append({
const commit = {
kind: "error",
text: error instanceof Error ? error.message : String(error),
phase: "start",
source: "system",
})
messageID: MessageID.ascending(),
} as const
rememberLocal(commit)
footer.append(commit)
}
}
: undefined,
@@ -614,6 +666,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
await state.switching?.catch(() => {})
let outputAnchor: LocalReplayAnchor | undefined
return withRunSpan(
"RunInteractive.turn",
{
@@ -644,8 +697,16 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
prompt,
files: input.files,
includeFiles,
onVisibleOutput: (anchor) => {
outputAnchor = anchor
},
signal,
})
if (prompt.messageID) {
state.localRows = state.localRows.filter(
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
)
}
includeFiles = false
} catch (error) {
if (signal.aborted || footer.isClosed) {
@@ -656,7 +717,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
const text =
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
(error instanceof Error ? error.message : String(error))
footer.append({ kind: "error", text, phase: "start", source: "system" })
const commit = {
kind: "error",
text,
phase: "start",
source: "system",
messageID: prompt.messageID,
} as const
rememberLocal(commit, outputAnchor)
footer.append(commit)
}
},
)
@@ -683,6 +752,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
try {
await runQueue()
} finally {
if (replayResizeTimer) {
clearTimeout(replayResizeTimer)
}
offResize()
await state.stream?.then((item) => item.handle.close()).catch(() => {})
}
} finally {