add run --replay mode (#30239)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionTransport } from "@/cli/cmd/run/stream.transport"
|
||||
import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
|
||||
import type { FooterApi, FooterEvent, LocalReplayRow, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
|
||||
|
||||
type EventStream = Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>>["stream"]
|
||||
type GlobalEventStream = Awaited<ReturnType<OpencodeClient["global"]["event"]>>["stream"]
|
||||
@@ -11,6 +11,7 @@ type SessionChild = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["ch
|
||||
type SessionToolPart = Extract<SessionMessage["parts"][number], { type: "tool" }>
|
||||
type SessionStatusMap = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["status"]>>["data"]>
|
||||
type TextPart = Extract<SessionMessage["parts"][number], { type: "text" }>
|
||||
type ReasoningPart = Extract<SessionMessage["parts"][number], { type: "reasoning" }>
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
@@ -298,6 +299,29 @@ function textUpdated(part: TextPart): SdkEvent {
|
||||
}
|
||||
}
|
||||
|
||||
function reasoningPart(id: string, messageID: string, text: string): ReasoningPart {
|
||||
return {
|
||||
id,
|
||||
sessionID: "session-1",
|
||||
messageID,
|
||||
type: "reasoning",
|
||||
text,
|
||||
time: { start: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function reasoningUpdated(part: ReasoningPart): SdkEvent {
|
||||
return {
|
||||
id: `evt-${part.id}-updated`,
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: part.sessionID,
|
||||
part,
|
||||
time: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toolUpdated(part: SessionToolPart): SdkEvent {
|
||||
return {
|
||||
id: `evt-${part.id}-updated`,
|
||||
@@ -721,6 +745,444 @@ describe("run stream transport", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("rebuilds session output on resize and continues live deltas from replayed state", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let calls = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
messages: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([
|
||||
assistantMessage({
|
||||
sessionID: "session-1",
|
||||
id: "msg-1",
|
||||
parts: [textPart("text-1", "msg-1", "Hello")],
|
||||
}),
|
||||
])
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
const localRows: LocalReplayRow[] = [
|
||||
{ commit: { kind: "user", text: "pending prompt", phase: "start", source: "system", messageID: "msg-pending" } },
|
||||
]
|
||||
const reset = mock(() => {
|
||||
localRows.push({
|
||||
commit: {
|
||||
kind: "user",
|
||||
text: "sent during reset",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-during-reset",
|
||||
},
|
||||
})
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
try {
|
||||
expect(
|
||||
await transport.replayOnResize({
|
||||
localRows: () => localRows,
|
||||
reset,
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(reset).toHaveBeenCalledTimes(1)
|
||||
expect(ui.commits).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "assistant", text: "Hello" }),
|
||||
expect.objectContaining({ kind: "user", text: "sent during reset", messageID: "msg-during-reset" }),
|
||||
]),
|
||||
)
|
||||
|
||||
src.push(textUpdated(textPart("text-1", "msg-1", "Hello world")))
|
||||
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === " world"))
|
||||
expect(ui.commits.filter((commit) => commit.kind === "assistant").map((commit) => commit.text)).toEqual([
|
||||
"Hello",
|
||||
" world",
|
||||
])
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("coalesces active resize requests into one trailing replay", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
const firstReset = defer()
|
||||
const resetA = mock(() => firstReset.promise)
|
||||
const resetB = mock(() => Promise.resolve())
|
||||
const resetC = mock(() => Promise.resolve())
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({ stream: src.stream }),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
const active = transport.replayOnResize({ localRows: () => [], reset: resetA })
|
||||
await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined))
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false)
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false)
|
||||
expect(resetB).not.toHaveBeenCalled()
|
||||
|
||||
firstReset.resolve()
|
||||
expect(await active).toBe(true)
|
||||
expect(resetA).toHaveBeenCalledTimes(1)
|
||||
expect(resetB).not.toHaveBeenCalled()
|
||||
expect(resetC).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps coalescing resize requests while buffered events drain", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
const firstReset = defer()
|
||||
const statusGate = defer()
|
||||
const statusStarted = defer()
|
||||
let blockStatus = false
|
||||
const trace = mock((_type: string, _data?: unknown) => {})
|
||||
const resetA = mock(() => firstReset.promise)
|
||||
const resetB = mock(() => Promise.resolve())
|
||||
const resetC = mock(() => Promise.resolve())
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
status: async () => {
|
||||
if (blockStatus) {
|
||||
statusStarted.resolve()
|
||||
await statusGate.promise
|
||||
}
|
||||
return ok(statusMap(true))
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
trace: { write: trace },
|
||||
})
|
||||
const turn = transport.runPromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { text: "active", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
|
||||
try {
|
||||
await waitFor(() => ui.events.find((event) => event.type === "turn.wait"))
|
||||
const active = transport.replayOnResize({ localRows: () => [], reset: resetA })
|
||||
await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined))
|
||||
blockStatus = true
|
||||
src.push(busy())
|
||||
src.push(idle())
|
||||
await waitFor(() => (trace.mock.calls.filter((call) => call[0] === "recv.event").length >= 2 ? true : undefined))
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false)
|
||||
firstReset.resolve()
|
||||
await Promise.race([
|
||||
statusStarted.promise,
|
||||
Bun.sleep(1_000).then(() => {
|
||||
throw new Error("timed out waiting for buffered status drain")
|
||||
}),
|
||||
])
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false)
|
||||
expect(resetC).not.toHaveBeenCalled()
|
||||
blockStatus = false
|
||||
statusGate.resolve()
|
||||
|
||||
expect(
|
||||
await Promise.race([
|
||||
active,
|
||||
Bun.sleep(1_000).then(() => {
|
||||
throw new Error("timed out waiting for trailing resize replay")
|
||||
}),
|
||||
]),
|
||||
).toBe(true)
|
||||
expect(resetB).not.toHaveBeenCalled()
|
||||
expect(resetC).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
await turn
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves assistant deltas not yet persisted when replaying during a live stream", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let calls = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
messages: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([
|
||||
assistantMessage({
|
||||
sessionID: "session-1",
|
||||
id: "msg-live",
|
||||
parts: [textPart("text-live", "msg-live", "")],
|
||||
}),
|
||||
])
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
src.push(assistant("msg-live"))
|
||||
src.push(textUpdated(textPart("text-live", "msg-live", "")))
|
||||
src.push(textDelta("msg-live", "text-live", "Hello"))
|
||||
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello"))
|
||||
ui.commits.length = 0
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
|
||||
src.push(textDelta("msg-live", "text-live", "Hello"))
|
||||
src.push(
|
||||
textUpdated({
|
||||
...textPart("text-live", "msg-live", "HelloHello"),
|
||||
time: { start: 1, end: 2 },
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text === "Hello").length === 2
|
||||
? true
|
||||
: undefined,
|
||||
)
|
||||
expect(
|
||||
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text),
|
||||
).toEqual(["Hello", "Hello"])
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves the display prefix for active reasoning restored during replay", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let calls = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
messages: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([
|
||||
assistantMessage({
|
||||
sessionID: "session-1",
|
||||
id: "msg-thinking",
|
||||
parts: [reasoningPart("thinking-1", "msg-thinking", "")],
|
||||
}),
|
||||
])
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
src.push(assistant("msg-thinking"))
|
||||
src.push(reasoningUpdated(reasoningPart("thinking-1", "msg-thinking", "")))
|
||||
src.push(textDelta("msg-thinking", "thinking-1", "plan"))
|
||||
await waitFor(() => ui.commits.find((commit) => commit.kind === "reasoning" && commit.text === "Thinking: plan"))
|
||||
ui.commits.length = 0
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
|
||||
expect(ui.commits.filter((commit) => commit.kind === "reasoning").map((commit) => commit.text)).toEqual([
|
||||
"Thinking: plan",
|
||||
])
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not overlay stale active text when persistence completes during replay", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let calls = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
messages: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([
|
||||
assistantMessage({
|
||||
sessionID: "session-1",
|
||||
id: "msg-finished",
|
||||
parts: [
|
||||
{
|
||||
...textPart("text-finished", "msg-finished", "Hello"),
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
src.push(assistant("msg-finished"))
|
||||
src.push(textUpdated(textPart("text-finished", "msg-finished", "")))
|
||||
src.push(textDelta("msg-finished", "text-finished", "Hello"))
|
||||
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello"))
|
||||
ui.commits.length = 0
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
|
||||
expect(
|
||||
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text),
|
||||
).toEqual(["Hello"])
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not clear the terminal when resize replay snapshot fetch fails", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let calls = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
messages: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
throw new Error("snapshot failed")
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
const reset = mock(() => Promise.resolve())
|
||||
|
||||
try {
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||
expect(reset).not.toHaveBeenCalled()
|
||||
expect(ui.commits).toEqual([])
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("disables resize replay for the session after terminal reset fails", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({ stream: src.stream }),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
const reset = mock(() => Promise.reject(new Error("clear failed")))
|
||||
|
||||
try {
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||
expect(reset).toHaveBeenCalledTimes(1)
|
||||
expect(ui.commits).toContainEqual({
|
||||
kind: "error",
|
||||
text: "resize replay failed; disabled for this session",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
})
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("disables resize replay when rebuilding scrollback fails after terminal reset", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let cleared = false
|
||||
const idle = ui.api.idle
|
||||
ui.api.idle = () => (cleared ? Promise.reject(new Error("render failed")) : idle())
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({ stream: src.stream }),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
const reset = mock(() => {
|
||||
cleared = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
try {
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||
expect(reset).toHaveBeenCalledTimes(1)
|
||||
expect(ui.commits).toContainEqual({
|
||||
kind: "error",
|
||||
text: "resize replay failed; disabled for this session",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
})
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("drops completed historical subagent tabs during bootstrap", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
|
||||
Reference in New Issue
Block a user