Skip to content

Commit 53c6133

Browse files
committed
fix: recover chats after terminal provider failures
1 parent 1c19d59 commit 53c6133

14 files changed

Lines changed: 663 additions & 60 deletions

apps/desktop/src/main/services/chat/agentChatService.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13681,6 +13681,125 @@ describe("createAgentChatService", () => {
1368113681
}));
1368213682
});
1368313683

13684+
it("emits one Codex failure when error precedes turn/completed with the same payload", async () => {
13685+
const events: AgentChatEventEnvelope[] = [];
13686+
const { service } = createService({
13687+
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
13688+
});
13689+
const session = await service.createSession({
13690+
laneId: "lane-1",
13691+
provider: "codex",
13692+
model: "gpt-5.4",
13693+
});
13694+
13695+
const turn = service.runSessionTurn({
13696+
sessionId: session.id,
13697+
text: "Continue shipping the fix.",
13698+
});
13699+
await vi.waitFor(() => {
13700+
expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
13701+
});
13702+
mockState.emitCodexPayload({
13703+
jsonrpc: "2.0",
13704+
method: "turn/started",
13705+
params: { turn: { id: "turn-capacity", status: "inProgress" } },
13706+
});
13707+
const error = {
13708+
message: "Selected model is at capacity. Please try a different model.",
13709+
codexErrorInfo: "serverOverloaded",
13710+
};
13711+
mockState.emitCodexPayload({
13712+
jsonrpc: "2.0",
13713+
method: "error",
13714+
params: { turnId: "turn-capacity", error, willRetry: false },
13715+
});
13716+
mockState.emitCodexPayload({
13717+
jsonrpc: "2.0",
13718+
method: "turn/completed",
13719+
params: {
13720+
turn: {
13721+
id: "turn-capacity",
13722+
status: "failed",
13723+
error,
13724+
},
13725+
},
13726+
});
13727+
13728+
await expect(turn).resolves.toEqual(expect.objectContaining({ turnId: "turn-capacity" }));
13729+
expect(events.filter((event) => event.event.type === "error")).toHaveLength(1);
13730+
expect(events).toEqual(expect.arrayContaining([
13731+
expect.objectContaining({
13732+
event: expect.objectContaining({
13733+
type: "status",
13734+
turnStatus: "failed",
13735+
turnId: "turn-capacity",
13736+
}),
13737+
}),
13738+
expect.objectContaining({
13739+
event: expect.objectContaining({
13740+
type: "done",
13741+
status: "failed",
13742+
turnId: "turn-capacity",
13743+
}),
13744+
}),
13745+
]));
13746+
await expect(service.getSessionSummary(session.id)).resolves.toEqual(
13747+
expect.objectContaining({ status: "idle" }),
13748+
);
13749+
});
13750+
13751+
it("keeps Codex willRetry errors non-terminal until turn/completed", async () => {
13752+
const events: AgentChatEventEnvelope[] = [];
13753+
const { service } = createService({
13754+
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
13755+
});
13756+
const session = await service.createSession({
13757+
laneId: "lane-1",
13758+
provider: "codex",
13759+
model: "gpt-5.4",
13760+
});
13761+
13762+
const turn = service.runSessionTurn({ sessionId: session.id, text: "Retry transiently." });
13763+
await vi.waitFor(() => {
13764+
expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
13765+
});
13766+
mockState.emitCodexPayload({
13767+
jsonrpc: "2.0",
13768+
method: "turn/started",
13769+
params: { turn: { id: "turn-retry", status: "inProgress" } },
13770+
});
13771+
mockState.emitCodexPayload({
13772+
jsonrpc: "2.0",
13773+
method: "error",
13774+
params: {
13775+
turnId: "turn-retry",
13776+
willRetry: true,
13777+
error: { message: "Temporary upstream failure.", codexErrorInfo: "serverOverloaded" },
13778+
},
13779+
});
13780+
13781+
expect(events.filter((event) => event.event.type === "error")).toHaveLength(0);
13782+
expect(events).toEqual(expect.arrayContaining([
13783+
expect.objectContaining({
13784+
event: expect.objectContaining({
13785+
type: "system_notice",
13786+
noticeKind: "provider_health",
13787+
turnId: "turn-retry",
13788+
}),
13789+
}),
13790+
]));
13791+
await expect(service.getSessionSummary(session.id)).resolves.toEqual(
13792+
expect.objectContaining({ status: "active" }),
13793+
);
13794+
13795+
mockState.emitCodexPayload({
13796+
jsonrpc: "2.0",
13797+
method: "turn/completed",
13798+
params: { turn: { id: "turn-retry", status: "completed" } },
13799+
});
13800+
await expect(turn).resolves.toEqual(expect.objectContaining({ turnId: "turn-retry" }));
13801+
});
13802+
1368413803
it("ignores stale Codex lifecycle notifications from a foreign turn", async () => {
1368513804
const events: Array<{ type: string; turnId?: string; text?: string }> = [];
1368613805
const { service } = createService({

apps/desktop/src/main/services/chat/agentChatService.ts

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ type CodexRuntime = {
704704
agentMessageScopeByTurn: Map<string, "item" | "turn">;
705705
agentMessageTextByTurn: Map<string, string>;
706706
recentNotificationKeys: Set<string>;
707+
emittedErrorKeys: Set<string>;
707708
reconciledItemSignaturesByTurn: Map<string, Set<string>>;
708709
noFirstEventWatchdog: {
709710
turnId: string;
@@ -20105,6 +20106,38 @@ export function createAgentChatService(args: {
2010520106
persistChatState(managed);
2010620107
}
2010720108

20109+
function emitCodexErrorOnce(
20110+
managed: ManagedChatSession,
20111+
runtime: CodexRuntime,
20112+
input: {
20113+
turnId?: string | null;
20114+
message: unknown;
20115+
errorInfo?: unknown;
20116+
detail?: unknown;
20117+
},
20118+
): void {
20119+
const turnId = typeof input.turnId === "string" && input.turnId.trim().length
20120+
? input.turnId.trim()
20121+
: null;
20122+
const message = String(input.message ?? "Codex app-server error.");
20123+
const errorInfo = formatCodexErrorInfo(input.errorInfo);
20124+
const detail = typeof input.detail === "string" && input.detail.trim().length
20125+
? input.detail.trim()
20126+
: undefined;
20127+
if (turnId) {
20128+
const semanticKey = JSON.stringify([turnId, message.trim(), errorInfo ?? null, detail ?? null]);
20129+
if (runtime.emittedErrorKeys.has(semanticKey)) return;
20130+
rememberBoundedId(runtime.emittedErrorKeys, semanticKey, 512);
20131+
}
20132+
emitChatEvent(managed, {
20133+
type: "error",
20134+
message,
20135+
...(turnId ? { turnId } : {}),
20136+
...(errorInfo ? { errorInfo } : {}),
20137+
...(detail ? { detail } : {}),
20138+
});
20139+
}
20140+
2010820141
async function finishCodexTurnFromReconciledState(
2010920142
managed: ManagedChatSession,
2011020143
runtime: CodexRuntime,
@@ -20148,11 +20181,11 @@ export function createAgentChatService(args: {
2014820181
const error = asRecord(turn.error);
2014920182
const errorMessage = stringOrNull(error?.message);
2015020183
if (status === "failed" && errorMessage) {
20151-
emitChatEvent(managed, {
20152-
type: "error",
20153-
message: errorMessage,
20184+
emitCodexErrorOnce(managed, runtime, {
2015420185
turnId,
20155-
errorInfo: formatCodexErrorInfo(error?.codexErrorInfo),
20186+
message: errorMessage,
20187+
errorInfo: error?.codexErrorInfo,
20188+
detail: error?.additionalDetails,
2015620189
});
2015720190
}
2015820191

@@ -20599,11 +20632,11 @@ export function createAgentChatService(args: {
2059920632
}
2060020633

2060120634
if (status === "failed" && turn?.error?.message) {
20602-
emitChatEvent(managed, {
20603-
type: "error",
20604-
message: String(turn.error.message),
20635+
emitCodexErrorOnce(managed, runtime, {
2060520636
turnId,
20606-
errorInfo: formatCodexErrorInfo(turn.error.codexErrorInfo)
20637+
message: turn.error.message,
20638+
errorInfo: turn.error.codexErrorInfo,
20639+
detail: (turn.error as { additionalDetails?: unknown }).additionalDetails,
2060720640
});
2060820641
}
2060920642

@@ -20997,12 +21030,28 @@ export function createAgentChatService(args: {
2099721030
}
2099821031

2099921032
if (method === "error") {
21000-
const error = (params.error as { message?: unknown; codexErrorInfo?: unknown } | null) ?? null;
21001-
emitChatEvent(managed, {
21002-
type: "error",
21003-
message: String(error?.message ?? "Codex app-server error."),
21004-
turnId: typeof params.turnId === "string" ? params.turnId : undefined,
21005-
errorInfo: formatCodexErrorInfo(error?.codexErrorInfo)
21033+
const error = (params.error as {
21034+
message?: unknown;
21035+
codexErrorInfo?: unknown;
21036+
additionalDetails?: unknown;
21037+
} | null) ?? null;
21038+
const turnId = typeof params.turnId === "string" ? params.turnId : runtime.activeTurnId ?? undefined;
21039+
if (params.willRetry === true) {
21040+
emitChatEvent(managed, {
21041+
type: "system_notice",
21042+
noticeKind: "provider_health",
21043+
severity: "warning",
21044+
message: "Codex hit a provider error and is retrying automatically.",
21045+
detail: String(error?.message ?? "The provider request failed."),
21046+
...(turnId ? { turnId } : {}),
21047+
});
21048+
return;
21049+
}
21050+
emitCodexErrorOnce(managed, runtime, {
21051+
turnId,
21052+
message: error?.message ?? "Codex app-server error.",
21053+
errorInfo: error?.codexErrorInfo,
21054+
detail: error?.additionalDetails,
2100621055
});
2100721056
return;
2100821057
}
@@ -21224,6 +21273,7 @@ export function createAgentChatService(args: {
2122421273
agentMessageScopeByTurn: new Map<string, "item" | "turn">(),
2122521274
agentMessageTextByTurn: new Map<string, string>(),
2122621275
recentNotificationKeys: new Set<string>(),
21276+
emittedErrorKeys: new Set<string>(),
2122721277
reconciledItemSignaturesByTurn: new Map<string, Set<string>>(),
2122821278
noFirstEventWatchdog: null,
2122921279
stalledTurnIds: new Set<string>(),

apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,8 @@ export function AgentChatComposer({
10511051
appControlContextItems = [],
10521052
builtInBrowserContextItems = [],
10531053
modelSelectionLocked = false,
1054+
modelPickerOpenRequestKey,
1055+
onModelPickerOpenRequestHandled,
10541056
permissionModeLocked = false,
10551057
hideNativeControls = false,
10561058
orchestrationRole = null,
@@ -1183,6 +1185,8 @@ export function AgentChatComposer({
11831185
builtInBrowserContextItems?: BuiltInBrowserContextItem[];
11841186
executionModeOptions?: ExecutionModeOption[];
11851187
modelSelectionLocked?: boolean;
1188+
modelPickerOpenRequestKey?: number;
1189+
onModelPickerOpenRequestHandled?: () => void;
11861190
permissionModeLocked?: boolean;
11871191
hideNativeControls?: boolean;
11881192
/**
@@ -3849,6 +3853,8 @@ export function AgentChatComposer({
38493853
value={modelId}
38503854
onChange={onModelChange}
38513855
surfaceKey="chat-composer"
3856+
openRequestKey={modelPickerOpenRequestKey}
3857+
onOpenRequestHandled={onModelPickerOpenRequestHandled}
38523858
{...(availableModelIds ? { availableModelIds } : {})}
38533859
constrainToAvailableModelIds={constrainModelSelection}
38543860
{...(providerAuthStatus ? { providerAuthStatus } : {})}

apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import {
9191
import { BackgroundFinishChip, SubagentResultCard, SubagentSpawnCard } from "./SubagentActivityCards";
9292
import { ChatUserMinimap } from "./ChatUserMinimap";
9393
import { AgentCliAuthCard, type AgentCliAuthCardInfo } from "./AgentCliAuthCard";
94+
import { classifyProviderFailure, ProviderFailureRecoveryCard } from "./ProviderFailureRecoveryCard";
9495
import { HighlightedCode } from "./CodeHighlighter";
9596
import { MosaicCard } from "./MosaicCard";
9697
import { MOSAIC_FENCE_LANGUAGE } from "../../../shared/chatMosaic";
@@ -2635,6 +2636,8 @@ function renderEvent(
26352636
options?: {
26362637
onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record<string, string | string[]>) => void;
26372638
onCodexRecovery?: (args: AgentChatRecoverCodexTurnArgs) => Promise<AgentChatRecoverCodexTurnResult>;
2639+
onRetryProviderFailure?: (turnId: string | null) => void;
2640+
onChooseProviderFailureModel?: () => void;
26382641
turnModel?: { label: string; modelId?: string; model?: string } | null;
26392642
surfaceMode?: ChatSurfaceMode;
26402643
surfaceProfile?: ChatSurfaceProfile;
@@ -3617,6 +3620,7 @@ function renderEvent(
36173620
const errorCopyValue = event.detail?.trim().length
36183621
? `${event.message}\n\n${event.detail}`
36193622
: event.message;
3623+
const recovery = classifyProviderFailure(event);
36203624
const renderAgentCliAuthCard = () => agentCliInfo ? (
36213625
<AgentCliAuthCard
36223626
agentCli={agentCliInfo}
@@ -3680,10 +3684,24 @@ function renderEvent(
36803684
{event.detail}
36813685
</div>
36823686
) : null}
3687+
{recovery ? (
3688+
<ProviderFailureRecoveryCard
3689+
recovery={recovery}
3690+
disabled={Boolean(options?.turnActive)}
3691+
onRetry={options?.onRetryProviderFailure
3692+
? () => options.onRetryProviderFailure?.(event.turnId ?? null)
3693+
: undefined}
3694+
onChooseModel={options?.onChooseProviderFailureModel}
3695+
/>
3696+
) : null}
36833697
{renderAgentCliAuthCard()}
36843698
{event.errorInfo && !agentCliInfo ? (
3685-
<div className="mt-2 font-mono text-[length:calc(var(--chat-font-size)*10/14)] text-muted-fg/40">
3686-
{typeof event.errorInfo === "string" ? event.errorInfo : `${event.errorInfo.provider ? `${event.errorInfo.provider}` : ""}${event.errorInfo.model ? ` / ${event.errorInfo.model}` : ""}`}
3699+
<div
3700+
className="mt-2 font-mono text-[length:calc(var(--chat-font-size)*10/14)] text-muted-fg/40"
3701+
title={typeof event.errorInfo === "string" ? event.errorInfo : undefined}
3702+
>
3703+
{recovery?.label
3704+
?? (typeof event.errorInfo === "string" ? event.errorInfo : `${event.errorInfo.provider ? `${event.errorInfo.provider}` : ""}${event.errorInfo.model ? ` / ${event.errorInfo.model}` : ""}`)}
36873705
</div>
36883706
) : null}
36893707
</div>
@@ -4143,6 +4161,8 @@ type EventRowProps = {
41434161
turnEndDurationMs?: number | null;
41444162
onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record<string, string | string[]>) => void;
41454163
onCodexRecovery?: (args: AgentChatRecoverCodexTurnArgs) => Promise<AgentChatRecoverCodexTurnResult>;
4164+
onRetryProviderFailure?: (turnId: string | null) => void;
4165+
onChooseProviderFailureModel?: () => void;
41464166
surfaceMode?: ChatSurfaceMode;
41474167
surfaceProfile?: ChatSurfaceProfile;
41484168
assistantLabel?: string;
@@ -4176,6 +4196,8 @@ const EventRow = React.memo(function EventRow({
41764196
turnEndDurationMs,
41774197
onApproval,
41784198
onCodexRecovery,
4199+
onRetryProviderFailure,
4200+
onChooseProviderFailureModel,
41794201
surfaceMode = "standard",
41804202
surfaceProfile = "standard",
41814203
assistantLabel,
@@ -4243,6 +4265,8 @@ const EventRow = React.memo(function EventRow({
42434265
: renderEvent(envelope as RenderEnvelope, {
42444266
onApproval,
42454267
onCodexRecovery,
4268+
onRetryProviderFailure,
4269+
onChooseProviderFailureModel,
42464270
turnModel,
42474271
surfaceMode,
42484272
surfaceProfile,
@@ -4571,6 +4595,8 @@ function AgentChatMessageListMain({
45714595
className,
45724596
onApproval,
45734597
onCodexRecovery,
4598+
onRetryProviderFailure,
4599+
onChooseProviderFailureModel,
45744600
surfaceMode = "standard",
45754601
surfaceProfile = "standard",
45764602
assistantLabel,
@@ -4595,6 +4621,8 @@ function AgentChatMessageListMain({
45954621
className?: string;
45964622
onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record<string, string | string[]>) => void;
45974623
onCodexRecovery?: (args: AgentChatRecoverCodexTurnArgs) => Promise<AgentChatRecoverCodexTurnResult>;
4624+
onRetryProviderFailure?: (turnId: string | null) => void;
4625+
onChooseProviderFailureModel?: () => void;
45984626
surfaceMode?: ChatSurfaceMode;
45994627
surfaceProfile?: ChatSurfaceProfile;
46004628
assistantLabel?: string;
@@ -5397,6 +5425,8 @@ function AgentChatMessageListMain({
53975425
turnEndDurationMs={turnEndDurationMs}
53985426
onApproval={handleApproval}
53995427
onCodexRecovery={onCodexRecovery}
5428+
onRetryProviderFailure={onRetryProviderFailure}
5429+
onChooseProviderFailureModel={onChooseProviderFailureModel}
54005430
surfaceMode={surfaceMode}
54015431
surfaceProfile={surfaceProfile}
54025432
assistantLabel={assistantLabel}
@@ -5433,6 +5463,8 @@ function AgentChatMessageListMain({
54335463
turnModel={turnModel}
54345464
onApproval={handleApproval}
54355465
onCodexRecovery={onCodexRecovery}
5466+
onRetryProviderFailure={onRetryProviderFailure}
5467+
onChooseProviderFailureModel={onChooseProviderFailureModel}
54365468
surfaceMode={surfaceMode}
54375469
surfaceProfile={surfaceProfile}
54385470
assistantLabel={assistantLabel}
@@ -5458,7 +5490,7 @@ function AgentChatMessageListMain({
54585490
assistantTurnCopy={assistantTurnCopy}
54595491
/>
54605492
);
5461-
}, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic, scrollToRowKey]);
5493+
}, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRetryProviderFailure, onChooseProviderFailureModel, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic, scrollToRowKey]);
54625494

54635495
// Compute the bottom spacer height for virtualized mode.
54645496
const bottomSpacerHeight = useMemo(() => {

0 commit comments

Comments
 (0)