add run --replay mode (#30239)

This commit is contained in:
Simon Klee
2026-06-01 15:11:45 +02:00
committed by GitHub
parent d85f8cd4d8
commit 6a3b2f339a
16 changed files with 1292 additions and 58 deletions
+1 -1
View File
@@ -221,7 +221,7 @@ export const RunCommand = effectCmd({
.option("replay", {
type: "boolean",
default: false,
describe: "replay visible session history on interactive resume",
describe: "replay interactive session history on resume and after resize",
})
.option("replay-limit", {
type: "number",
+32 -8
View File
@@ -171,6 +171,7 @@ export class RunFooter implements FooterApi {
private queue: StreamCommit[] = []
private pending = false
private flushing: Promise<void> = Promise.resolve()
private flushError: unknown
// Fixed portion of footer height above the textarea.
private base: number
private rows = TEXTAREA_MIN_ROWS
@@ -204,6 +205,15 @@ export class RunFooter implements FooterApi {
private requestExitHandler: (() => boolean) | undefined
private scrollback: RunScrollbackStream
private createScrollback(wrote: boolean): RunScrollbackStream {
return new RunScrollbackStream(this.renderer, this.options.theme, {
diffStyle: this.options.diffStyle,
wrote,
sessionID: this.options.sessionID,
treeSitterClient: this.options.treeSitterClient,
})
}
constructor(
private renderer: CliRenderer,
private options: RunFooterOptions,
@@ -257,12 +267,7 @@ export class RunFooter implements FooterApi {
this.queuedPrompts = queuedPrompts
this.setQueuedPrompts = setQueuedPrompts
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
diffStyle: options.diffStyle,
wrote: options.wrote,
sessionID: options.sessionID,
treeSitterClient: options.treeSitterClient,
})
this.scrollback = this.createScrollback(options.wrote ?? false)
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
@@ -465,7 +470,9 @@ export class RunFooter implements FooterApi {
},
),
)
.catch(() => {})
.catch((error) => {
this.flushError = error
})
}
private present(view: FooterView): void {
@@ -523,6 +530,12 @@ export class RunFooter implements FooterApi {
}
return this.flushing.then(async () => {
if (this.flushError !== undefined) {
const error = this.flushError
this.flushError = undefined
throw error
}
if (this.isGone) {
return
}
@@ -535,6 +548,15 @@ export class RunFooter implements FooterApi {
})
}
public resetForReplay(wrote: boolean): void {
if (this.isGone) {
return
}
this.scrollback.destroy()
this.scrollback = this.createScrollback(wrote)
}
public close(): void {
if (this.closed) {
return
@@ -936,6 +958,8 @@ export class RunFooter implements FooterApi {
},
),
)
.catch(() => {})
.catch((error) => {
this.flushError = error
})
}
}
@@ -8,7 +8,7 @@
//
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
// back to the usual two-press exit sequence through RunFooter.requestExit().
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Session as SessionApi } from "@/session/session"
import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
@@ -75,6 +75,8 @@ export type LifecycleInput = {
export type Lifecycle = {
footer: FooterApi
onResize(fn: () => void): () => void
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
}
@@ -307,6 +309,46 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
return {
footer,
onResize(fn) {
let width = renderer.terminalWidth
let height = renderer.terminalHeight
const resize = () => {
if (width === renderer.terminalWidth && height === renderer.terminalHeight) {
return
}
width = renderer.terminalWidth
height = renderer.terminalHeight
fn()
}
renderer.on(CliRenderEvents.RESIZE, resize)
return () => renderer.off(CliRenderEvents.RESIZE, resize)
},
async resetForReplay(next) {
if (closed || renderer.isDestroyed || footer.isClosed) {
throw new Error("runtime closed")
}
await footer.idle()
if (closed || renderer.isDestroyed || footer.isClosed) {
throw new Error("runtime closed")
}
footer.resetForReplay(true)
renderer.resetSplitFooterForReplay({ clearSavedLines: true })
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
renderer.writeToScrollback(
entrySplash({
...splashMeta({
title: splash.title,
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
}),
theme: theme.splash,
showSession: splash.showSession,
}),
)
renderer.requestRender()
},
close,
}
} catch (error) {
@@ -162,7 +162,14 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
continue
}
state.active = prompt
const sent =
prompt.mode === "shell"
? prompt
: {
...prompt,
messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(),
}
state.active = sent
emit(
{
@@ -185,18 +192,24 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
break
}
if (prompt.mode !== "shell") {
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
if (sent.mode !== "shell") {
const commit = {
kind: "user",
text: sent.text,
phase: "start",
source: "system",
messageID: sent.messageID,
} as const
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
}
input.onSend?.(prompt)
input.onSend?.(sent)
if (state.closed) {
break
}
const task = input.run(prompt, ctrl.signal).then(
const task = input.run(sent, ctrl.signal).then(
() => ({ type: "done" as const }),
(error) => ({ type: "error" as const, error }),
)
+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 {
@@ -60,6 +60,7 @@ type SessionCommit = StreamCommit
// - part: part ID → "assistant" | "reasoning" (text parts only)
// - text: part ID → full accumulated text so far
// - sent: part ID → byte offset of last flushed text (for incremental output)
// - visible: part ID → rendered text for an active part after display transforms
// - end: part IDs whose time.end has arrived (part is finished)
// - shell: shell call ID → chosen transcript source for direct shell calls
// - echo: message ID → bash outputs to strip from the next assistant chunk
@@ -82,6 +83,7 @@ export type SessionData = {
part: Map<string, PartKind>
text: Map<string, string>
sent: Map<string, number>
visible: Map<string, string>
end: Set<string>
echo: Map<string, Set<string>>
}
@@ -119,6 +121,7 @@ export function createSessionData(
part: new Map(),
text: new Map(),
sent: new Map(),
visible: new Map(),
end: new Set(),
echo: new Map(),
}
@@ -538,6 +541,7 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string,
if (chunk) {
data.sent.set(partID, text.length)
data.visible.set(partID, (data.visible.get(partID) ?? "") + chunk)
commits.push({
kind,
text: chunk,
@@ -567,6 +571,7 @@ function drop(data: SessionData, partID: string) {
data.part.delete(partID)
data.text.delete(partID)
data.sent.delete(partID)
data.visible.delete(partID)
data.msg.delete(partID)
data.end.delete(partID)
}
@@ -1,7 +1,7 @@
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"
import type { FooterPatch, LocalReplayRow, StreamCommit } from "./types"
type ReplayInput = {
messages: SessionMessages
@@ -186,3 +186,112 @@ export function replaySession(input: ReplayInput): SessionReplay {
patch: replayPatch(data, patch),
}
}
export function replayLocalRows(messages: SessionMessages, commits: StreamCommit[], rows: LocalReplayRow[]): StreamCommit[] {
const persisted = new Set(messages.map((message) => message.info.id))
return rows.reduce((out, local) => {
const row = local.commit
if (row.kind === "user" && row.messageID && persisted.has(row.messageID)) {
return out
}
if (!row.messageID) {
return [...out, row]
}
const exact = local.after
? out.findIndex(
(commit) =>
commit.kind === local.after?.kind &&
commit.text === local.after.text &&
commit.phase === local.after.phase &&
commit.toolState === local.after.toolState &&
(local.after.partID ? commit.partID === local.after.partID : commit.messageID === local.after.messageID),
)
: -1
const anchored =
exact !== -1
? exact
: local.after
? out.findLastIndex((commit) =>
local.after?.partID
? commit.partID === local.after.partID
: commit.kind === local.after?.kind && commit.messageID === local.after.messageID,
)
: -1
if (anchored !== -1) {
const commit = out[anchored]
const visible = local.after?.visible
if (commit && visible && commit.text.startsWith(visible) && commit.text.length > visible.length) {
return [
...out.slice(0, anchored),
{ ...commit, text: visible },
row,
{ ...commit, text: commit.text.slice(visible.length) },
...out.slice(anchored + 1),
]
}
return [...out.slice(0, anchored + 1), row, ...out.slice(anchored + 1)]
}
const after = out.findIndex((commit) => commit.kind === "user" && commit.messageID === row.messageID)
if (after !== -1) {
return [...out.slice(0, after + 1), row, ...out.slice(after + 1)]
}
const before = out.findIndex((commit) => commit.messageID && row.messageID! < commit.messageID)
if (before === -1) {
return [...out, row]
}
return [...out.slice(0, before), row, ...out.slice(before)]
}, commits)
}
export function replayActiveText(data: SessionData, current: SessionData): StreamCommit[] {
return [...current.part.entries()].flatMap(([partID, kind]) => {
if (kind === "user" || current.end.has(partID) || data.ids.has(partID)) {
return []
}
const text = current.text.get(partID) ?? ""
const existing = data.text.get(partID) ?? ""
const sent = current.sent.get(partID) ?? 0
const existingSent = data.sent.get(partID) ?? 0
const visible = current.visible.get(partID) ?? ""
const existingVisible = data.visible.get(partID) ?? ""
if (!text.startsWith(existing) || existingSent > sent || !visible.startsWith(existingVisible)) {
return []
}
data.part.set(partID, kind)
data.text.set(partID, text)
data.sent.set(partID, sent)
data.visible.set(partID, visible)
const messageID = current.msg.get(partID)
if (messageID) {
data.msg.set(partID, messageID)
const role = current.role.get(messageID)
if (role) {
data.role.set(messageID, role)
}
}
const chunk = visible.slice(existingVisible.length)
if (!chunk) {
return []
}
return [
{
kind,
text: chunk,
phase: "progress",
source: kind,
...(messageID ? { messageID } : {}),
partID,
},
] satisfies StreamCommit[]
})
}
@@ -27,7 +27,7 @@ import {
reduceSessionData,
type SessionData,
} from "./session-data"
import { replaySession } from "./session-replay"
import { replayActiveText, replayLocalRows, replaySession } from "./session-replay"
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
@@ -51,6 +51,8 @@ import type {
FooterSubagentState,
FooterSubagentTab,
FooterView,
LocalReplayAnchor,
LocalReplayRow,
RunFilePart,
RunInput,
RunPrompt,
@@ -81,6 +83,7 @@ type Wait = {
tick: number
armed: boolean
live: boolean
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
done: Deferred.Deferred<void, unknown>
}
@@ -91,15 +94,22 @@ export type SessionTurnInput = {
prompt: RunPrompt
files: RunFilePart[]
includeFiles: boolean
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
signal?: AbortSignal
}
export type SessionTransport = {
runPromptTurn(input: SessionTurnInput): Promise<void>
selectSubagent(sessionID: string | undefined): void
replayOnResize(input: SessionResizeReplayInput): Promise<boolean>
close(): Promise<void>
}
export type SessionResizeReplayInput = {
localRows: () => LocalReplayRow[]
reset: () => Promise<void>
}
type State = {
data: SessionData
subagent: SubagentData
@@ -115,6 +125,7 @@ type State = {
type TransportService = {
readonly runPromptTurn: (input: SessionTurnInput) => Effect.Effect<void, unknown>
readonly selectSubagent: (sessionID: string | undefined) => Effect.Effect<void>
readonly replayOnResize: (input: SessionResizeReplayInput) => Effect.Effect<boolean>
readonly close: () => Effect.Effect<void>
}
@@ -440,6 +451,9 @@ function createLayer(input: StreamInput) {
blockers: new Map(),
}
let booting = true
let replaying = false
let replayDisabled = false
let replayPending: SessionResizeReplayInput | undefined
const buffered: Event[] = []
const replayedParts = new Set<string>()
const recovering = new Set<string>()
@@ -594,6 +608,38 @@ function createLayer(input: StreamInput) {
Effect.orElseSucceed(() => []),
)
const replayMessages = () =>
Effect.promise(() =>
input.sdk.session.messages({
sessionID: input.sessionID,
...(input.replayLimit === undefined
? {}
: { limit: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT) }),
}),
).pipe(Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))))
const replayRequests = () =>
Effect.all(
[
Effect.promise(() => input.sdk.permission.list()).pipe(
Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))),
),
Effect.promise(() => input.sdk.question.list()).pipe(
Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))),
),
],
{ concurrency: "unbounded" },
)
const markReplayedParts = (data: SessionData) => {
replayedParts.clear()
for (const [partID] of data.text) {
if (data.part.has(partID)) {
replayedParts.add(partID)
}
}
}
const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(function* (
sessions: string[],
) {
@@ -681,7 +727,6 @@ function createLayer(input: StreamInput) {
})
: history
replayedParts.clear()
if (history) {
state.data = history.data
}
@@ -695,14 +740,8 @@ function createLayer(input: StreamInput) {
})
}
if (replay) {
for (const [partID] of replay.data.text) {
if (!replay.data.part.has(partID)) {
continue
}
replayedParts.add(partID)
}
if (history) {
markReplayedParts(history.data)
}
bootstrapSubagentData({
@@ -862,6 +901,20 @@ function createLayer(input: StreamInput) {
limits: input.limits(),
})
state.data = next.data
const visible = next.commits.at(-1)
if (visible) {
state.wait?.onVisibleOutput?.({
kind: visible.kind,
text: visible.text,
phase: visible.phase,
messageID: visible.messageID,
partID: visible.partID,
toolState: visible.toolState,
...(visible.partID && state.data.visible.has(visible.partID)
? { visible: state.data.visible.get(visible.partID) }
: {}),
})
}
if (
event.type === "message.part.updated" &&
@@ -910,15 +963,163 @@ function createLayer(input: StreamInput) {
yield* applyEvent(event)
}
if (!changed) {
const arrived = buffered.splice(0)
if (!changed && arrived.length === 0) {
buffered.push(...next)
return
}
pending = next
pending = [...next, ...arrived]
}
})
const replayOnResize: (next: SessionResizeReplayInput) => Effect.Effect<boolean> = Effect.fn(
"RunStreamTransport.replayOnResize",
)(function* (next: SessionResizeReplayInput) {
if (!input.replay || replayDisabled || booting || closed || input.footer.isClosed) {
return false
}
if (replaying) {
replayPending = next
return false
}
const finish: () => Effect.Effect<void> = Effect.fnUntraced(function* () {
yield* drainBuffered()
const pending = replayPending
replayPending = undefined
if (!pending || replayDisabled || closed || input.footer.isClosed) {
replaying = false
return
}
replaying = false
yield* replayOnResize(pending).pipe(Effect.asVoid)
})
replayedParts.clear()
replaying = true
input.trace?.write("replay.resize.start", {
sessionID: input.sessionID,
})
const source = yield* Effect.all([replayMessages(), replayRequests()], { concurrency: "unbounded" }).pipe(
Effect.exit,
)
if (Exit.isFailure(source)) {
input.trace?.write("replay.resize.abort", {
sessionID: input.sessionID,
phase: "snapshot",
})
yield* finish()
return false
}
const [messagesList, [permissions, questions]] = source.value
const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID)
const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID)
const snapshot = yield* Effect.try({
try: () => {
const history = replaySession({
messages: messagesList,
permissions: sessionPermissions,
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
})
const activeCommits = replayActiveText(history.data, state.data)
return {
history,
activeCommits,
patch:
history.data.part.size > 0 || history.data.tools.size > 0
? { ...history.patch, phase: "running" as const }
: history.patch,
visible:
input.replayLimit !== undefined && messagesList.length > input.replayLimit
? replaySession({
messages: messagesList.slice(-input.replayLimit),
permissions: sessionPermissions,
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
})
: history,
}
},
catch: (error) => error,
}).pipe(Effect.exit)
if (Exit.isFailure(snapshot)) {
input.trace?.write("replay.resize.abort", {
sessionID: input.sessionID,
phase: "snapshot",
})
yield* finish()
return false
}
const idle = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit)
if (Exit.isFailure(idle) || closed || input.footer.isClosed) {
yield* finish()
return false
}
const reset = yield* Effect.promise(() => next.reset()).pipe(Effect.exit)
if (Exit.isFailure(reset)) {
replayDisabled = true
input.trace?.write("replay.resize.disable", {
sessionID: input.sessionID,
phase: "reset",
})
input.footer.append({
kind: "error",
text: "resize replay failed; disabled for this session",
phase: "start",
source: "system",
})
yield* finish()
return false
}
state.data = snapshot.value.history.data
for (const request of [...state.data.permissions, ...state.data.questions]) {
seedBlocker(request.id)
}
for (const commit of replayLocalRows(
messagesList,
[...snapshot.value.visible.commits, ...snapshot.value.activeCommits],
next.localRows(),
)) {
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
}
syncFooter([], snapshot.value.patch, currentSubagentState())
const rebuilt = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit)
if (Exit.isFailure(rebuilt)) {
replayDisabled = true
input.trace?.write("replay.resize.disable", {
sessionID: input.sessionID,
phase: "rebuild",
})
input.footer.append({
kind: "error",
text: "resize replay failed; disabled for this session",
phase: "start",
source: "system",
})
yield* finish()
return false
}
input.trace?.write("replay.resize.complete", {
sessionID: input.sessionID,
})
yield* finish()
return true
})
const watch = Effect.fn("RunStreamTransport.watch")(() =>
Stream.fromAsyncIterable(events.stream, (error) =>
error instanceof Error ? error : new Error(String(error)),
@@ -943,7 +1144,7 @@ function createLayer(input: StreamInput) {
}
const sessionID = sid(event)
if (booting) {
if (booting || replaying) {
if (sessionID) {
input.trace?.write("recv.event", event)
buffered.push(event)
@@ -1005,6 +1206,7 @@ function createLayer(input: StreamInput) {
tick: state.tick,
armed: false,
live: false,
onVisibleOutput: next.onVisibleOutput,
done: yield* Deferred.make<void, unknown>(),
}
state.wait = item
@@ -1020,6 +1222,7 @@ function createLayer(input: StreamInput) {
const req = {
sessionID: input.sessionID,
messageID: next.prompt.messageID,
agent: next.agent,
model: next.model,
variant: next.variant,
@@ -1081,6 +1284,7 @@ function createLayer(input: StreamInput) {
input.sdk.session.command(
{
sessionID: input.sessionID,
messageID: next.prompt.messageID,
agent: next.agent,
model: next.model ? `${next.model.providerID}/${next.model.modelID}` : undefined,
variant: next.variant,
@@ -1231,6 +1435,7 @@ function createLayer(input: StreamInput) {
return Service.of({
runPromptTurn,
selectSubagent,
replayOnResize,
close,
})
}),
@@ -1254,6 +1459,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return {
runPromptTurn: (next) => runtime.runPromise((svc) => svc.runPromptTurn(next)),
selectSubagent: (sessionID) => runtime.runSync((svc) => svc.selectSubagent(sessionID)),
replayOnResize: (next) => runtime.runPromise((svc) => svc.replayOnResize(next)),
close: () => runtime.runPromise((svc) => svc.close()),
}
}
@@ -309,6 +309,21 @@ export type StreamCommit = {
}
}
export type LocalReplayAnchor = {
kind: EntryKind
text: string
phase: StreamPhase
messageID?: string
partID?: string
toolState?: StreamToolState
visible?: string
}
export type LocalReplayRow = {
commit: StreamCommit
after?: LocalReplayAnchor
}
// The public contract between the stream transport / prompt queue and
// the footer. RunFooter implements this. The transport and queue never
// touch the renderer directly -- they go through this interface.