From 1382735166cefce17ee026a20e6fa76179f6e7e7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:34:30 +0800 Subject: [PATCH] feat(agents): Show when Claude Code retries a failed API request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/ide-agent/src/lib.rs | 37 +++++++++++++++++++ crates/ide-agent/src/model.rs | 15 ++++++++ crates/ide-agent/src/native/claude/tests.rs | 14 +++++++ .../ide-agent/src/native/claude/translate.rs | 18 +++++++++ crates/ide-agent/src/tests.rs | 11 ++++++ src/bindings.ts | 14 +++++++ src/features/agents/Session.module.css | 4 ++ src/features/agents/SessionEditor.tsx | 24 ++++++++++-- src/features/agents/agents.test.ts | 9 +++++ src/features/agents/composerModel.ts | 18 ++++++++- src/platform/locales/text/zh.json | 2 + 11 files changed, 162 insertions(+), 4 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index a7d7962c6..621ab64d8 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -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) { + { + 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) { { 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, 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 { + 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 { config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone()) } diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 5df6a47ad..c777ef2e6 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -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, + /// 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, +} + +/// 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, + /// When the next attempt starts (ms since the epoch). + pub retry_at_ms: f64, + pub error: Option, } /// One value of a select-type session setting. diff --git a/crates/ide-agent/src/native/claude/tests.rs b/crates/ide-agent/src/native/claude/tests.rs index 8faf50db7..5e52741f0 100644 --- a/crates/ide-agent/src/native/claude/tests.rs +++ b/crates/ide-agent/src/native/claude/tests.rs @@ -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); diff --git a/crates/ide-agent/src/native/claude/translate.rs b/crates/ide-agent/src/native/claude/translate.rs index 534e0eee6..ac8158f01 100644 --- a/crates/ide-agent/src/native/claude/translate.rs +++ b/crates/ide-agent/src/native/claude/translate.rs @@ -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", diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 24e84f963..670675734 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -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"); +} diff --git a/src/bindings.ts b/src/bindings.ts index 70bcd3d26..217678da0 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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 `/agents/.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 = { diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index f7230811a..420f835bf 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -437,6 +437,10 @@ color: var(--fg-muted); } +.retrying { + color: var(--warning); +} + .composerArea { flex: none; display: flex; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index b26a517a4..e60faa8f0 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -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 && (
- {task.status === "starting" ? "Starting the agent…" : "Working…"} + {task.retry ? ( + + ) : ( + {task.status === "starting" ? "Starting the agent…" : "Working…"} + )} {lastUser?.sentMs != null && !lastUser.doneMs && }
)} @@ -393,6 +397,20 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o type UserEntry = Extract; /** 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 ( + + {wait > 0 + ? tx`API request failed, retrying in ${wait}s (attempt ${attempt})` + : tx`API request failed, retrying (attempt ${attempt})`} + + ); +} + function Elapsed({ since }: { since: number }) { const now = useNow(1000); return {formatDuration(now - since)}; diff --git a/src/features/agents/agents.test.ts b/src/features/agents/agents.test.ts index d9b23b27f..e130796e0 100644 --- a/src/features/agents/agents.test.ts +++ b/src/features/agents/agents.test.ts @@ -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 }); + }); +}); diff --git a/src/features/agents/composerModel.ts b/src/features/agents/composerModel.ts index 86d14cc92..c030288df 100644 --- a/src/features/agents/composerModel.ts +++ b/src/features/agents/composerModel.ts @@ -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` }; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c722229dc..a3edf1558 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "命中后即移除的行断点",