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 a7d7962c..621ab64d 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 5df6a47a..c777ef2e 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 8faf50db..5e52741f 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 534e0eee..ac8158f0 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 24e84f96..67067573 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 70bcd3d2..217678da 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 f7230811..420f835b 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 b26a517a..e60faa8f 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 d9b23b27..e130796e 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 86d14cc9..c030288d 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 c722229d..a3edf155 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": "命中后即移除的行断点",