fix(core,web): retry transport and quota LLM errors, disable dead sessions on auth failure (#82)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-07-27 22:29:16 +08:00
1 parent c369e089a7
commit 3d3495528c
54 files changed
+2278 -161

No files matched your search

+95 -12
View File
@@ -856,6 +856,10 @@ export function ChatInput({
onTextChange,
initialHandoffTargetId,
onHandoffTargetChange,
modelAuthDead = false,
onOpenModels,
onRetryModelAuth,
onNewSession,
}: {
status: SessionStatus;
/** Returns whether it succeeded: on failure the input draft is kept (not cleared). */
@@ -979,6 +983,26 @@ export function ChatInput({
initialHandoffTargetId?: string;
/** Callback when the @ handoff target changes (selected/removed; the clear after a successful send does not call back, same as onTextChange). */
onHandoffTargetChange?: (agentId: string | null) => void;
/**
* Session state: the Session's model credentials failed authentication (abort with
* status "auth") and the Project's credentials have not been updated since (the parent
* computes the time gate — see isModelAuthDead). Recoverable: only the model reference
* is fixed at creation, credentials come from the current Project config — so the
* composer disables and grays itself with a notice pointing at the Models page (primary),
* a Retry affordance, and a New Session escape. Updating the key auto-unlocks (live via
* the credentials_updated event, and across reloads via the time gate).
*/
modelAuthDead?: boolean;
/** Navigates to the Models page (where the credential is actually fixed); renders the notice's primary button when supplied. */
onOpenModels?: () => void;
/**
* Clears the auth-dead state and re-enables the composer for another attempt (the state
* re-arms on the next auth failure). The escape hatch for credential changes the
* timestamps miss (e.g. edited outside the UI).
*/
onRetryModelAuth?: () => void;
/** Navigates to a fresh draft (`/chat/new`); renders the notice's "New Session" button when supplied. */
onNewSession?: () => void;
}) {
const { locale } = useLocale();
const [text, setText] = useState(initialText ?? "");
@@ -1021,13 +1045,19 @@ export function ChatInput({
!running &&
!compacting &&
!busy &&
!modelAuthDead &&
(text.trim().length > 0 || images.length > 0 || target !== null || selectedSkills.length > 0);
// Mid-run steering: while running, Enter/send queues plain text for the running agent
// (delivered between turns as a [user_steering] user message). Text only — images / skills /
// @ target stay in the draft for a later normal send (an @ target also blocks steering: a
// leading mention means a handoff, not a message to this agent).
const canSteer =
running && !busy && onSteer !== undefined && target === null && text.trim().length > 0;
running &&
!busy &&
!modelAuthDead &&
onSteer !== undefined &&
target === null &&
text.trim().length > 0;
// Mid-run send mode (owner directive): the user chooses between "steer" (delivered
// mid-run as a [user_steering] input) and "follow-up" (held server-side and auto-sent as
// an ordinary next task once this run finishes). Set from the More-settings popover on
@@ -1045,6 +1075,7 @@ export function ChatInput({
const canFollowUp =
running &&
!busy &&
!modelAuthDead &&
followUpMode &&
(text.trim().length > 0 || images.length > 0 || target !== null || selectedSkills.length > 0);
// The single action button's mode: while running, an **empty** composer means Stop
@@ -1631,13 +1662,62 @@ export function ChatInput({
</p>
)}
{/* Auth-dead session (model credentials failed): slim notice above the composer, same
width — the abort line in the stream keeps the raw reason, this adds the "what now"
guidance. Recoverable: primary CTA opens the Models page (fixing the key there
auto-unlocks the session), Retry clears the state for another attempt (re-arms on
the next auth failure), New Session stays as the escape. The composer below is
disabled and hazed. */}
{modelAuthDead && (
<div className="anim-fade mb-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-300">
<span className="min-w-0 flex-1 basis-56">{S.chat.modelAuthDead}</span>
<span className="flex shrink-0 flex-wrap items-center gap-1.5">
{onOpenModels && (
<button
type="button"
onClick={onOpenModels}
className="shrink-0 rounded-md bg-gray-900 px-2.5 py-1 font-medium text-white transition-colors duration-150 hover:bg-gray-700 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300"
>
{S.chat.modelAuthDeadOpenModels}
</button>
)}
{onRetryModelAuth && (
<button
type="button"
onClick={onRetryModelAuth}
className="shrink-0 rounded-md border border-gray-300 bg-white px-2.5 py-1 font-medium text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700"
>
{S.chat.modelAuthDeadRetry}
</button>
)}
{onNewSession && (
<button
type="button"
onClick={onNewSession}
className="shrink-0 rounded-md border border-gray-300 bg-white px-2.5 py-1 font-medium text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700"
>
{S.chat.modelAuthDeadCta}
</button>
)}
</span>
</div>
)}
{/* Unified input card: the multi-line text body occupies the top area, with all controls
collected onto a single bottom row that never shares a line with the text.
@container: the bottom toolbar row collapses based on the **card's actual width**
(help text/button text visibility uses @md/@lg container breakpoints) — because the
card's width changes with the viewport and the Files panel squeezing it, viewport
breakpoints wouldn't judge it accurately. */}
<div className="@container rounded-lg border border-gray-300 bg-white px-2.5 pb-2 pt-2 transition-[border-color,box-shadow] duration-200 focus-within:border-gray-500 focus-within:ring-2 focus-within:ring-gray-400/30 dark:border-gray-700 dark:bg-gray-900 dark:focus-within:border-gray-400">
<div
// Auth-dead haze deliberately keeps pointer events and text selection: the
// textarea's `disabled` already blocks editing, and the stuck draft must stay
// selectable/copyable — a long message that failed to send is exactly what the
// user wants to copy back out.
className={`@container rounded-lg border border-gray-300 bg-white px-2.5 pb-2 pt-2 transition-[border-color,box-shadow] duration-200 focus-within:border-gray-500 focus-within:ring-2 focus-within:ring-gray-400/30 dark:border-gray-700 dark:bg-gray-900 dark:focus-within:border-gray-400${
modelAuthDead ? " opacity-50 grayscale" : ""
}`}
>
{/* Chip row above the text body: the @ handoff target (fixed at the front — send-time
@ semantics stay leading-only) followed by the selected skills, mirroring the
agent chip's look. Remove buttons recolor the x on hover (no background wash). */}
@@ -1724,18 +1804,21 @@ export function ChatInput({
onSelect={(e) => setCaret(e.currentTarget.selectionStart ?? 0)}
onKeyDown={onKeyDown}
onPaste={onPaste}
disabled={modelAuthDead}
placeholder={
running && followUpMode
? narrow
? S.chat.followUpPlaceholderShort
: S.chat.followUpPlaceholder
: running && onSteer
modelAuthDead
? S.chat.modelAuthDeadPlaceholder
: running && followUpMode
? narrow
? S.chat.steerPlaceholderShort
: S.chat.steerPlaceholder
: narrow
? S.chat.inputPlaceholderShort
: S.chat.inputPlaceholder
? S.chat.followUpPlaceholderShort
: S.chat.followUpPlaceholder
: running && onSteer
? narrow
? S.chat.steerPlaceholderShort
: S.chat.steerPlaceholder
: narrow
? S.chat.inputPlaceholderShort
: S.chat.inputPlaceholder
}
className="block max-h-44 min-h-[60px] w-full resize-none bg-transparent px-1 py-0.5 text-base leading-6 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:placeholder:text-gray-500"
/>
+30 -1
View File
@@ -29,7 +29,7 @@ import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title";
import { formatDateTime, formatMoney, humanizeDuration, humanizeTokens } from "../../lib/format";
import { latestConversation } from "../../lib/session-grouping";
import { approvalKey } from "../../lib/omni/stream-model";
import { approvalKey, isModelAuthDead } from "../../lib/omni/stream-model";
import { useTheme } from "../../state/theme";
import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
@@ -595,6 +595,11 @@ export function ChatPage() {
navigate(`/chat/${DRAFT_SESSION_ID}`);
}, [navigate]);
// Auth-dead notice primary CTA: the Models page is where the credential is actually fixed.
const openModels = useCallback(() => {
navigate("/models");
}, [navigate]);
// Real-time cost for this turn: converts the Task's bucketed usage using the session Model's
// (paired reference) current pricing; null if no pricing is configured.
const modelPricing = models?.models.find((m) => sameModelRef(m, activeModelRef))?.pricing;
@@ -616,6 +621,15 @@ export function ChatPage() {
1e6
);
},
// Reconnect countdown controls (live waiting state only): retry-now skips the
// remaining backoff server-side (benign no-op on timing races), give-up is the
// ordinary session abort — the engine's abort-during-backoff path ends the turn.
onRetryNow: () => {
if (selected) void api.postRetryNow(selected.sessionId).catch(() => undefined);
},
onGiveUp: () => {
void onStop();
},
onOpenFile: (path) => {
// The file card has already normalized the text path to a Workspace-relative path
// (toWorkspaceRelative, including stripping absolute-path prefixes and converting Windows
@@ -643,6 +657,17 @@ export function ChatPage() {
const emptyChat =
selected !== null && !stream.loading && !stream.error && stream.model.items.length === 0;
// Auth-dead gate (recoverable): an auth failure is on record AND the Project's credentials
// have not been updated since — only the model reference is fixed at creation, credentials
// come from the current Project config, so a key update (Models page) unlocks the session
// (live via the credentials_updated event; across reloads via this time comparison against
// the models response's updatedAt).
const credsUpdatedMs = models?.updatedAt !== undefined ? Date.parse(models.updatedAt) : NaN;
const modelAuthDead = isModelAuthDead(
stream.lastAuthFailureMs,
Number.isFinite(credsUpdatedMs) ? credsUpdatedMs : null,
);
// Input area in session state: Agent / Workspace / Model are already locked by the Session
// (the model selector isn't rendered; models feeds the locked model's read-only display and
// the /model switch picker) — approval mode and the per-turn thinking level stay editable;
@@ -650,6 +675,10 @@ export function ChatPage() {
const input = selected && (
<ChatInput
status={stream.taskState}
modelAuthDead={modelAuthDead}
onOpenModels={openModels}
onRetryModelAuth={stream.dismissModelAuthDead}
onNewSession={newChat}
onSend={onSend}
onSteer={onSteer}
// Count of steering messages already visible in the stream: the input area keeps its
+82 -11
View File
@@ -4,13 +4,13 @@
* streamed as Markdown, tool cards, subagent cards, compaction banners, abort markers, and Task
* stats lines. Items have a light entrance animation.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { S } from "../../lib/strings";
import { useLocale } from "../../state/locale";
import { formatMessageTime } from "../../lib/format";
import { STAT_ICONS } from "../../lib/stat-icons";
import { splitImageAttachments } from "../../lib/attachments";
import type { ChatItem } from "../../lib/omni/stream-model";
import type { ChatItem, ReconnectItem } from "../../lib/omni/stream-model";
import { Md } from "./md";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { ZoomableImage } from "../../components/ui/image-zoom";
@@ -89,6 +89,85 @@ function MessageMeta({
);
}
/** Countdown floor: waits shorter than this keep the plain waiting text (sub-second flashes of numbers are noise). */
const COUNTDOWN_MIN_MS = 2000;
/**
* Reconnect hint line. The live waiting state renders a COUNTDOWN to the next attempt when
* the engine announced its planned wait (request_end.retry_in_ms ≥ 2s — with the
* exponential ladder a wait can reach 30s, and a static "waiting" line reads as a hang),
* plus two inline controls: "retry now" (skips the remaining backoff server-side; the line
* flips to "retrying" when the request_begin arrives — no optimistic state beyond
* disabling the buttons) and "give up" (the ordinary session abort; the engine's
* abort-during-backoff path ends the turn and re-enables the composer). Anchored on the
* CLIENT arrival time of the event (skew-free), ticking every 250ms with ceil'd whole
* seconds; at zero it falls back to the plain waiting text, so a stale item can never tick
* forever. History safety: replay delivers the following request_begin/abort immediately,
* flipping the state, so neither the countdown nor the buttons render for replayed items;
* the buttons are additionally main-session-only (a subagent's backoff belongs to the
* child session, which the retry-now route does not target).
*/
function ReconnectLine({ item, ctx }: { item: ReconnectItem; ctx: StreamRenderContext }) {
const state = item.gaveUp ? "gaveUp" : item.retrying ? "retried" : "waiting";
const target =
state === "waiting" &&
item.plannedDelayMs !== undefined &&
item.plannedDelayMs >= COUNTDOWN_MIN_MS &&
item.arrivedAtMs !== undefined
? item.arrivedAtMs + item.plannedDelayMs
: null;
const [now, setNow] = useState(() => Date.now());
const [acted, setActed] = useState(false);
useEffect(() => {
if (target === null || Date.now() >= target) return;
const timer = setInterval(() => {
setNow(Date.now());
if (Date.now() >= target) clearInterval(timer); // stop ticking once the wait has elapsed
}, 250);
return () => clearInterval(timer);
}, [target]);
const remainingMs = target !== null ? target - now : 0;
const live = target !== null && remainingMs > 0;
const seconds = live ? Math.ceil(remainingMs / 1000) : undefined;
const showControls =
live && ctx.origin.length === 0 && (ctx.onRetryNow !== undefined || ctx.onGiveUp !== undefined);
return (
<p className="anim-msg my-1 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-amber-600 dark:text-amber-500">
<span>{S.chat.reconnect(item.status, state, item.attempt, seconds)}</span>
{showControls && (
<span className="flex shrink-0 items-center gap-1.5">
{ctx.onRetryNow && (
<button
type="button"
disabled={acted}
onClick={() => {
setActed(true);
ctx.onRetryNow!();
}}
className="rounded border border-amber-300 px-1.5 py-0.5 text-[11px] font-medium text-amber-700 transition-colors duration-150 hover:bg-amber-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-700 dark:text-amber-400 dark:hover:bg-amber-950/40"
>
{S.chat.reconnectRetryNow}
</button>
)}
{ctx.onGiveUp && (
<button
type="button"
disabled={acted}
onClick={() => {
setActed(true);
ctx.onGiveUp!();
}}
className="rounded border border-gray-300 px-1.5 py-0.5 text-[11px] text-gray-500 transition-colors duration-150 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800"
>
{S.chat.reconnectGiveUp}
</button>
)}
</span>
)}
</p>
);
}
export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderContext }) {
switch (item.kind) {
case "user_text": {
@@ -228,15 +307,7 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
</p>
);
case "reconnect":
return (
<p className="anim-msg my-1 font-mono text-xs text-amber-600 dark:text-amber-500">
{S.chat.reconnect(
item.status,
item.gaveUp ? "gaveUp" : item.retrying ? "retried" : "waiting",
item.attempt,
)}
</p>
);
return <ReconnectLine item={item} ctx={ctx} />;
case "compaction":
return <CompactionBanner item={item} />;
case "task_stats":
@@ -32,6 +32,15 @@ export interface StreamRenderContext {
taskRunning: boolean;
/** Converts this turn's stats into cost (USD) using the current Model pricing; returns null when no price is configured (cost is hidden). */
taskCost?: (stats: TaskStats) => number | null;
/**
* "Retry now" on the live reconnect countdown: skips the remaining backoff wait
* server-side (POST /retry-now); the line flips to "retrying" when the request_begin
* arrives. Only honored on main-session items — a subagent's backoff belongs to the
* child session, which the route does not target.
*/
onRetryNow?: () => void;
/** "Give up" on the live reconnect countdown: the ordinary session abort (same call as the Stop button). */
onGiveUp?: () => void;
/** Opens the Files panel and navigates to this file (triggered by clicking the file-summary card at the end of a message; takes a Workspace-relative path); the card doesn't render if this isn't wired up. */
onOpenFile?: (path: string) => void;
/** Absolute Workspace path of the current Session (used by the file-summary card to normalize body paths). */
@@ -38,6 +38,21 @@ export interface SessionStreamState {
taskState: SessionStatus;
/** Queued follow-up count from the stream's task_state events (auto-sent once the session is idle). */
queuedFollowUps: number;
/**
* Timestamp (ms) of the last main-session auth failure (request_end with status "auth"),
* or null. Derived from the model, so it survives history replay and resets when
* switching sessions; cleared by a later completed request, a `credentials_updated`
* server event, or dismissModelAuthDead. The composer gates on it TOGETHER with the
* models response's `updatedAt` (see isModelAuthDead): an abort older than the last
* credential update no longer disables the composer.
*/
lastAuthFailureMs: number | null;
/**
* Clears the auth-dead state (the user clicked Retry / dismissed the notice — e.g. the
* credential changed outside the UI in a way the timestamps miss); the state re-arms if
* the next request aborts on auth again.
*/
dismissModelAuthDead: () => void;
/** approvalKey(origin, toolCallId) → pending approval. */
pendingApprovals: ReadonlyMap<string, PendingApproval>;
/** Recorded when this client clicks an approval decision (marks it as "manual"). */
@@ -185,6 +200,14 @@ export function useSessionStream(
void controllerRef.current?.retry();
}, []);
const dismissModelAuthDead = useCallback(() => {
const m = controllerRef.current?.model;
if (m && m.lastAuthFailureMs !== null) {
m.lastAuthFailureMs = null;
setVersion((v) => v + 1);
}
}, []);
// pendingTick participates in the render dependencies, ensuring pending-table changes trigger a re-render.
void pendingTick;
@@ -194,6 +217,8 @@ export function useSessionStream(
loading,
taskState,
queuedFollowUps,
lastAuthFailureMs: (controllerRef.current?.model ?? placeholderRef.current).lastAuthFailureMs,
dismissModelAuthDead,
pendingApprovals: controllerRef.current?.pendingApprovals ?? EMPTY_PENDING,
markLocalDecision,
resolveApproval,