diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 274ee3472ee..88e41855bec 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2522,12 +2522,24 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) { const theme = useTheme() + const [seconds, setSeconds] = createSignal(0) + createEffect(() => { + const at = props.retry?.at + if (at === undefined) return + const update = () => setSeconds(Math.max(0, Math.ceil((at - Date.now()) / 1_000))) + if (update() === 0) return + const timer = setInterval(() => { + if (update() === 0) clearInterval(timer) + }, 1_000) + onCleanup(() => clearInterval(timer)) + }) return ( {(retry) => ( - ⚠ Retry attempt {retry().attempt} scheduled: {retry().error.message} + ⚠ {seconds() > 0 ? `Retrying in ${seconds()}s` : "Retry due"} · attempt {retry().attempt} ·{" "} + {retry().error.message} )} diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index e13d5724255..612ec26a1ba 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1529,3 +1529,95 @@ test("server plugin failures share one notice and use source names before an ID expect(setup.captureCharFrame()).toContain("/fixture/broken.ts") expect(setup.captureCharFrame()).toContain("Open plugins") }) + +test.each([44, 100])( + "retry countdown updates and clears with the retry lifecycle at width %s", + async (width) => { + await using state = await tmpdir() + const session = { + id: "ses_countdown", + projectID: "proj_test", + location: { directory }, + title: "Retry countdown", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + } + const model = { id: "model", providerID: "provider" } + const error = { type: "provider.transport" as const, message: "Provider unavailable" } + await using setup = await createAppFixture({ + width, + state: state.path, + args: { sessionID: session.id }, + config: { animations: false, tabs: { enabled: false } }, + fetch: (url) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) + return json({ + data: [ + { + id: "msg_countdown", + type: "assistant", + agent: "build", + model, + content: [], + error, + retry: { attempt: 2, at: Date.now() + 2_500, error }, + time: { created: 1 }, + }, + ], + cursor: {}, + }) + if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname)) + return json({ data: [] }) + return undefined + }, + }) + await setup.ready + await setup.waitForFrame((frame) => frame.includes("Retrying in 3s")) + expect(setup.captureCharFrame()).toContain("attempt 2") + expect(setup.captureCharFrame()).toContain("Provider unavailable") + expect(setup.captureCharFrame()).not.toContain("Error:") + await setup.waitForFrame((frame) => frame.includes("Retrying in 2s"), { maxPasses: 200 }) + await setup.waitForFrame((frame) => frame.includes("Retrying in 1s"), { maxPasses: 200 }) + await setup.waitForFrame((frame) => frame.includes("Retry due"), { maxPasses: 200 }) + expect(setup.captureCharFrame()).not.toContain("in 0s") + + setup.events.emit({ + id: "evt_countdown_rescheduled", + created: 2, + type: "session.retry.scheduled", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 3, at: Date.now() + 10_500, error }, + }) + await setup.waitForFrame((frame) => frame.includes("Retrying in 11s") && frame.includes("attempt 3")) + setup.events.emit({ + id: "evt_countdown_started", + created: 3, + type: "session.step.started", + durable: { aggregateID: session.id, seq: 2, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_countdown", agent: "build", model }, + }) + await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due")) + + setup.events.emit({ + id: "evt_countdown_expired", + created: 4, + type: "session.retry.scheduled", + durable: { aggregateID: session.id, seq: 3, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 4, at: Date.now() - 1_000, error }, + }) + await setup.waitForFrame((frame) => frame.includes("Retry due") && frame.includes("attempt 4")) + expect(setup.captureCharFrame()).not.toContain("in -") + setup.events.emit({ + id: "evt_countdown_interrupted", + created: 5, + type: "session.execution.interrupted", + durable: { aggregateID: session.id, seq: 4, version: 1 }, + data: { sessionID: session.id, reason: "shutdown" }, + }) + await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due")) + }, + 15_000, +)