add run --replay mode (#30239)
This commit is contained in:
@@ -103,7 +103,7 @@ Options:
|
||||
--variant model variant (provider-specific reasoning effort, e.g., high,
|
||||
max, minimal) [string]
|
||||
--thinking show thinking blocks [boolean]
|
||||
--replay replay visible session history on interactive resume
|
||||
--replay replay interactive session history on resume and after resize
|
||||
[boolean] [default: false]
|
||||
--replay-limit cap visible interactive replay to the newest N messages
|
||||
[number]
|
||||
|
||||
@@ -143,6 +143,7 @@ describe("run runtime queue", () => {
|
||||
text: "hello",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: expect.any(String),
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -225,6 +226,7 @@ describe("run runtime queue", () => {
|
||||
text: " hello ",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: expect.any(String),
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -260,6 +262,7 @@ describe("run runtime queue", () => {
|
||||
text: "/fmt bash",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: expect.any(String),
|
||||
},
|
||||
])
|
||||
ui.api.close()
|
||||
@@ -321,7 +324,7 @@ describe("run runtime queue", () => {
|
||||
await Promise.resolve()
|
||||
|
||||
expect(turns.map((item) => item.text)).toEqual(["one"])
|
||||
expect(turns[0]?.messageID).toBeUndefined()
|
||||
expect(turns[0]?.messageID).toEqual(expect.any(String))
|
||||
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
|
||||
const first = ui.events.find((item) => item.type === "queued.prompts")
|
||||
const event = ui.events.findLast((item) => item.type === "queued.prompts")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { replaySession } from "@/cli/cmd/run/session-replay"
|
||||
import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
|
||||
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
|
||||
|
||||
function userMessage(id: string, text: string): SessionMessages[number] {
|
||||
@@ -156,4 +156,286 @@ describe("run session replay", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("merges failed local rows ahead of later persisted prompts", () => {
|
||||
const persisted = {
|
||||
kind: "user",
|
||||
text: "successful",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-2",
|
||||
} as const
|
||||
const failed = {
|
||||
kind: "user",
|
||||
text: "failed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "network unavailable",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
|
||||
expect(replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }])).toEqual([
|
||||
failed,
|
||||
error,
|
||||
persisted,
|
||||
])
|
||||
})
|
||||
|
||||
test("retains local errors but not duplicate local prompts once a prompt persists", () => {
|
||||
const persisted = {
|
||||
kind: "user",
|
||||
text: "failed after persistence",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "connection closed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
|
||||
expect(replayLocalRows([userMessage("msg-user-1", "failed after persistence")], [persisted], [{ commit: persisted }, { commit: error }])).toEqual([
|
||||
persisted,
|
||||
error,
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a local turn failure below assistant output already visible for that turn", () => {
|
||||
const first = {
|
||||
kind: "user",
|
||||
text: "start",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const answer = {
|
||||
kind: "assistant",
|
||||
text: "partial answer",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-assistant-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "stream failed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const second = {
|
||||
kind: "user",
|
||||
text: "retry",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-2",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows(
|
||||
[userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")],
|
||||
[first, answer, second],
|
||||
[
|
||||
{
|
||||
commit: error,
|
||||
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" },
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual([first, answer, error, second])
|
||||
})
|
||||
|
||||
test("keeps a local failure above assistant output received after the failure", () => {
|
||||
const first = {
|
||||
kind: "user",
|
||||
text: "start",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "request failed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const late = {
|
||||
kind: "assistant",
|
||||
text: "late answer",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-assistant-1",
|
||||
} as const
|
||||
|
||||
expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([
|
||||
first,
|
||||
error,
|
||||
late,
|
||||
])
|
||||
})
|
||||
|
||||
test("inserts a local failure between persisted output chunks spanning that failure", () => {
|
||||
const first = {
|
||||
kind: "user",
|
||||
text: "start",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const complete = {
|
||||
kind: "assistant",
|
||||
text: "before after",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "part-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "stream failed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows([userMessage("msg-user-1", "start")], [first, complete], [
|
||||
{
|
||||
commit: error,
|
||||
after: {
|
||||
kind: "assistant",
|
||||
text: "before ",
|
||||
phase: "progress",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "part-1",
|
||||
visible: "before ",
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }])
|
||||
})
|
||||
|
||||
test("places an unpersisted failed prompt before live output from that turn", () => {
|
||||
const prompt = {
|
||||
kind: "user",
|
||||
text: "start",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-1",
|
||||
} as const
|
||||
const answer = {
|
||||
kind: "assistant",
|
||||
text: "partial answer",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-2",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "stream failed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-1",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows([], [answer], [
|
||||
{ commit: prompt },
|
||||
{
|
||||
commit: error,
|
||||
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" },
|
||||
},
|
||||
]),
|
||||
).toEqual([prompt, answer, error])
|
||||
})
|
||||
|
||||
test("anchors a failure after the visible start of a tool that later completes", () => {
|
||||
const prompt = {
|
||||
kind: "user",
|
||||
text: "run ls",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const running = {
|
||||
kind: "tool",
|
||||
text: "running bash",
|
||||
phase: "start",
|
||||
source: "tool",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "part-tool-1",
|
||||
toolState: "running",
|
||||
} as const
|
||||
const completed = {
|
||||
kind: "tool",
|
||||
text: "file.txt",
|
||||
phase: "final",
|
||||
source: "tool",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "part-tool-1",
|
||||
toolState: "completed",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "connection lost",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows([userMessage("msg-user-1", "run ls")], [prompt, running, completed], [
|
||||
{
|
||||
commit: error,
|
||||
after: {
|
||||
kind: "tool",
|
||||
text: "running bash",
|
||||
phase: "start",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "part-tool-1",
|
||||
toolState: "running",
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual([prompt, running, error, completed])
|
||||
})
|
||||
|
||||
test("retains an unpersisted local diagnostic before later persisted prompts", () => {
|
||||
const first = {
|
||||
kind: "user",
|
||||
text: "before",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "failed to start new session",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-2",
|
||||
} as const
|
||||
const second = {
|
||||
kind: "user",
|
||||
text: "after",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-3",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows([userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")], [first, second], [
|
||||
{ commit: error },
|
||||
]),
|
||||
).toEqual([first, error, second])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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