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
>
-
+
{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);
});