feat(agents): Show when Claude Code retries a failed API request

When Claude Code loses its API connection or is refused, it retries on its own
for up to several minutes. The session showed only "Working…" meanwhile. It
now shows "API request failed, retrying in Ns (attempt 3/10)" with the error
in the tooltip, cleared by the agent's next output or the end of the turn.
This commit is contained in:
l0ng-ai
2026-10-29 11:34:30 +08:00
parent 9d4e778c7b
commit 1382735166
11 changed files with 162 additions and 4 deletions
+37
View File
@@ -46,6 +46,8 @@ const STDERR_LINES: usize = 20;
/// Updates of a session's state rather than its conversation.
const SESSION_STATE: [&str; 5] =
["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"];
/// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]).
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
const MAX_EMBED: u64 = 256 * 1024;
/// How long a probe waits for the agent to answer `initialize`.
@@ -264,6 +266,7 @@ impl AgentManager {
if matches!(f.task.status, AgentStatus::Running | AgentStatus::NeedsInput | AgentStatus::Starting) {
f.task.status = AgentStatus::Ready;
}
f.task.retry = None;
let task = Task::restore(f, shared.clone());
task.state.lock().conversation.settle(now_ms());
tasks.insert(task.id.clone(), task);
@@ -389,6 +392,7 @@ impl AgentManager {
queue: Vec::new(),
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
resets_ms: None,
retry: None,
};
let file = TaskFile {
task: task.clone(),
@@ -577,6 +581,7 @@ impl AgentManager {
usage: None,
queue: Vec::new(),
resets_ms: None,
retry: None,
..s.task.clone()
};
TaskFile {
@@ -830,9 +835,22 @@ impl Task {
self.shared.emit(AgentEvent::Task { task });
}
/// Shows (or clears) the agent's pending retry; emitted only when it changes.
fn set_retry(&self, retry: Option<AgentRetry>) {
{
let mut s = self.state.lock();
if s.task.retry == retry {
return;
}
s.task.retry = retry;
}
self.emit_task();
}
fn set_status(&self, status: AgentStatus, error: Option<String>) {
{
let mut s = self.state.lock();
s.task.retry = None;
s.task.status = status;
s.task.error = error;
s.task.updated_ms = now_ms();
@@ -1264,6 +1282,12 @@ impl Task {
}
return;
}
if kind == RETRY_UPDATE {
if !self.replaying.load(Ordering::Acquire) {
self.set_retry(retry_of(update, now_ms()));
}
return;
}
if kind == "session_info_update" {
// The agent's own title for the session replaces the first line of the prompt.
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
@@ -1332,6 +1356,8 @@ impl Task {
}
fn apply(self: &Arc<Self>, session: Option<&str>, kind: &str, update: &Value) {
// Output again: the retried request went through.
self.set_retry(None);
let applied = self.state.lock().conversation.apply(session, update, now_ms());
self.mark_dirty(applied);
// A subagent can start or finish after the turn that started it: saved right away.
@@ -1556,6 +1582,17 @@ fn is_zero(n: &u32) -> bool {
*n == 0
}
/// The retry a [`RETRY_UPDATE`] announces, timed from `now`.
fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
let n = |k: &str| update.get(k).and_then(Value::as_u64);
Some(AgentRetry {
attempt: u32::try_from(n("attempt")?).unwrap_or(u32::MAX),
max_retries: n("maxRetries").and_then(|m| u32::try_from(m).ok()),
retry_at_ms: now + n("retryDelayMs").unwrap_or(0) as f64,
error: update.get("error").and_then(Value::as_str).filter(|e| !e.is_empty()).map(str::to_owned),
})
}
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
}
+15
View File
@@ -81,6 +81,21 @@ pub struct AgentTask {
/// When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said.
#[serde(default)]
pub resets_ms: Option<f64>,
/// The agent is retrying a failed API request (connection lost, overloaded…); cleared
/// by its next output or any status change.
#[serde(default)]
pub retry: Option<AgentRetry>,
}
/// An API request the agent is about to retry.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentRetry {
pub attempt: u32,
pub max_retries: Option<u32>,
/// When the next attempt starts (ms since the epoch).
pub retry_at_ms: f64,
pub error: Option<String>,
}
/// One value of a select-type session setting.
@@ -917,6 +917,20 @@ fn pending_starts_round_trip() {
}
}
#[test]
fn api_retries_are_announced() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
let out = t.on_message(&json!({"type": "system", "subtype": "api_retry", "attempt": 3, "max_retries": 10,
"retry_delay_ms": 4000, "error_status": null, "error": "Connection error."}));
assert_eq!(out.len(), 1);
assert_eq!(out[0].session, "s");
assert_eq!(
out[0].update,
json!({"sessionUpdate": crate::RETRY_UPDATE, "attempt": 3, "maxRetries": 10, "retryDelayMs": 4000,
"error": "Connection error."})
);
}
#[test]
fn streamed_text_is_not_sent_twice() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
@@ -5,6 +5,7 @@
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
use super::tools;
use crate::RETRY_UPDATE;
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
@@ -427,6 +428,23 @@ impl Translator {
}
Vec::new()
}
// Claude Code retries a failed API request on its own; without this the turn looks stuck.
Some("api_retry") => {
let n = |k: &str| msg.get(k).and_then(Value::as_u64);
let error = msg.get("error").and_then(|e| {
e.as_str().map(str::to_owned).or_else(|| e.get("message")?.as_str().map(str::to_owned))
});
vec![Update {
session: self.root.clone(),
update: json!({
"sessionUpdate": RETRY_UPDATE,
"attempt": n("attempt").unwrap_or(1),
"maxRetries": n("max_retries"),
"retryDelayMs": n("retry_delay_ms").unwrap_or(0),
"error": error,
}),
}]
}
Some("task_notification") if self.children.get(&tool_use).is_some_and(|c| c.background != Some(false)) => {
let stop = match msg.get("status").and_then(Value::as_str) {
Some("failed") => "error",
+11
View File
@@ -709,3 +709,14 @@ async fn usage_limits_and_late_settings() {
let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone();
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
}
#[test]
fn retry_updates_become_a_countdown() {
let update =
json!({"sessionUpdate": RETRY_UPDATE, "attempt": 2, "maxRetries": 10, "retryDelayMs": 1500, "error": ""});
assert_eq!(
retry_of(&update, 1000.0),
Some(AgentRetry { attempt: 2, max_retries: Some(10), retry_at_ms: 2500.0, error: None })
);
assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt");
}
+14
View File
@@ -1179,6 +1179,15 @@ export type AgentRegistry = {
warning: string | null,
};
/** An API request the agent is about to retry. */
export type AgentRetry = {
attempt: number,
maxRetries: number | null,
/** When the next attempt starts (ms since the epoch). */
retryAtMs: number | null,
error: string | null,
};
/** Where an agent comes from. */
export type AgentSource = { kind: "builtin" } |
/** Defined by the user in `<config>/agents/<id>.toml`. */
@@ -1238,6 +1247,11 @@ export type AgentTask = {
capabilities?: AgentCapabilities,
/** When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said. */
resetsMs?: number | null,
/**
* The agent is retrying a failed API request (connection lost, overloaded…); cleared
* by its next output or any status change.
*/
retry?: AgentRetry | null,
};
export type AgentToolDiff = {
+4
View File
@@ -437,6 +437,10 @@
color: var(--fg-muted);
}
.retrying {
color: var(--warning);
}
.composerArea {
flex: none;
display: flex;
+21 -3
View File
@@ -2,7 +2,7 @@
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import type { AgentChange, AgentEntry, AgentTask, AgentUsage } from "@/bindings";
import type { AgentChange, AgentEntry, AgentRetry, AgentTask, AgentUsage } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands, onIdeEvent } from "@/platform/ipc";
import { basename, relativeDir, useProject } from "@/platform/project";
@@ -25,7 +25,7 @@ import {
import { useTx, tx } from "@/platform/i18n";
import { TaskStatusIcon } from "./AgentsView";
import { Composer } from "./Composer";
import { canSend, emptyDraft, usageLabel, workTime } from "./composerModel";
import { canSend, emptyDraft, retryParts, usageLabel, workTime } from "./composerModel";
import { ConfigPickers } from "./ConfigPickers";
import { findRanges, formatDuration, formatSentAt } from "./conversation";
import { Markdown } from "./Markdown";
@@ -381,7 +381,11 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
{!subagent && (task.status === "running" || task.status === "starting") && !waiting && (
<div className={styles.working}>
<Spinner size={12} />
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
{task.retry ? (
<Retrying retry={task.retry} />
) : (
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
)}
{lastUser?.sentMs != null && !lastUser.doneMs && <Elapsed since={lastUser.sentMs} />}
</div>
)}
@@ -393,6 +397,20 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
type UserEntry = Extract<AgentEntry, { type: "user" }>;
/** Time since `since`, ticking every second. */
/** The agent lost its API connection (or was refused) and retries on its own. */
function Retrying({ retry }: { retry: AgentRetry }) {
const tx = useTx();
const now = useNow(1000);
const { attempt, wait } = retryParts(retry, now);
return (
<span className={styles.retrying} title={retry.error ?? undefined}>
{wait > 0
? tx`API request failed, retrying in ${wait}s (attempt ${attempt})`
: tx`API request failed, retrying (attempt ${attempt})`}
</span>
);
}
function Elapsed({ since }: { since: number }) {
const now = useNow(1000);
return <span className={styles.muted}>{formatDuration(now - since)}</span>;
+9
View File
@@ -13,6 +13,7 @@ import {
nextValue,
recall,
toPrompt,
retryParts,
usageLabel,
workTime,
} from "./composerModel";
@@ -264,3 +265,11 @@ describe("turns", () => {
]);
});
});
describe("retryParts", () => {
it("counts down to the next attempt and shows the limit when known", () => {
const retry = { attempt: 3, maxRetries: 10, retryAtMs: 5_400, error: "Connection error." };
expect(retryParts(retry, 1_000)).toEqual({ attempt: "3/10", wait: 5 });
expect(retryParts({ ...retry, maxRetries: null }, 9_000)).toEqual({ attempt: "3", wait: 0 });
});
});
+17 -1
View File
@@ -1,5 +1,13 @@
/** Pure composer helpers: `@` / `/` completion, attachments, history recall, settings. */
import type { AgentCommand, AgentConfigOption, AgentEntry, AgentImage, AgentPrompt, AgentUsage } from "@/bindings";
import type {
AgentCommand,
AgentConfigOption,
AgentEntry,
AgentImage,
AgentPrompt,
AgentRetry,
AgentUsage,
} from "@/bindings";
import { tx } from "@/platform/i18n";
export interface FileRef {
@@ -131,6 +139,14 @@ export function nextValue(option: AgentConfigOption, current = option.current):
}
/** "12%" of the context window and a tooltip with the numbers. */
/** A pending API retry: "3/10" (or "3" when the agent gives no limit) and the seconds left. */
export function retryParts(r: AgentRetry, now: number): { attempt: string; wait: number } {
return {
attempt: r.maxRetries != null ? `${r.attempt}/${r.maxRetries}` : String(r.attempt),
wait: r.retryAtMs == null ? 0 : Math.max(0, Math.ceil((r.retryAtMs - now) / 1000)),
};
}
export function usageLabel(u: AgentUsage): { short: string; detail: string } {
const pct = Math.min(100, Math.round((u.used / u.size) * 100));
return { short: `${pct}%`, detail: tx`${tokens(u.used)} / ${tokens(u.size)} tokens` };
+2
View File
@@ -3,6 +3,8 @@
", e.g.": ",例如",
", then press Tab.": ",然后按 Tab。",
"? This cannot be undone.": "?此操作无法撤消。",
"API request failed, retrying (attempt {attempt})": "API 请求失败,正在重试(第 {attempt} 次)",
"API request failed, retrying in {wait}s (attempt {attempt})": "API 请求失败,{wait} 秒后重试(第 {attempt} 次)",
"A charset in .editorconfig takes precedence. Change one file's encoding from the status bar.": ".editorconfig 中的 charset 优先。可以从状态栏更改单个文件的编码。",
"A command line, e.g. /bin/zsh --login. Leave empty to use the login shell.": "命令行,例如 /bin/zsh --login。留空则使用登录 Shell。",
"A line breakpoint that is removed after it is hit": "命中后即移除的行断点",