From 934e0e899b99a490d9e5c96d7636db655d1bfeb7 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 21 Jul 2026 13:58:13 +0800 Subject: [PATCH 1/2] feat(chat): expandable stream-retry details in GUI and WebUI, codex-style retry policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the LLM stream-retry layer with codex (6 total attempts = 5 retries, uncapped 200ms*2^(n-1)*uniform(0.9,1.1) backoff) and surface each retry attempt's error in an expandable "重试详情" block on the live reply, on both the desktop app and the WebUI. Desktop (agent-gui): - streamRetry.ts: codex backoff, onRetry now carries the failing attempt's errorMessage, onRetryRecovered fires when a retried attempt commits - liveTranscriptStore/useLiveTranscriptController: retryAttempts live state with the same coalesced per-frame flush as toolStatus - agentRunner/runTextConversationTurn: per-network-attempt accumulators (cleared at each fresh attempt) wired through both chat modes - RetryDetailsBlock (RoundContent.tsx, modeled after ThinkingBlock), rendered in the active live round and in the no-rounds-yet fallback row WebUI (agent-gateway/web): - retryAttempts rides the existing tool_status chat event (Rust envelope passes the field through; Go ingress already merges data generically): null/absent = unchanged, empty array = clear - transcriptStore mirrors the list with run-boundary resets; GatewayTranscript renders the mirrored RetryDetailsBlock in the pending bubble and under the live assistant row Tests: stream-retry callback contract incl. errorMessage, bridge event dedupe/ride-along shape, Rust envelope pass-through, web store lifecycle (update/keep/clear/run-boundary resets). Known limitation: a WebUI client that (re)connects mid-retry restores the status text from the activity snapshot but sees retry details only from the next retry event (activity/snapshot protos don't carry the list). Co-Authored-By: Claude Fable 5 --- .../agent-gateway/web/src/app/GatewayApp.tsx | 1 + .../web/src/components/GatewayTranscript.tsx | 23 +++++- crates/agent-gateway/web/src/i18n/config.ts | 4 + .../lib/chat/stream/useConversationChat.ts | 1 + .../lib/chat/transcript/transcriptStore.ts | 51 +++++++++++- .../web/src/lib/chat/transcript/types.ts | 12 +++ .../agent-gateway/web/src/lib/gatewayTypes.ts | 3 + .../web/src/pages/chat/AssistantBubble.tsx | 1 + .../chat/assistant-bubble/RoundContent.tsx | 55 ++++++++++++- .../web/test/transcript-store.test.mjs | 79 +++++++++++++++++++ .../src-tauri/src/services/gateway/chat.rs | 4 + .../src-tauri/src/services/gateway/tests.rs | 54 +++++++++++++ crates/agent-gui/src/i18n/config.ts | 4 + .../chat/conversation/liveTranscriptStore.ts | 19 ++++- .../conversation/run/gatewayBridgeEvents.ts | 30 +++++++ .../src/lib/chat/runner/agentRunner.ts | 16 ++++ .../src/lib/providers/runtime/streamRetry.ts | 40 ++++++++-- .../lib/providers/runtime/textOnlyRuntime.ts | 10 +++ crates/agent-gui/src/pages/ChatPage.tsx | 9 +++ .../pages/chat/components/AssistantBubble.tsx | 6 ++ .../assistant-bubble/RoundContent.tsx | 59 +++++++++++++- .../chat/hooks/useLiveTranscriptController.ts | 43 ++++++++++ .../pages/chat/transcript/AssistantRow.tsx | 10 ++- .../pages/chat/transcript/TranscriptList.tsx | 1 + .../chat/turns/runAgentConversationTurn.ts | 10 ++- .../chat/turns/runTextConversationTurn.ts | 17 +++- .../test/chat/gateway-bridge-events.test.mjs | 45 +++++++++++ .../test/providers/stream-retry.test.mjs | 72 +++++++++++++++-- 28 files changed, 657 insertions(+), 22 deletions(-) diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 85f7231c5..abae93890 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -4203,6 +4203,7 @@ export default function GatewayApp() { error={transcriptError} toolStatus={transcriptToolStatus} toolStatusIsCompaction={transcriptToolStatusIsCompaction} + retryAttempts={displayedTranscript.retryAttempts} isStreaming={transcriptBusy} isLoading={transcriptHistoryLoading} loadingTitle={historyDetailLoadingTitle} diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index 625074e54..99f38188e 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -50,9 +50,10 @@ import { AssistantBubble, AssistantStatus, CompactingText, + RetryDetailsBlock, VibingText, } from "@/pages/chat/AssistantBubble"; -import type { TranscriptRow } from "../lib/chat/transcript/types"; +import type { RetryAttemptRecord, TranscriptRow } from "../lib/chat/transcript/types"; import type { GatewayTranscriptRound } from "../lib/chatUi"; import type { SectionId } from "../pages/settings/types"; @@ -93,6 +94,9 @@ type GatewayTranscriptProps = { error?: string | null; toolStatus?: string | null; toolStatusIsCompaction?: boolean; + // Live run's stream-retry history; renders as an expandable details block + // under the live status (mirrors the desktop app). + retryAttempts?: readonly RetryAttemptRecord[]; isStreaming?: boolean; isLoading?: boolean; loadingTitle?: string; @@ -1183,6 +1187,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr branchPendingMessageId?: string | null; toolStatus?: string | null; toolStatusIsCompaction: boolean; + retryAttempts?: readonly RetryAttemptRecord[]; readOnly?: boolean; redactToolContent?: boolean; }) { @@ -1210,6 +1215,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr branchPendingMessageId, toolStatus, toolStatusIsCompaction, + retryAttempts, readOnly = false, redactToolContent = false, } = props; @@ -1552,7 +1558,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr >
-
+
{displayedToolStatusIsCompaction ? (
@@ -1584,6 +1590,9 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
)} + {retryAttempts && retryAttempts.length > 0 ? ( + + ) : null}
@@ -1647,6 +1656,14 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr redactToolContent={redactToolContent} /> {shouldShowLiveStatus ? : null} + {isLatestLiveStreaming && + !shouldShowPendingLiveBubble && + retryAttempts && + retryAttempts.length > 0 ? ( +
+ +
+ ) : null} {!readOnly && !isLatestLiveStreaming ? ( diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 8d2daea6f..fc8fbdefd 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -107,6 +107,8 @@ export const translations: Record> = { "chat.stopGeneration": "停止生成", "chat.thinking": "思考中", "chat.thinkingProcess": "思考过程", + "chat.retryDetailsToggle": "重试详情 ({count})", + "chat.retryAttemptLabel": "第 {attempt}/{maxAttempts} 次重试", "chat.runtime.thinkingOn": "Thinking 已开启", "chat.runtime.thinkingOff": "Thinking 已关闭", "chat.runtime.thinkingUnavailable": "当前模型不支持 Thinking", @@ -1967,6 +1969,8 @@ export const translations: Record> = { "chat.stopGeneration": "Stop Generation", "chat.thinking": "Thinking", "chat.thinkingProcess": "Thinking Process", + "chat.retryDetailsToggle": "Retry details ({count})", + "chat.retryAttemptLabel": "Retry {attempt}/{maxAttempts}", "chat.runtime.thinkingOn": "Thinking enabled", "chat.runtime.thinkingOff": "Thinking disabled", "chat.runtime.thinkingUnavailable": "Thinking is unavailable for this model", diff --git a/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts b/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts index d2e4b4e8d..839ebbc27 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts @@ -71,6 +71,7 @@ const EMPTY_TRANSCRIPT: TranscriptSnapshot = { activeRun: null, toolStatus: null, toolStatusIsCompaction: false, + retryAttempts: [], foldRevision: 0, revision: 0, }; diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts b/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts index fac3e5b46..619103dd9 100644 --- a/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts +++ b/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts @@ -24,6 +24,7 @@ import { } from "./turnReducer"; import type { HistoryApplyMode, + RetryAttemptRecord, TranscriptRow, TranscriptSnapshot, Turn, @@ -46,7 +47,7 @@ import type { // row keys, row objects and the DOM container are all unchanged, so the // fold is a pure data transition and nothing remounts. -export type { TranscriptRow, TranscriptSnapshot, Turn } from "./types"; +export type { RetryAttemptRecord, TranscriptRow, TranscriptSnapshot, Turn } from "./types"; export type TranscriptStore = { getSnapshot(): TranscriptSnapshot; @@ -83,6 +84,8 @@ export type TranscriptStore = { flush(): void; }; +const EMPTY_RETRY_ATTEMPTS: readonly RetryAttemptRecord[] = []; + const EMPTY_SNAPSHOT: TranscriptSnapshot = { rows: [], liveStartIndex: -1, @@ -91,10 +94,37 @@ const EMPTY_SNAPSHOT: TranscriptSnapshot = { activeRun: null, toolStatus: null, toolStatusIsCompaction: false, + retryAttempts: EMPTY_RETRY_ATTEMPTS, foldRevision: 0, revision: 0, }; +// tool_status events without a retryAttempts array leave the current list +// untouched (null result); an array — including an empty one — replaces it. +function normalizeRetryAttempts(raw: unknown): RetryAttemptRecord[] | null { + if (!Array.isArray(raw)) { + return null; + } + const attempts: RetryAttemptRecord[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== "object") { + continue; + } + const value = entry as Record; + const attempt = typeof value.attempt === "number" && Number.isFinite(value.attempt); + const maxAttempts = typeof value.maxAttempts === "number" && Number.isFinite(value.maxAttempts); + if (!attempt || !maxAttempts) { + continue; + } + attempts.push({ + attempt: value.attempt as number, + maxAttempts: value.maxAttempts as number, + errorMessage: typeof value.errorMessage === "string" ? value.errorMessage : "", + }); + } + return attempts; +} + // Streaming-delta commit cadence while the tab is hidden and rAF is frozen. const HIDDEN_COMMIT_DELAY_MS = 250; @@ -148,6 +178,7 @@ export function createTranscriptStore(options?: { let activeRun: StreamRunActivity | null = null; let toolStatus: string | null = null; let toolStatusIsCompaction = false; + let retryAttempts: readonly RetryAttemptRecord[] = EMPTY_RETRY_ATTEMPTS; let foldRevision = 0; let localTurnSeq = 0; // Idempotency cursor: the highest log seq already applied. Re-subscribe @@ -303,6 +334,7 @@ export function createTranscriptStore(options?: { activeRun, toolStatus, toolStatusIsCompaction, + retryAttempts, foldRevision, revision: snapshot.revision + 1, }; @@ -399,6 +431,14 @@ export function createTranscriptStore(options?: { schedule(flush); }; + const setRetryAttempts = (next: readonly RetryAttemptRecord[], flush?: boolean) => { + if (retryAttempts.length === 0 && next.length === 0) { + return; + } + retryAttempts = next.length === 0 ? EMPTY_RETRY_ATTEMPTS : next; + schedule(flush); + }; + const applyUserMessage = (event: ConversationStreamEvent, runId: string) => { const payload = event as { message?: unknown; uploaded_files?: unknown }; const clientRequestId = readEventClientRequestId(event); @@ -567,6 +607,7 @@ export function createTranscriptStore(options?: { } activeRun = null; setToolStatus(null, false); + setRetryAttempts(EMPTY_RETRY_ATTEMPTS); schedule(true); }; @@ -663,6 +704,7 @@ export function createTranscriptStore(options?: { updatedAt: Date.now(), }; setToolStatus(null, false, true); + setRetryAttempts(EMPTY_RETRY_ATTEMPTS, true); schedule(true); return; } @@ -720,6 +762,12 @@ export function createTranscriptStore(options?: { typeof status === "string" ? status : null, (event as { isCompaction?: boolean }).isCompaction === true, ); + const nextRetryAttempts = normalizeRetryAttempts( + (event as { retryAttempts?: unknown }).retryAttempts, + ); + if (nextRetryAttempts !== null) { + setRetryAttempts(nextRetryAttempts); + } if (activeRun && activeRun.runId === runId) { activeRun = { ...activeRun, toolStatus, toolStatusIsCompaction }; } @@ -803,6 +851,7 @@ export function createTranscriptStore(options?: { } toolStatus = null; toolStatusIsCompaction = false; + retryAttempts = EMPTY_RETRY_ATTEMPTS; // Set the activity before the rebuild so the snapshot can target the // optimistic pending turn by client_request_id (its user bubble then // keeps its identity instead of a duplicate run turn appearing). diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/types.ts b/crates/agent-gateway/web/src/lib/chat/transcript/types.ts index 70391ba1b..4626685dc 100644 --- a/crates/agent-gateway/web/src/lib/chat/transcript/types.ts +++ b/crates/agent-gateway/web/src/lib/chat/transcript/types.ts @@ -5,6 +5,15 @@ import type { ChatEntry, GatewayTranscriptRound } from "@/lib/chatUi"; export type UserChatEntry = Extract; +// One failed-and-retried network attempt of the live run's model request, +// mirrored from the desktop's stream-retry layer over the tool_status event. +// attempt/maxAttempts are retry ordinals (1..5 of 5), not total attempts. +export type RetryAttemptRecord = { + attempt: number; + maxAttempts: number; + errorMessage: string; +}; + // A turn is one prompt/response exchange of the live stream: the user bubble // (a single slot — a second user_message for the same run can only upsert it, // never append a sibling) plus every assistant-side entry its run produced. @@ -99,6 +108,9 @@ export type TranscriptSnapshot = { activeRun: StreamRunActivity | null; toolStatus: string | null; toolStatusIsCompaction: boolean; + // Live run's stream-retry history (cleared at run boundaries and whenever + // the desktop starts a fresh network attempt). + retryAttempts: readonly RetryAttemptRecord[]; // Bumped whenever turns fold into the virtualized region. foldRevision: number; revision: number; diff --git a/crates/agent-gateway/web/src/lib/gatewayTypes.ts b/crates/agent-gateway/web/src/lib/gatewayTypes.ts index f568dbec1..a69f647bc 100644 --- a/crates/agent-gateway/web/src/lib/gatewayTypes.ts +++ b/crates/agent-gateway/web/src/lib/gatewayTypes.ts @@ -138,6 +138,9 @@ export type ChatEvent = ( type: "tool_status"; status?: string | null; isCompaction?: boolean; + // Stream-retry history of the live run: null/absent = unchanged, an + // array (possibly empty) replaces the current list. + retryAttempts?: { attempt: number; maxAttempts: number; errorMessage: string }[] | null; round?: number; conversation_id?: string; } diff --git a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx index b6725b178..6abe5bc45 100644 --- a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx +++ b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx @@ -4,6 +4,7 @@ import { AssistantAvatar } from "./assistant-bubble/AssistantAvatar"; import { RoundContent } from "./assistant-bubble/RoundContent"; export { AssistantAvatar } from "./assistant-bubble/AssistantAvatar"; +export { RetryDetailsBlock } from "./assistant-bubble/RoundContent"; export { AssistantStatus, CompactingText, VibingText } from "./assistant-bubble/StatusText"; const EMPTY_RUNNING_TOOL_CALL_IDS: string[] = []; diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx index 33c47ddda..97aaef80e 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx +++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx @@ -1,8 +1,9 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { ChevronRight, Lightbulb } from "../../../components/icons"; +import { ChevronRight, Lightbulb, RefreshCw } from "../../../components/icons"; import { Markdown } from "../../../components/Markdown"; import { useLocale } from "../../../i18n"; import { normalizeLiveToolStatus, VIBING_STATUS } from "../../../lib/chat/chatPageHelpers"; +import type { RetryAttemptRecord } from "../../../lib/chat/transcript/types"; import type { ToolTraceItem, UiRound } from "../../../lib/chat/uiMessages"; import { groupRoundBlocks, isBuiltinShareToolName } from "./assistantBubbleUtils"; import { HostedSearchGroupView } from "./HostedSearchGroupView"; @@ -77,6 +78,58 @@ const ThinkingBlock = memo(function ThinkingBlock({ ); }); +// Expandable per-attempt stream-retry history for the live run, mirrored +// from the desktop app's RetryDetailsBlock (agent-gui RoundContent.tsx). +export const RetryDetailsBlock = memo(function RetryDetailsBlock({ + attempts, +}: { + attempts: readonly RetryAttemptRecord[]; +}) { + const { t } = useLocale(); + const [isOpen, setIsOpen] = useState(false); + + if (attempts.length === 0) return null; + + return ( +
+ + + {() => ( +
+ {/* Index-keyed: attempt ordinals can repeat within one list (text + mode's tool-recovery loop restarts each wrapper's counter at 1) + and the list is append-only, so the index is the stable key. */} + {attempts.map((entry, index) => ( +
+
+ {t("chat.retryAttemptLabel") + .replace("{attempt}", String(entry.attempt)) + .replace("{maxAttempts}", String(entry.maxAttempts))} +
+
{entry.errorMessage}
+
+ ))} +
+ )} +
+
+ ); +}); + export const RoundContent = memo(function RoundContent(props: { round: UiRound; showUsage?: boolean; diff --git a/crates/agent-gateway/web/test/transcript-store.test.mjs b/crates/agent-gateway/web/test/transcript-store.test.mjs index 1d73728f0..e86102510 100644 --- a/crates/agent-gateway/web/test/transcript-store.test.mjs +++ b/crates/agent-gateway/web/test/transcript-store.test.mjs @@ -548,6 +548,85 @@ test("tool status mirrors into the snapshot and clears on run end", () => { assert.equal(store.getSnapshot().toolStatus, null); }); +test("retry attempts mirror into the snapshot, survive plain status updates and clear on run end", () => { + const store = createTranscriptStore(); + store.applyEvent(runStarted("run-1", 1)); + store.applyEvent({ + type: "tool_status", + conversation_id: "conv-1", + run_id: "run-1", + seq: 2, + status: "连接已断开,正在重试 (1/5)...", + retryAttempts: [{ attempt: 1, maxAttempts: 5, errorMessage: "503 service unavailable" }], + }); + store.flush(); + let snapshot = store.getSnapshot(); + assert.equal(snapshot.retryAttempts.length, 1); + assert.equal(snapshot.retryAttempts[0].attempt, 1); + assert.equal(snapshot.retryAttempts[0].maxAttempts, 5); + assert.equal(snapshot.retryAttempts[0].errorMessage, "503 service unavailable"); + + // A status-only update (retryAttempts null) leaves the list untouched. + store.applyEvent({ + type: "tool_status", + conversation_id: "conv-1", + run_id: "run-1", + seq: 3, + status: "模型生成中...", + retryAttempts: null, + }); + store.flush(); + snapshot = store.getSnapshot(); + assert.equal(snapshot.toolStatus, "模型生成中..."); + assert.equal(snapshot.retryAttempts.length, 1, "plain status update keeps retry history"); + + // An explicit empty array clears the list (fresh network attempt). + store.applyEvent({ + type: "tool_status", + conversation_id: "conv-1", + run_id: "run-1", + seq: 4, + status: "模型生成中...", + retryAttempts: [], + }); + store.flush(); + assert.equal(store.getSnapshot().retryAttempts.length, 0); + + store.applyEvent({ + type: "tool_status", + conversation_id: "conv-1", + run_id: "run-1", + seq: 5, + status: "连接已断开,正在重试 (1/5)...", + retryAttempts: [{ attempt: 1, maxAttempts: 5, errorMessage: "rate limited" }], + }); + store.applyEvent(runFinished("run-1", 6)); + store.flush(); + assert.equal(store.getSnapshot().retryAttempts.length, 0, "run end clears retry history"); +}); + +test("retry attempts reset at the next run_started", () => { + const store = createTranscriptStore(); + store.applyEvent(runStarted("run-1", 1)); + store.applyEvent({ + type: "tool_status", + conversation_id: "conv-1", + run_id: "run-1", + seq: 2, + status: "连接已断开,正在重试 (2/5)...", + retryAttempts: [ + { attempt: 1, maxAttempts: 5, errorMessage: "503" }, + { attempt: 2, maxAttempts: 5, errorMessage: "timeout" }, + ], + }); + store.flush(); + assert.equal(store.getSnapshot().retryAttempts.length, 2); + + store.applyEvent(runStarted("run-2", 3)); + store.flush(); + assert.equal(store.getSnapshot().retryAttempts.length, 0); +}); + test("replay idempotency: a resubscribe replaying applied events changes nothing", () => { const store = createTranscriptStore(); const events = [ diff --git a/crates/agent-gui/src-tauri/src/services/gateway/chat.rs b/crates/agent-gui/src-tauri/src/services/gateway/chat.rs index 52e819493..d18c1a6cc 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/chat.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/chat.rs @@ -517,6 +517,10 @@ pub(crate) fn build_chat_event_envelope( json!({ "status": object.get("status").cloned().unwrap_or(Value::Null), "isCompaction": object.get("isCompaction").and_then(Value::as_bool).unwrap_or(false), + // Optional stream-retry history (array of {attempt, + // maxAttempts, errorMessage}); absent/null means "unchanged" + // on the WebUI, an empty array clears its list. + "retryAttempts": object.get("retryAttempts").cloned().unwrap_or(Value::Null), "round": optional_number_field(object, "round"), }), ), diff --git a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs index 0447b52ac..02b871985 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs @@ -627,6 +627,60 @@ fn build_chat_event_envelope_preserves_tool_result_arguments() { assert_eq!(data["arguments"]["cwd"], "crates/agent-gateway"); } +#[test] +fn build_chat_event_envelope_preserves_tool_status_retry_attempts() { + let envelope = build_chat_event_envelope( + "request-1".to_string(), + json!({ + "type": "tool_status", + "conversation_id": "conversation-1", + "status": "第 1 轮:模型生成中...", + "isCompaction": false, + "retryAttempts": [ + { "attempt": 1, "maxAttempts": 5, "errorMessage": "503 service unavailable" } + ] + }), + ) + .expect("build chat tool_status event envelope"); + + let chat_event = match envelope.payload.expect("payload") { + super::proto::agent_envelope::Payload::ChatEvent(event) => event, + _ => panic!("expected chat event payload"), + }; + assert_eq!( + chat_event.r#type, + super::proto::chat_event::ChatEventType::ToolStatus as i32 + ); + + let data: Value = serde_json::from_str(&chat_event.data).expect("chat event data"); + assert_eq!(data["status"], "第 1 轮:模型生成中..."); + assert_eq!(data["retryAttempts"][0]["attempt"], 1); + assert_eq!(data["retryAttempts"][0]["maxAttempts"], 5); + assert_eq!( + data["retryAttempts"][0]["errorMessage"], + "503 service unavailable" + ); + + // Status-only events keep the key as an explicit null (WebUI treats + // null/absent as "leave the current list untouched"). + let plain = build_chat_event_envelope( + "request-1".to_string(), + json!({ + "type": "tool_status", + "conversation_id": "conversation-1", + "status": "Running", + "isCompaction": false + }), + ) + .expect("build plain tool_status event envelope"); + let plain_event = match plain.payload.expect("payload") { + super::proto::agent_envelope::Payload::ChatEvent(event) => event, + _ => panic!("expected chat event payload"), + }; + let plain_data: Value = serde_json::from_str(&plain_event.data).expect("chat event data"); + assert!(plain_data["retryAttempts"].is_null()); +} + #[test] fn build_chat_event_envelope_preserves_title_final_flag() { let envelope = build_chat_event_envelope( diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 5899713c8..fa2904b92 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -131,6 +131,8 @@ export const translations: Record> = { "chat.stopGeneration": "停止生成", "chat.thinking": "思考中", "chat.thinkingProcess": "思考过程", + "chat.retryDetailsToggle": "重试详情 ({count})", + "chat.retryAttemptLabel": "第 {attempt}/{maxAttempts} 次重试", "chat.runtime.thinkingOn": "Thinking 已开启", "chat.runtime.thinkingOff": "Thinking 已关闭", "chat.runtime.thinkingUnavailable": "当前模型不支持 Thinking", @@ -2072,6 +2074,8 @@ export const translations: Record> = { "chat.stopGeneration": "Stop Generation", "chat.thinking": "Thinking", "chat.thinkingProcess": "Thinking Process", + "chat.retryDetailsToggle": "Retry details ({count})", + "chat.retryAttemptLabel": "Retry {attempt}/{maxAttempts}", "chat.runtime.thinkingOn": "Thinking enabled", "chat.runtime.thinkingOff": "Thinking disabled", "chat.runtime.thinkingUnavailable": "Thinking is unavailable for this model", diff --git a/crates/agent-gui/src/lib/chat/conversation/liveTranscriptStore.ts b/crates/agent-gui/src/lib/chat/conversation/liveTranscriptStore.ts index 982d70632..8a00782a3 100644 --- a/crates/agent-gui/src/lib/chat/conversation/liveTranscriptStore.ts +++ b/crates/agent-gui/src/lib/chat/conversation/liveTranscriptStore.ts @@ -1,9 +1,13 @@ +import type { RetryAttemptRecord } from "../../providers/runtime/streamRetry"; import type { LiveRound } from "../messages/uiMessages"; +export type { RetryAttemptRecord } from "../../providers/runtime/streamRetry"; + export type LiveTranscriptState = { draftAssistantText: string; toolStatus: string | null; liveRounds: LiveRound[]; + retryAttempts: RetryAttemptRecord[]; }; export type LiveTranscriptStore = { @@ -12,13 +16,17 @@ export type LiveTranscriptStore = { reset: () => void; appendDraftAssistantText: (delta: string) => void; setToolStatus: (toolStatus: string | null) => void; + setRetryAttempts: (retryAttempts: RetryAttemptRecord[]) => void; updateLiveRounds: (updater: (prev: LiveRound[]) => LiveRound[]) => void; }; +const EMPTY_RETRY_ATTEMPTS: RetryAttemptRecord[] = []; + const EMPTY_STATE: LiveTranscriptState = { draftAssistantText: "", toolStatus: null, liveRounds: [], + retryAttempts: EMPTY_RETRY_ATTEMPTS, }; export function createLiveTranscriptStore( @@ -45,7 +53,8 @@ export function createLiveTranscriptStore( if ( state.draftAssistantText.length === 0 && state.toolStatus === null && - state.liveRounds.length === 0 + state.liveRounds.length === 0 && + state.retryAttempts.length === 0 ) { return; } @@ -68,6 +77,14 @@ export function createLiveTranscriptStore( }; emitChange(); }, + setRetryAttempts: (retryAttempts) => { + if (state.retryAttempts === retryAttempts) return; + state = { + ...state, + retryAttempts, + }; + emitChange(); + }, updateLiveRounds: (updater) => { const nextLiveRounds = updater(state.liveRounds); if (nextLiveRounds === state.liveRounds) return; diff --git a/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts b/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts index 6011fa86e..ad5d9082a 100644 --- a/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts +++ b/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts @@ -1,4 +1,5 @@ import type { ConversationViewState, HistoryMessageRef } from "../conversationState"; +import type { RetryAttemptRecord } from "../liveTranscriptStore"; type QueueEventOptions = { allowAfterClose?: boolean; @@ -52,6 +53,7 @@ export type GatewayBridgeEventController = { queueToken: (delta: string, extra?: Record) => void; queueTitle: (nextTitle: string, allowAfterClose?: boolean) => void; queueToolStatus: (status: string | null, isCompaction?: boolean) => void; + queueRetryAttempts: (attempts: readonly RetryAttemptRecord[]) => void; queueCheckpoint: (state: ConversationViewState) => void; emitError: (message: string, conversationIdOverride?: string) => void; close: () => void; @@ -65,6 +67,9 @@ export function createGatewayBridgeEventController( let forwardedText = false; let streamClosed = false; let lastToolStatusKey = ""; + let lastToolStatus: string | null = null; + let lastToolStatusIsCompaction = false; + let lastRetryAttemptsKey = "[]"; const queueEvent = (event: Record, options?: QueueEventOptions) => { if (!params.enabled) return; @@ -77,6 +82,8 @@ export function createGatewayBridgeEventController( const statusKey = `${normalizedStatus}::${isCompaction ? "1" : "0"}`; if (statusKey === lastToolStatusKey) return; lastToolStatusKey = statusKey; + lastToolStatus = normalizedStatus || null; + lastToolStatusIsCompaction = isCompaction; queueEvent({ type: "tool_status", status: normalizedStatus || null, @@ -85,6 +92,28 @@ export function createGatewayBridgeEventController( }); }; + // Rides on the tool_status wire event (re-sending the current status text) + // so the WebUI can mirror the desktop's expandable retry-details block + // without a new event type. Events without a retryAttempts array leave the + // WebUI's list untouched; an explicit empty array clears it. + const queueRetryAttempts = (attempts: readonly RetryAttemptRecord[]) => { + const payload = attempts.map((entry) => ({ + attempt: entry.attempt, + maxAttempts: entry.maxAttempts, + errorMessage: entry.errorMessage, + })); + const attemptsKey = JSON.stringify(payload); + if (attemptsKey === lastRetryAttemptsKey) return; + lastRetryAttemptsKey = attemptsKey; + queueEvent({ + type: "tool_status", + status: lastToolStatus, + isCompaction: lastToolStatusIsCompaction, + retryAttempts: payload, + conversation_id: params.conversationId, + }); + }; + return { queueEvent, queueUserMessage(message: string, uploadedFiles = [], options?: QueueUserMessageOptions) { @@ -131,6 +160,7 @@ export function createGatewayBridgeEventController( ); }, queueToolStatus, + queueRetryAttempts, queueCheckpoint(state: ConversationViewState) { const activeSegment = state.segments[state.activeSegmentIndex]; const summary = activeSegment?.summary; diff --git a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts index 90de383cb..82d656a50 100644 --- a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts +++ b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts @@ -34,6 +34,7 @@ import { isProviderNativeWebSearchToolName, } from "../../providers/nativeWebSearch"; import { prepareProxyRequest } from "../../providers/proxy"; +import type { RetryAttemptRecord } from "../../providers/runtime/streamRetry"; import { inferRuntimePlatform, normalizeRuntimePlatform, @@ -688,6 +689,7 @@ export async function runAssistantWithTools(params: { emittedMessages: Message[]; } | null>; onToolStatus?: (status: string | null) => void; + onRetryAttempts?: (round: number, attempts: RetryAttemptRecord[]) => void; signal?: AbortSignal; debugLogger?: StreamDebugLogger; subagentScheduler?: SubagentScheduler; @@ -1166,6 +1168,8 @@ export async function runAssistantWithTools(params: { let streamRound = 0; const streamFn = (streamModel: typeof model, streamContext: Context, options?: any) => { const round = ++streamRound; + const retryAttemptsForRound: RetryAttemptRecord[] = []; + params.onRetryAttempts?.(round, retryAttemptsForRound); const streamTools = streamContext.tools ?? (agent?.state.tools as Context["tools"] | undefined) ?? llmTools; const effectiveContext = sanitizeContextForModelRequest({ @@ -1210,6 +1214,18 @@ export async function runAssistantWithTools(params: { metadata: buildProviderRequestMetadata(params.providerId, params.sessionId), toolChoice: options?.toolChoice ?? (effectiveContext.tools?.length ? "auto" : undefined), reasoning: normalizeStreamReasoning(options?.reasoning) ?? fallbackReasoning, + streamRetry: { + onRetry: (attempt, maxAttempts, errorMessage) => { + params.onToolStatus?.( + `第 ${round} 轮:连接已断开,正在重试 (${attempt}/${maxAttempts})...`, + ); + retryAttemptsForRound.push({ attempt, maxAttempts, errorMessage }); + params.onRetryAttempts?.(round, retryAttemptsForRound.slice()); + }, + onRetryRecovered: () => { + params.onToolStatus?.(`第 ${round} 轮:模型生成中...`); + }, + }, }; streamOptions = finalizeProviderStreamOptions({ diff --git a/crates/agent-gui/src/lib/providers/runtime/streamRetry.ts b/crates/agent-gui/src/lib/providers/runtime/streamRetry.ts index cb42ee6b2..f99e2cd43 100644 --- a/crates/agent-gui/src/lib/providers/runtime/streamRetry.ts +++ b/crates/agent-gui/src/lib/providers/runtime/streamRetry.ts @@ -5,14 +5,28 @@ import { isRetryableAssistantError, } from "@earendil-works/pi-ai"; -export const DEFAULT_STREAM_RETRY_MAX_ATTEMPTS = 3; +/** 6 total attempts = 5 retries after the initial try — matches codex's stream_max_retries=5. */ +export const DEFAULT_STREAM_RETRY_MAX_ATTEMPTS = 6; -const STREAM_RETRY_BASE_DELAY_MS = 500; -const STREAM_RETRY_MAX_DELAY_MS = 8_000; +export type RetryAttemptRecord = { + attempt: number; + maxAttempts: number; + errorMessage: string; +}; + +const STREAM_RETRY_BASE_DELAY_MS = 200; +const STREAM_RETRY_BACKOFF_FACTOR = 2; export type StreamRetryConfig = { maxAttempts?: number; disabled?: boolean; + /** + * Retry ordinal (1..maxRetries) about to be attempted, invoked before the + * backoff sleep. `errorMessage` is the failure that triggered this retry. + */ + onRetry?: (attempt: number, maxAttempts: number, errorMessage: string) => void; + /** Invoked once a retried attempt commits its first content-bearing event. */ + onRetryRecovered?: () => void; }; export type StreamRetryOptions = StreamRetryConfig & { @@ -35,10 +49,10 @@ function terminalMessage(event: TerminalEvent) { return event.type === "done" ? event.message : event.error; } -/** Full-jitter exponential backoff (AWS-style): uniform(0, min(cap, base * 2^(attempt-1))). */ +/** Codex-style backoff: base * factor^(attempt-1) * uniform(0.9, 1.1), uncapped. */ export function computeStreamRetryBackoffMs(attempt: number): number { - const cap = Math.min(STREAM_RETRY_MAX_DELAY_MS, STREAM_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1)); - return Math.random() * cap; + const base = STREAM_RETRY_BASE_DELAY_MS * STREAM_RETRY_BACKOFF_FACTOR ** (attempt - 1); + return base * (0.9 + Math.random() * 0.2); } function sleepWithAbort(ms: number, signal: AbortSignal | undefined): Promise { @@ -65,9 +79,11 @@ function sleepWithAbort(ms: number, signal: AbortSignal | undefined): Promise { let attempt = 1; let source = firstSource; + let hasRetried = false; while (true) { let committed = false; @@ -99,6 +116,10 @@ export function withStreamRetry( if (!committed && COMMITTING_EVENT_TYPES.has(event.type)) { committed = true; for (const bufferedEvent of buffered.splice(0)) output.push(bufferedEvent); + if (hasRetried) { + hasRetried = false; + options?.onRetryRecovered?.(); + } } if (committed) { output.push(event); @@ -110,7 +131,10 @@ export function withStreamRetry( if (terminal?.type === "error" && !committed && !disabled && attempt < maxAttempts) { if (isRetryableAssistantError(terminalMessage(terminal))) { + const errorMessage = terminalMessage(terminal)?.errorMessage || "Unknown error"; attempt += 1; + options?.onRetry?.(attempt - 1, maxAttempts - 1, errorMessage); + hasRetried = true; try { await sleepWithAbort(computeStreamRetryBackoffMs(attempt - 1), signal); source = factory(); diff --git a/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts b/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts index 79439f467..1b82a933c 100644 --- a/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts +++ b/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts @@ -71,6 +71,8 @@ function buildTextOnlyStreamOptions(params: { cacheRetention?: CacheRetention; nativeWebSearch?: boolean; debugLogger?: StreamDebugLogger; + onRetryStatus?: (attempt: number, maxAttempts: number, errorMessage: string) => void; + onRetryRecovered?: () => void; }): StreamOptionsEx { const sessionId = normalizeSessionId(params.sessionId); const nativeWebSearch = @@ -102,6 +104,10 @@ function buildTextOnlyStreamOptions(params: { // Text-only mode cannot execute local tools. Provider-native web search is // hosted by the upstream provider, so it can stay on auto when explicitly enabled. toolChoice: usesOpenAIChatNativeWebSearch ? undefined : nativeWebSearch ? "auto" : "none", + streamRetry: { + onRetry: params.onRetryStatus, + onRetryRecovered: params.onRetryRecovered, + }, }; return finalizeProviderStreamOptions({ providerId: params.providerId, @@ -130,6 +136,8 @@ export async function streamAssistantMessage(params: { allowJsonOutput?: boolean; nativeWebSearch?: boolean; onHostedSearch?: (block: HostedSearchBlock) => void; + onRetryStatus?: (attempt: number, maxAttempts: number, errorMessage: string) => void; + onRetryRecovered?: () => void; }) { const modelId = params.model.trim(); if (!modelId) throw new Error("No model selected"); @@ -180,6 +188,8 @@ export async function streamAssistantMessage(params: { cacheRetention: params.cacheRetention, nativeWebSearch: params.nativeWebSearch, debugLogger: params.debugLogger, + onRetryStatus: params.onRetryStatus, + onRetryRecovered: params.onRetryRecovered, }); params.debugLogger?.logRequest( diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index e3ee3cff5..a5163095c 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -1410,6 +1410,7 @@ export function ChatPage(props: ChatPageProps) { appendDraftAssistantText, batchLiveRoundsUpdate, updateToolStatus, + updateRetryAttempts, } = useLiveTranscriptController({ currentConversationId, }); @@ -3728,6 +3729,12 @@ export function ChatPage(props: ChatPageProps) { } void queueGatewayRuntimeSnapshot(conversationId); }; + // Mirrors the live retry-attempt list to remote WebUI clients alongside + // the local live-transcript update. + const updateGatewayBridgeRetryAttempts: typeof updateRetryAttempts = (attempts, store) => { + gatewayBridgeEvents.queueRetryAttempts(attempts); + updateRetryAttempts(attempts, store); + }; const setConversationErrorState = (message: string | null) => { updateConversationRuntimeEntry(conversationId, (prev) => ({ ...prev, @@ -4576,6 +4583,7 @@ export function ChatPage(props: ChatPageProps) { resetLiveTranscript, batchLiveRoundsUpdate, updateToolStatus, + updateRetryAttempts: updateGatewayBridgeRetryAttempts, updatePersistableAgentProgress: (progress) => { persistableAgentProgress = progress; }, @@ -4625,6 +4633,7 @@ export function ChatPage(props: ChatPageProps) { appendDraftAssistantText, batchLiveRoundsUpdate, updateGatewayBridgeToolStatus, + updateRetryAttempts: updateGatewayBridgeRetryAttempts, commitVisibleAbortedConversation, updateConversationRuntimeEntry, persistConversationWithHistorySync, diff --git a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx index 71d1a4e9f..5186e1dd7 100644 --- a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx +++ b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx @@ -1,14 +1,17 @@ import { memo, useMemo } from "react"; +import type { RetryAttemptRecord } from "../../../lib/chat/conversation/liveTranscriptStore"; import type { UiRound } from "../../../lib/chat/messages/uiMessages"; import { AssistantAvatar } from "./assistant-bubble/AssistantAvatar"; import { RoundContent } from "./assistant-bubble/RoundContent"; export { AssistantAvatar } from "./assistant-bubble/AssistantAvatar"; +export { RetryDetailsBlock } from "./assistant-bubble/RoundContent"; export { AssistantStatus, CompactingText, VibingText } from "./assistant-bubble/StatusText"; const EMPTY_RUNNING_TOOL_CALL_IDS: string[] = []; +const EMPTY_RETRY_ATTEMPTS: RetryAttemptRecord[] = []; export const AssistantBubble = memo(function AssistantBubble(props: { rounds: (UiRound & { @@ -23,6 +26,7 @@ export const AssistantBubble = memo(function AssistantBubble(props: { renderMode?: "streaming" | "static"; toolStatus?: string | null; toolStatusVariant?: "default" | "compaction"; + retryAttempts?: RetryAttemptRecord[]; }) { const { rounds, @@ -32,6 +36,7 @@ export const AssistantBubble = memo(function AssistantBubble(props: { renderMode, toolStatus, toolStatusVariant, + retryAttempts, } = props; const latestTodoItem = useMemo(() => { for (let roundIndex = rounds.length - 1; roundIndex >= 0; roundIndex -= 1) { @@ -61,6 +66,7 @@ export const AssistantBubble = memo(function AssistantBubble(props: { renderMode={renderMode} toolStatus={idx === rounds.length - 1 ? toolStatus : null} toolStatusVariant={idx === rounds.length - 1 ? toolStatusVariant : "default"} + retryAttempts={idx === rounds.length - 1 ? retryAttempts : EMPTY_RETRY_ATTEMPTS} runningToolCallIds={round.runningToolCallIds ?? EMPTY_RUNNING_TOOL_CALL_IDS} thinkingOpen={round.thinkingOpen} latestTodoItem={latestTodoItem} diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx index 553afbc3b..a6c4a90cf 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx @@ -1,8 +1,9 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { ChevronRight, Lightbulb } from "../../../../components/icons"; +import { ChevronRight, Lightbulb, RefreshCw } from "../../../../components/icons"; import { Markdown } from "../../../../components/Markdown"; import { useLocale } from "../../../../i18n"; +import type { RetryAttemptRecord } from "../../../../lib/chat/conversation/liveTranscriptStore"; import type { ToolTraceItem, UiRound } from "../../../../lib/chat/messages/uiMessages"; import { normalizeLiveToolStatus, VIBING_STATUS } from "../../../../lib/chat/page/chatPageHelpers"; import { groupRoundBlocks } from "./assistantBubbleUtils"; @@ -76,6 +77,56 @@ const ThinkingBlock = memo(function ThinkingBlock({ ); }); +export const RetryDetailsBlock = memo(function RetryDetailsBlock({ + attempts, +}: { + attempts: RetryAttemptRecord[]; +}) { + const { t } = useLocale(); + const [isOpen, setIsOpen] = useState(false); + + if (attempts.length === 0) return null; + + return ( +
+ + + {() => ( +
+ {/* Index-keyed: attempt ordinals can repeat within one list (text + mode's tool-recovery loop restarts each wrapper's counter at 1) + and the list is append-only, so the index is the stable key. */} + {attempts.map((entry, index) => ( +
+
+ {t("chat.retryAttemptLabel") + .replace("{attempt}", String(entry.attempt)) + .replace("{maxAttempts}", String(entry.maxAttempts))} +
+
{entry.errorMessage}
+
+ ))} +
+ )} +
+
+ ); +}); + export const RoundContent = memo(function RoundContent(props: { round: UiRound; showUsage?: boolean; @@ -87,6 +138,7 @@ export const RoundContent = memo(function RoundContent(props: { renderMode?: "streaming" | "static"; toolStatus?: string | null; toolStatusVariant?: "default" | "compaction"; + retryAttempts?: RetryAttemptRecord[]; runningToolCallIds?: string[]; thinkingOpen?: boolean; latestTodoItem?: ToolTraceItem | null; @@ -100,6 +152,7 @@ export const RoundContent = memo(function RoundContent(props: { renderMode, toolStatus, toolStatusVariant, + retryAttempts, runningToolCallIds, thinkingOpen, latestTodoItem, @@ -174,6 +227,10 @@ export const RoundContent = memo(function RoundContent(props: {
) : null} + {isActive && isLive && retryAttempts && retryAttempts.length > 0 ? ( + + ) : null} + {visibleGroupedBlocks.map((block) => { if (block.kind === "thinking") { return ( diff --git a/crates/agent-gui/src/pages/chat/hooks/useLiveTranscriptController.ts b/crates/agent-gui/src/pages/chat/hooks/useLiveTranscriptController.ts index 2078f67c3..c6fb90e08 100644 --- a/crates/agent-gui/src/pages/chat/hooks/useLiveTranscriptController.ts +++ b/crates/agent-gui/src/pages/chat/hooks/useLiveTranscriptController.ts @@ -7,6 +7,7 @@ import { import { createLiveTranscriptStore, type LiveTranscriptStore, + type RetryAttemptRecord, } from "../../../lib/chat/conversation/liveTranscriptStore"; import type { LiveRound } from "../../../lib/chat/messages/uiMessages"; @@ -86,6 +87,8 @@ type LiveTranscriptArtifacts = { // store emit per frame. pendingToolStatus: { value: string | null } | null; toolStatusFlushCancel: (() => void) | null; + pendingRetryAttempts: { value: RetryAttemptRecord[] } | null; + retryAttemptsFlushCancel: (() => void) | null; abortSnapshot: AbortSnapshot | null; }; @@ -98,6 +101,8 @@ function createLiveTranscriptArtifacts(): LiveTranscriptArtifacts { lrFlushCancel: null, pendingToolStatus: null, toolStatusFlushCancel: null, + pendingRetryAttempts: null, + retryAttemptsFlushCancel: null, abortSnapshot: null, }; } @@ -160,6 +165,10 @@ export function useLiveTranscriptController(params: UseLiveTranscriptControllerP artifacts.toolStatusFlushCancel?.(); artifacts.toolStatusFlushCancel = null; artifacts.pendingToolStatus = null; + + artifacts.retryAttemptsFlushCancel?.(); + artifacts.retryAttemptsFlushCancel = null; + artifacts.pendingRetryAttempts = null; }, []); const flushPendingLiveUpdates = useCallback( @@ -195,6 +204,14 @@ export function useLiveTranscriptController(params: UseLiveTranscriptControllerP artifacts.pendingToolStatus = null; targetStore.setToolStatus(pending.value); } + + artifacts.retryAttemptsFlushCancel?.(); + artifacts.retryAttemptsFlushCancel = null; + if (artifacts.pendingRetryAttempts) { + const pending = artifacts.pendingRetryAttempts; + artifacts.pendingRetryAttempts = null; + targetStore.setRetryAttempts(pending.value); + } }, [liveTranscriptStore, resolveLiveTranscriptArtifacts], ); @@ -355,6 +372,31 @@ export function useLiveTranscriptController(params: UseLiveTranscriptControllerP [liveTranscriptStore, resolveLiveTranscriptArtifacts], ); + const updateRetryAttempts = useCallback( + (retryAttempts: RetryAttemptRecord[], targetStore: LiveTranscriptStore = liveTranscriptStore) => { + const artifacts = resolveLiveTranscriptArtifacts(targetStore); + if (!artifacts) { + targetStore.setRetryAttempts(retryAttempts); + return; + } + + // Last-wins: only the newest list of a frame reaches the store. A + // pending flush (settle, abort snapshot) delivers it early. + artifacts.pendingRetryAttempts = { value: retryAttempts }; + if (artifacts.retryAttemptsFlushCancel !== null) return; + + artifacts.retryAttemptsFlushCancel = scheduleLiveTranscriptFlush(() => { + artifacts.retryAttemptsFlushCancel = null; + const pending = artifacts.pendingRetryAttempts; + artifacts.pendingRetryAttempts = null; + if (pending) { + targetStore.setRetryAttempts(pending.value); + } + }); + }, + [liveTranscriptStore, resolveLiveTranscriptArtifacts], + ); + useEffect( () => () => { for (const artifacts of liveTranscriptArtifactsRef.current.values()) { @@ -376,5 +418,6 @@ export function useLiveTranscriptController(params: UseLiveTranscriptControllerP appendDraftAssistantText, batchLiveRoundsUpdate, updateToolStatus, + updateRetryAttempts, }; } diff --git a/crates/agent-gui/src/pages/chat/transcript/AssistantRow.tsx b/crates/agent-gui/src/pages/chat/transcript/AssistantRow.tsx index 558b19047..2a3c759d4 100644 --- a/crates/agent-gui/src/pages/chat/transcript/AssistantRow.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/AssistantRow.tsx @@ -1,6 +1,7 @@ import { memo } from "react"; import type { HistoryMessageRef } from "../../../lib/chat/conversation/conversationState"; +import type { RetryAttemptRecord } from "../../../lib/chat/conversation/liveTranscriptStore"; import type { PendingUploadedFile } from "../../../lib/chat/messages/uploadedFiles"; import { VIBING_STATUS } from "../../../lib/chat/page/chatPageHelpers"; import { @@ -8,6 +9,7 @@ import { AssistantBubble, AssistantStatus, CompactingText, + RetryDetailsBlock, VibingText, } from "../components/AssistantBubble"; import { AssistantRowFooter } from "./RowActions"; @@ -22,6 +24,7 @@ export type AssistantRowProps = { isAgentMode: boolean; isCompactionRunning: boolean; toolStatus: string | null; + retryAttempts?: RetryAttemptRecord[]; onResendFromEdit: ( messageRef: HistoryMessageRef, text: string, @@ -45,6 +48,7 @@ export const AssistantRow = memo(function AssistantRow(props: AssistantRowProps) isAgentMode, isCompactionRunning, toolStatus, + retryAttempts, onResendFromEdit, onBranchConversation, } = props; @@ -60,11 +64,12 @@ export const AssistantRow = memo(function AssistantRow(props: AssistantRowProps) renderMode={row.renderMode} toolStatus={row.live ? toolStatus : null} toolStatusVariant={row.live && isCompactionRunning ? "compaction" : "default"} + retryAttempts={row.live ? retryAttempts : undefined} /> ) : row.live ? (
-
+
{isCompactionRunning ? (
@@ -82,6 +87,9 @@ export const AssistantRow = memo(function AssistantRow(props: AssistantRowProps)
)} + {retryAttempts && retryAttempts.length > 0 ? ( + + ) : null}
) : null} diff --git a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx index 378565324..9a7f12fdc 100644 --- a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx @@ -497,6 +497,7 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList isAgentMode={isAgentMode} isCompactionRunning={row.live ? isCompactionRunning : false} toolStatus={row.live ? displayedToolStatus : null} + retryAttempts={row.live ? liveState.retryAttempts : undefined} onResendFromEdit={onResendFromEdit} onBranchConversation={onBranchConversation} /> diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 35cafaaf7..84dd77ab0 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -17,7 +17,10 @@ import { appendRenderOnlyMessagesToConversation, type ConversationViewState, } from "../../../lib/chat/conversation/conversationState"; -import type { LiveTranscriptStore } from "../../../lib/chat/conversation/liveTranscriptStore"; +import type { + LiveTranscriptStore, + RetryAttemptRecord, +} from "../../../lib/chat/conversation/liveTranscriptStore"; import type { ConversationHookLifecycle, GatewayBridgeEventController, @@ -254,6 +257,7 @@ export type RunAgentConversationTurnParams = { store: LiveTranscriptStore, ) => void; updateToolStatus: (status: string | null, store: LiveTranscriptStore) => void; + updateRetryAttempts: (attempts: RetryAttemptRecord[], store: LiveTranscriptStore) => void; updatePersistableAgentProgress: (progress: { completedThroughRound: number; suppressedToolTrace: SuppressedToolTraceSnapshot[]; @@ -312,6 +316,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP resetLiveTranscript, batchLiveRoundsUpdate, updateToolStatus, + updateRetryAttempts, updatePersistableAgentProgress, commitVisibleAbortedConversation, updateConversationRuntimeEntry, @@ -834,6 +839,9 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP gatewayBridgeEvents.queueToolStatus(s, false); updateToolStatus(s, transcriptStore); }, + onRetryAttempts: (_round, attempts) => { + updateRetryAttempts(attempts, transcriptStore); + }, onBeforeNextTurn: async ({ round, assistant, toolResults, emittedMessages }) => { publishPersistableAgentProgress(round, assistant, toolResults); latestAgentEmittedMessages = emittedMessages.slice(); diff --git a/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts index 94d3c166f..3a60c064a 100644 --- a/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts @@ -6,7 +6,10 @@ import { appendMessagesToConversation, type ConversationViewState, } from "../../../lib/chat/conversation/conversationState"; -import type { LiveTranscriptStore } from "../../../lib/chat/conversation/liveTranscriptStore"; +import type { + LiveTranscriptStore, + RetryAttemptRecord, +} from "../../../lib/chat/conversation/liveTranscriptStore"; import type { ConversationHookLifecycle, GatewayBridgeEventController, @@ -92,6 +95,7 @@ export type RunTextConversationTurnParams = { store: LiveTranscriptStore, ) => void; updateGatewayBridgeToolStatus: (status: string | null, isCompaction?: boolean) => void; + updateRetryAttempts: (attempts: RetryAttemptRecord[], store: LiveTranscriptStore) => void; commitVisibleAbortedConversation: () => boolean; updateConversationRuntimeEntry: ( conversationId: string, @@ -130,6 +134,7 @@ export async function runTextConversationTurn(params: RunTextConversationTurnPar appendDraftAssistantText, batchLiveRoundsUpdate, updateGatewayBridgeToolStatus, + updateRetryAttempts, commitVisibleAbortedConversation, updateConversationRuntimeEntry, persistConversationWithHistorySync, @@ -260,6 +265,8 @@ export async function runTextConversationTurn(params: RunTextConversationTurnPar status: nativeWebSearchStatus, onStatus: (status) => updateGatewayBridgeToolStatus(status), }); + const retryAttemptsForAttempt: RetryAttemptRecord[] = []; + updateRetryAttempts(retryAttemptsForAttempt, transcriptStore); try { finalAssistant = await streamAssistantMessage({ providerId, @@ -304,6 +311,14 @@ export async function runTextConversationTurn(params: RunTextConversationTurnPar }, signal: scope.controller.signal, debugLogger: streamAttempt === 0 ? conversationDebugLogger : recoveryDebugLogger, + onRetryStatus: (attempt, maxAttempts, errorMessage) => { + updateGatewayBridgeToolStatus(`连接已断开,正在重试 (${attempt}/${maxAttempts})...`); + retryAttemptsForAttempt.push({ attempt, maxAttempts, errorMessage }); + updateRetryAttempts(retryAttemptsForAttempt.slice(), transcriptStore); + }, + onRetryRecovered: () => { + updateGatewayBridgeToolStatus(null); + }, }); nativeWebSearchStatusController.finish(); } catch (streamErr) { diff --git a/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs b/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs index ee1c234cd..a09d61e61 100644 --- a/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs +++ b/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs @@ -149,6 +149,51 @@ test("gateway bridge tool status is normalized and de-duplicated", () => { ); }); +test("gateway bridge retry attempts ride tool_status with the current status and de-duplicate", () => { + const { controller, sent } = createController(); + + // The initial clear (fresh round, nothing to clear remotely) is suppressed. + controller.queueRetryAttempts([]); + assert.deepEqual(sent, []); + + controller.queueToolStatus("第 1 轮:模型生成中..."); + controller.queueRetryAttempts([ + { attempt: 1, maxAttempts: 5, errorMessage: "503 service unavailable" }, + ]); + // Same list again: de-duplicated. + controller.queueRetryAttempts([ + { attempt: 1, maxAttempts: 5, errorMessage: "503 service unavailable" }, + ]); + // Explicit clear after a non-empty list is forwarded. + controller.queueRetryAttempts([]); + + assert.deepEqual( + sent.map((item) => item.event), + [ + { + type: "tool_status", + status: "第 1 轮:模型生成中...", + isCompaction: false, + conversation_id: "conversation-1", + }, + { + type: "tool_status", + status: "第 1 轮:模型生成中...", + isCompaction: false, + retryAttempts: [{ attempt: 1, maxAttempts: 5, errorMessage: "503 service unavailable" }], + conversation_id: "conversation-1", + }, + { + type: "tool_status", + status: "第 1 轮:模型生成中...", + isCompaction: false, + retryAttempts: [], + conversation_id: "conversation-1", + }, + ], + ); +}); + test("gateway bridge close blocks normal events but allows forced title updates", () => { const { controller, sent } = createController(); diff --git a/crates/agent-gui/test/providers/stream-retry.test.mjs b/crates/agent-gui/test/providers/stream-retry.test.mjs index aca99f118..9aecfba34 100644 --- a/crates/agent-gui/test/providers/stream-retry.test.mjs +++ b/crates/agent-gui/test/providers/stream-retry.test.mjs @@ -120,6 +120,67 @@ test("withStreamRetry succeeds after N retryable errors without leaking failed-a assert.equal(final.content[0].text, "final answer"); }); +test("withStreamRetry invokes onRetry per attempt and onRetryRecovered once content commits", async () => { + let calls = 0; + const retryCalls = []; + let recoveredCalls = 0; + const wrapped = withStreamRetry( + () => { + calls += 1; + if (calls < 3) return createErrorStream("503 service unavailable"); + return createSuccessStream("final answer"); + }, + { + maxAttempts: 5, + onRetry: (attempt, maxAttempts) => retryCalls.push([attempt, maxAttempts]), + onRetryRecovered: () => { + recoveredCalls += 1; + }, + }, + ); + + await collectEvents(wrapped); + assert.deepEqual(retryCalls, [ + [1, 4], + [2, 4], + ]); + assert.equal(recoveredCalls, 1); +}); + +test("withStreamRetry passes the failing attempt's error message as onRetry's third argument", async () => { + let calls = 0; + const retryErrorMessages = []; + const wrapped = withStreamRetry( + () => { + calls += 1; + if (calls < 3) return createErrorStream(`503 service unavailable (call ${calls})`); + return createSuccessStream("final answer"); + }, + { + maxAttempts: 5, + onRetry: (_attempt, _maxAttempts, errorMessage) => retryErrorMessages.push(errorMessage), + }, + ); + + await collectEvents(wrapped); + assert.deepEqual(retryErrorMessages, [ + "503 service unavailable (call 1)", + "503 service unavailable (call 2)", + ]); +}); + +test("withStreamRetry never calls onRetryRecovered when no retry occurred", async () => { + let recoveredCalls = 0; + const wrapped = withStreamRetry(() => createSuccessStream("first try"), { + onRetryRecovered: () => { + recoveredCalls += 1; + }, + }); + + await collectEvents(wrapped); + assert.equal(recoveredCalls, 0); +}); + test("withStreamRetry does not retry once content has been committed", async () => { let calls = 0; const wrapped = withStreamRetry(() => { @@ -223,14 +284,15 @@ test("withStreamRetry with disabled:true never retries", async () => { assert.equal(calls, 1); }); -test("computeStreamRetryBackoffMs stays within the full-jitter cap and grows with attempt", () => { +test("computeStreamRetryBackoffMs follows codex's uncapped base*2^(n-1)*jitter(0.9,1.1) formula", () => { for (let attempt = 1; attempt <= 6; attempt += 1) { const delay = computeStreamRetryBackoffMs(attempt); - assert.ok(delay >= 0); - assert.ok(delay <= 8000); + const base = 200 * 2 ** (attempt - 1); + assert.ok(delay >= base * 0.9); + assert.ok(delay <= base * 1.1); } }); -test("DEFAULT_STREAM_RETRY_MAX_ATTEMPTS is a sane positive default", () => { - assert.ok(DEFAULT_STREAM_RETRY_MAX_ATTEMPTS >= 1); +test("DEFAULT_STREAM_RETRY_MAX_ATTEMPTS is 6 total attempts (5 retries), matching codex", () => { + assert.equal(DEFAULT_STREAM_RETRY_MAX_ATTEMPTS, 6); }); From e8210290bce33dbbb320c677f2a3b5bdaf43814e Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 21 Jul 2026 14:07:00 +0800 Subject: [PATCH 2/2] fix(ci): format retry attempts callback --- .../src/pages/chat/hooks/useLiveTranscriptController.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/agent-gui/src/pages/chat/hooks/useLiveTranscriptController.ts b/crates/agent-gui/src/pages/chat/hooks/useLiveTranscriptController.ts index c6fb90e08..5a8f6bd2b 100644 --- a/crates/agent-gui/src/pages/chat/hooks/useLiveTranscriptController.ts +++ b/crates/agent-gui/src/pages/chat/hooks/useLiveTranscriptController.ts @@ -373,7 +373,10 @@ export function useLiveTranscriptController(params: UseLiveTranscriptControllerP ); const updateRetryAttempts = useCallback( - (retryAttempts: RetryAttemptRecord[], targetStore: LiveTranscriptStore = liveTranscriptStore) => { + ( + retryAttempts: RetryAttemptRecord[], + targetStore: LiveTranscriptStore = liveTranscriptStore, + ) => { const artifacts = resolveLiveTranscriptArtifacts(targetStore); if (!artifacts) { targetStore.setRetryAttempts(retryAttempts);