add run --replay mode (#30239)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user