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