diff --git a/src/auto-reply/fallback-state.test.ts b/src/auto-reply/fallback-state.test.ts index fe6873bbe3..f15048a5bb 100644 --- a/src/auto-reply/fallback-state.test.ts +++ b/src/auto-reply/fallback-state.test.ts @@ -66,6 +66,19 @@ describe("fallback-state", () => { expect(resolved.nextState.activeModel).toBe("deepinfra/moonshotai/Kimi-K2.5"); }); + it("normalizes fallback reason whitespace for summaries", () => { + const resolved = resolveFallbackTransition({ + selectedProvider: "fireworks", + selectedModel: "fireworks/minimax-m2p5", + activeProvider: "deepinfra", + activeModel: "moonshotai/Kimi-K2.5", + attempts: [{ ...baseAttempt, reason: "rate_limit\n\tburst" }], + state: {}, + }); + + expect(resolved.reasonSummary).toBe("rate limit burst"); + }); + it("refreshes reason when fallback remains active with same model pair", () => { const resolved = resolveFallbackTransition({ selectedProvider: "fireworks", diff --git a/src/auto-reply/fallback-state.ts b/src/auto-reply/fallback-state.ts index d1c7c8d91a..836cf70d91 100644 --- a/src/auto-reply/fallback-state.ts +++ b/src/auto-reply/fallback-state.ts @@ -15,7 +15,9 @@ export function normalizeFallbackModelRef(value?: string): string | undefined { } function truncateFallbackReasonPart(value: string, max = FALLBACK_REASON_PART_MAX): string { - const text = String(value ?? "").trim(); + const text = String(value ?? "") + .replace(/\s+/g, " ") + .trim(); if (text.length <= max) { return text; } diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts index 27bdca0791..f87f8279b9 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -509,6 +509,30 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(onToolResult).not.toHaveBeenCalled(); }); + it("retries transient HTTP failures once with timer-driven backoff", async () => { + vi.useFakeTimers(); + let calls = 0; + state.runEmbeddedPiAgentMock.mockImplementation(async () => { + calls += 1; + if (calls === 1) { + throw new Error("502 Bad Gateway"); + } + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run } = createMinimalRun({ + typingMode: "message", + }); + const runPromise = run(); + + await vi.advanceTimersByTimeAsync(2_499); + expect(calls).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await runPromise; + expect(calls).toBe(2); + vi.useRealTimers(); + }); + it("announces auto-compaction in verbose mode and tracks count", async () => { await withTempStateDir(async (stateDir) => { const storePath = path.join(stateDir, "sessions", "sessions.json"); diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index c07bcd167f..4c948ecb75 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -83,4 +83,57 @@ describe("app-tool-stream fallback lifecycle handling", () => { expect(host.fallbackStatus).toBeNull(); vi.useRealTimers(); }); + + it("auto-clears fallback status after toast duration", () => { + vi.useFakeTimers(); + const host = createHost(); + + handleAgentEvent(host, { + runId: "run-1", + seq: 1, + stream: "lifecycle", + ts: Date.now(), + sessionKey: "main", + data: { + phase: "fallback", + selectedProvider: "fireworks", + selectedModel: "fireworks/minimax-m2p5", + activeProvider: "deepinfra", + activeModel: "moonshotai/Kimi-K2.5", + }, + }); + + expect(host.fallbackStatus).not.toBeNull(); + vi.advanceTimersByTime(7_999); + expect(host.fallbackStatus).not.toBeNull(); + vi.advanceTimersByTime(1); + expect(host.fallbackStatus).toBeNull(); + vi.useRealTimers(); + }); + + it("builds previous fallback label from provider + model on fallback_cleared", () => { + vi.useFakeTimers(); + const host = createHost(); + + handleAgentEvent(host, { + runId: "run-1", + seq: 1, + stream: "lifecycle", + ts: Date.now(), + sessionKey: "main", + data: { + phase: "fallback_cleared", + selectedProvider: "fireworks", + selectedModel: "fireworks/minimax-m2p5", + activeProvider: "fireworks", + activeModel: "fireworks/minimax-m2p5", + previousActiveProvider: "deepinfra", + previousActiveModel: "moonshotai/Kimi-K2.5", + }, + }); + + expect(host.fallbackStatus?.phase).toBe("cleared"); + expect(host.fallbackStatus?.previous).toBe("deepinfra/moonshotai/Kimi-K2.5"); + vi.useRealTimers(); + }); }); diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index cd57ee4a59..c7f3f9085b 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -339,8 +339,8 @@ function handleLifecycleFallbackEvent(host: CompactionHost, payload: AgentEventP resolveModelLabel(data.activeProvider, data.activeModel) ?? resolveModelLabel(data.toProvider, data.toModel); const previous = - toTrimmedString(data.previousActiveModel) ?? - resolveModelLabel(data.previousActiveProvider, data.previousActiveModel); + resolveModelLabel(data.previousActiveProvider, data.previousActiveModel) ?? + toTrimmedString(data.previousActiveModel); if (!selected || !active) { return; }