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:
1 parent
c369e089a7
commit
3d3495528c
54 files changed
+2278
-161
No files matched your search
@@ -51,6 +51,7 @@ import type {
|
||||
SessionTracesResponse,
|
||||
SkillInstallRequest,
|
||||
SkillLibraryResponse,
|
||||
RetryNowResponse,
|
||||
SteerRequest,
|
||||
TaskCreateRequest,
|
||||
TaskCreateResponse,
|
||||
@@ -265,6 +266,13 @@ export const postAbort = (sessionId: string) =>
|
||||
body: {},
|
||||
});
|
||||
|
||||
/** "Retry now" on the reconnect countdown: skips the remaining backoff wait server-side (skipped:false is the benign "no wait in progress" case — e.g. the timer elapsed in a race — never an error). */
|
||||
export const postRetryNow = (sessionId: string) =>
|
||||
apiFetch<RetryNowResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/retry-now`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
|
||||
/** Mid-run steering: queues a message for the running Task (delivered between turns as a standalone `[user_steering]` user message); 409 not_running when no Task is in progress. */
|
||||
export const postSteer = (sessionId: string, body: SteerRequest) =>
|
||||
apiFetch<void>(`/api/sessions/${encodeURIComponent(sessionId)}/steer`, {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* - OmniMessage uses the default event (no `event:` line); data is the message envelope as
|
||||
* raw JSON;
|
||||
* - Server events use `event: server_event` (approval_request / task_state / resync_required / hello);
|
||||
* - Server events use `event: server_event` (approval_request / task_state / resync_required /
|
||||
* credentials_updated / hello);
|
||||
* - EventSource can't set custom request headers, so auth relies on same-origin cookies; on
|
||||
* disconnect, the browser auto-reconnects and attaches a `Last-Event-ID` header (the server
|
||||
* replays from its ring buffer; if the event was already evicted, it pushes resync_required
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -166,6 +166,16 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
void rebuild();
|
||||
return;
|
||||
}
|
||||
case "credentials_updated": {
|
||||
// The Project's model credentials changed (Models page save): the server has
|
||||
// already invalidated cached runtimes, so any auth-dead state is stale — clear it
|
||||
// and let the user simply continue (it re-arms if the new key fails too). Buffered
|
||||
// during history load like other model-affecting events, so it replays AFTER the
|
||||
// history that set the flag.
|
||||
model.lastAuthFailureMs = null;
|
||||
deps.onModelChange();
|
||||
return;
|
||||
}
|
||||
case "hello":
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,6 +205,20 @@ export interface ReconnectItem {
|
||||
retrying: boolean;
|
||||
/** Retries exhausted (set true when an abort event arrives; the subsequent interruption marker item gives the reason). */
|
||||
gaveUp?: boolean;
|
||||
/**
|
||||
* The engine's planned wait before the next attempt (request_end.retry_in_ms; absent
|
||||
* when the event carried none — old Traces, or a final failure). Waits can reach the
|
||||
* 30s backoff ceiling, so the view renders a live countdown for the waiting state when
|
||||
* this is ≥2s (see ReconnectLine).
|
||||
*/
|
||||
plannedDelayMs?: number;
|
||||
/**
|
||||
* CLIENT-clock arrival time of the request_end (the countdown anchor — client-local, so
|
||||
* server clock skew cannot bend the ticker). On history replay the following
|
||||
* request_begin/abort arrives immediately and flips retrying/gaveUp, so a replayed item
|
||||
* never stays in the waiting state to tick.
|
||||
*/
|
||||
arrivedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface CompactionItem {
|
||||
@@ -300,6 +314,20 @@ export interface StreamModel {
|
||||
openApprovalWaitMs: number;
|
||||
/** Consecutive reconnect-failure count (incremented when request_end is timeout/malformed, reset to zero on any other terminal status). */
|
||||
reconnectRun: number;
|
||||
/**
|
||||
* Timestamp (ms) of the most recent auth failure: a main-session `request_end` with
|
||||
* status "auth" arrived on THIS model (a subagent's request events route to the nested
|
||||
* model and never mark the parent). Only the model REFERENCE is fixed at Session
|
||||
* creation — credentials are read from the current Project config on load — so this is
|
||||
* recoverable: the composer disables itself and points at the Models page. Cleared by a
|
||||
* later completed main-session request_end (live and history replay take the same path,
|
||||
* so a Trace with an auth failure followed by a completed request does not resurrect the
|
||||
* state on reload), by a `credentials_updated` server event, or by an explicit user
|
||||
* retry/dismiss; a timestamp (not a boolean) so the composer gate can compare it against
|
||||
* the Project's credentials-updated time after a reload (see isModelAuthDead). Re-arms
|
||||
* on the next auth failure.
|
||||
*/
|
||||
lastAuthFailureMs: number | null;
|
||||
/** Task segmentation state. */
|
||||
taskOpen: boolean;
|
||||
taskStartLocalMs: number;
|
||||
@@ -350,6 +378,7 @@ function newModel(nested: boolean, localDecisions: Set<string>): StreamModel {
|
||||
openRequestBeginMs: null,
|
||||
openApprovalWaitMs: 0,
|
||||
reconnectRun: 0,
|
||||
lastAuthFailureMs: null,
|
||||
taskOpen: false,
|
||||
taskStartLocalMs: 0,
|
||||
taskFirstTsMs: 0,
|
||||
@@ -364,6 +393,23 @@ export function createStreamModel(localDecisions: Set<string> = new Set()): Stre
|
||||
return newModel(false, localDecisions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer auth-dead gate: an auth failure is on record AND the Project's credentials have
|
||||
* not been updated since. `credentialsUpdatedMs` is the models response's `updatedAt`
|
||||
* (null = unknown / not loaded — nothing proves the credential changed, so the notice
|
||||
* stays). The time comparison is what keeps a reload honest: after a key fix the Trace
|
||||
* still ends with the auth-failed request, but that failure predates the credential update, so the
|
||||
* composer stays alive; a wrong replacement key re-arms naturally because its own auth
|
||||
* abort is newer than the update.
|
||||
*/
|
||||
export function isModelAuthDead(
|
||||
lastAuthFailureMs: number | null,
|
||||
credentialsUpdatedMs: number | null,
|
||||
): boolean {
|
||||
if (lastAuthFailureMs === null) return false;
|
||||
return credentialsUpdatedMs === null || lastAuthFailureMs > credentialsUpdatedMs;
|
||||
}
|
||||
|
||||
function nextId(model: StreamModel): number {
|
||||
return model.nextItemId++;
|
||||
}
|
||||
@@ -406,7 +452,7 @@ export function pushMessage(
|
||||
}
|
||||
if (isEventMessage(msg)) {
|
||||
touchTask(model, msg.timestamp);
|
||||
handleEvent(model, msg.payload as EventPayload, tsOf(msg.timestamp));
|
||||
handleEvent(model, msg.payload as EventPayload, tsOf(msg.timestamp), nowMs);
|
||||
advanceLastTs(model, msg.timestamp);
|
||||
return;
|
||||
}
|
||||
@@ -1018,7 +1064,7 @@ function createToolCard(
|
||||
// Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleEvent(model: StreamModel, p: EventPayload, tsMs?: number): void {
|
||||
function handleEvent(model: StreamModel, p: EventPayload, tsMs?: number, nowMs?: number): void {
|
||||
switch (p.type) {
|
||||
case "approval_decision": {
|
||||
const card = model.toolCards.get(p.tool_call_id);
|
||||
@@ -1103,6 +1149,18 @@ function handleEvent(model: StreamModel, p: EventPayload, tsMs?: number): void {
|
||||
// within a compaction range (only visible during history rebuild)
|
||||
// are neither rendered nor counted — the compaction process only exposes the compaction event pair to the Human.
|
||||
if (model.stats.compactionActive) return;
|
||||
// Credentials lifecycle rides on the request's own terminal status:
|
||||
// - "auth" records WHEN the failure happened (the event's envelope time), so the
|
||||
// composer gate can compare it against the Project's credentials-updated time
|
||||
// (see lastAuthFailureMs / isModelAuthDead). Origin routing already sends a
|
||||
// subagent's request events to the nested model, so reaching here with "auth"
|
||||
// always means the failure belongs to THIS session.
|
||||
// - a completed request proves the credentials work (again): the auth-dead state
|
||||
// must not outlive a success. Live and history replay share this path, so a Trace
|
||||
// with an auth failure followed by a completed request does not resurrect the dead
|
||||
// composer on reload.
|
||||
if (p.status === "auth") model.lastAuthFailureMs = tsMs ?? Date.now();
|
||||
if (p.status === "completed") model.lastAuthFailureMs = null;
|
||||
// Pairs with request_begin to compute this Request's wall-clock
|
||||
// duration, deducts the human approval wait, and adds the result to
|
||||
// this Task's LLM time (for output TPS) — this duration includes tool
|
||||
@@ -1126,13 +1184,20 @@ function handleEvent(model: StreamModel, p: EventPayload, tsMs?: number): void {
|
||||
commitPendingCompaction(model.stats);
|
||||
if (p.status === "timeout" || p.status === "malformed") {
|
||||
model.reconnectRun += 1;
|
||||
model.items.push({
|
||||
const item: ReconnectItem = {
|
||||
kind: "reconnect",
|
||||
id: nextId(model),
|
||||
status: p.status,
|
||||
attempt: model.reconnectRun,
|
||||
retrying: false,
|
||||
});
|
||||
};
|
||||
// The engine announced its planned backoff: keep it with the CLIENT arrival time
|
||||
// as the countdown anchor (skew-free — see ReconnectItem.arrivedAtMs).
|
||||
if (typeof p.retry_in_ms === "number" && p.retry_in_ms > 0) {
|
||||
item.plannedDelayMs = p.retry_in_ms;
|
||||
item.arrivedAtMs = nowMs ?? Date.now();
|
||||
}
|
||||
model.items.push(item);
|
||||
} else {
|
||||
model.reconnectRun = 0;
|
||||
}
|
||||
|
||||
@@ -642,10 +642,19 @@ When done, open index.html in a browser and self-test once.`,
|
||||
subagent: "Subagent",
|
||||
subagentRunning: "Running",
|
||||
aborted: (reason?: string) => `[Aborted]${reason ? `: ${reason}` : ""}`,
|
||||
/** Auth-dead notice (request_end status "auth"): action-only copy — updating the key on the Models page auto-unlocks this Session. */
|
||||
modelAuthDead:
|
||||
"Model API authentication failed: update this model's API key on the Models page, or start a new Session.",
|
||||
modelAuthDeadOpenModels: "Open Models page",
|
||||
modelAuthDeadRetry: "Retry",
|
||||
modelAuthDeadCta: "New Session",
|
||||
modelAuthDeadPlaceholder: "Model authentication failed — update the API key first",
|
||||
/** Reconnect hint line; `secondsLeft` (waiting state only) switches to the live-countdown wording. */
|
||||
reconnect: (
|
||||
status: "timeout" | "malformed",
|
||||
state: "waiting" | "retried" | "gaveUp",
|
||||
attempt: number,
|
||||
secondsLeft?: number,
|
||||
) => {
|
||||
const cause =
|
||||
status === "timeout" ? "Connection timed out" : "Response incomplete or unparseable";
|
||||
@@ -654,9 +663,15 @@ When done, open index.html in a browser and self-test once.`,
|
||||
? "no further retries"
|
||||
: state === "retried"
|
||||
? `retry #${attempt} sent`
|
||||
: `starting retry #${attempt}…`;
|
||||
: secondsLeft !== undefined
|
||||
? `retry #${attempt} in ${secondsLeft}s…`
|
||||
: `starting retry #${attempt}…`;
|
||||
return `[Retry] ${cause}; ${action}`;
|
||||
},
|
||||
/** "Retry now" on the reconnect countdown (skips the remaining backoff wait). */
|
||||
reconnectRetryNow: "Retry now",
|
||||
/** "Give up" on the reconnect countdown (the ordinary session abort). */
|
||||
reconnectGiveUp: "Give up",
|
||||
imageAlt: "Image uploaded by user",
|
||||
toolImageAlt: "Image from tool output",
|
||||
imagesAsPathHint:
|
||||
|
||||
@@ -626,10 +626,18 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
|
||||
subagent: "子会话",
|
||||
subagentRunning: "运行中",
|
||||
aborted: (reason?: string) => `[已中断]${reason ? `:${reason}` : ""}`,
|
||||
/** Auth-dead notice (request_end status "auth"): action-only copy — updating the key on the Models page auto-unlocks this Session. */
|
||||
modelAuthDead: "模型 API 认证失败:请在模型配置页更新该模型的 API key,或新建会话。",
|
||||
modelAuthDeadOpenModels: "打开模型配置",
|
||||
modelAuthDeadRetry: "重试",
|
||||
modelAuthDeadCta: "新建会话",
|
||||
modelAuthDeadPlaceholder: "模型认证失败,请先更新 API key",
|
||||
/** Reconnect hint line; `secondsLeft` (waiting state only) switches to the live-countdown wording. */
|
||||
reconnect: (
|
||||
status: "timeout" | "malformed",
|
||||
state: "waiting" | "retried" | "gaveUp",
|
||||
attempt: number,
|
||||
secondsLeft?: number,
|
||||
) => {
|
||||
const cause = status === "timeout" ? "连接超时或网络中断" : "响应不完整或无法解析";
|
||||
const action =
|
||||
@@ -637,9 +645,15 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
|
||||
? "已停止重试"
|
||||
: state === "retried"
|
||||
? `已发起第 ${attempt} 次重试`
|
||||
: `正在发起第 ${attempt} 次重试…`;
|
||||
: secondsLeft !== undefined
|
||||
? `第 ${attempt} 次重试,${secondsLeft} 秒后发起…`
|
||||
: `正在发起第 ${attempt} 次重试…`;
|
||||
return `[重试] ${cause},${action}`;
|
||||
},
|
||||
/** "Retry now" on the reconnect countdown (skips the remaining backoff wait). */
|
||||
reconnectRetryNow: "立即重试",
|
||||
/** "Give up" on the reconnect countdown (the ordinary session abort). */
|
||||
reconnectGiveUp: "放弃",
|
||||
imageAlt: "用户上传的图片",
|
||||
toolImageAlt: "工具输出的图片",
|
||||
imagesAsPathHint:
|
||||
|
||||
Reference in new issue
Block a user