feat(acp): surface Claude API retry state in chat input

Enable raw Claude SDK forwarding for ACP sessions and emit only system/api_retry events to the frontend.

Show a localized single-line retry banner with loading under the conversation input, including error details and retry progress.
This commit is contained in:
xintaofei
2026-04-14 14:59:32 +08:00
parent 77e46d80f8
commit f9923df1fe
17 changed files with 492 additions and 17 deletions

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react"
import { useTranslations } from "next-intl"
import type {
AgentType,
ConnectionStatus,
@@ -11,8 +12,10 @@ import type {
import type {
PendingPermission,
PendingQuestion,
ClaudeApiRetryState,
} from "@/contexts/acp-connections-context"
import type { QueuedMessage } from "@/hooks/use-message-queue"
import { Loader2 } from "lucide-react"
import { ChatInput } from "@/components/chat/chat-input"
import { PermissionDialog } from "@/components/chat/permission-dialog"
import { QuestionDialog } from "@/components/chat/question-dialog"
@@ -23,6 +26,7 @@ interface ConversationShellProps {
defaultPath?: string
agentName?: string
error: string | null
claudeApiRetry: ClaudeApiRetryState | null
pendingPermission: PendingPermission | null
pendingQuestion: PendingQuestion | null
onFocus: () => void
@@ -64,6 +68,7 @@ export function ConversationShell({
defaultPath,
agentName,
error,
claudeApiRetry,
pendingPermission,
pendingQuestion,
onFocus,
@@ -98,6 +103,63 @@ export function ConversationShell({
onCancelQueueEdit,
onForkSend,
}: ConversationShellProps) {
const tAcp = useTranslations("Folder.chat.acpConnections")
const retry = claudeApiRetry
const retryAttemptRaw = retry?.attempt
const retryMaxRaw = retry?.maxRetries
const retryDelayMsRaw = retry?.retryDelayMs
const retryErrorStatusRaw = retry?.errorStatus
const retryAttempt =
retryAttemptRaw !== null && retryAttemptRaw !== undefined
? Math.trunc(retryAttemptRaw)
: null
const retryMax =
retryMaxRaw !== null && retryMaxRaw !== undefined
? Math.trunc(retryMaxRaw)
: null
const retryDelaySeconds =
retryDelayMsRaw !== null && retryDelayMsRaw !== undefined
? (retryDelayMsRaw / 1000).toFixed(1)
: null
const errorLabel = retry?.error ?? tAcp("claudeApiRetry.fallbackError")
const statusLabel =
retryErrorStatusRaw !== null && retryErrorStatusRaw !== undefined
? tAcp("claudeApiRetry.httpStatus", {
status: Math.trunc(retryErrorStatusRaw),
})
: ""
const retryLabel =
retryAttempt !== null && retryMax !== null
? tAcp("claudeApiRetry.retryingWithMax", {
attempt: retryAttempt,
max: retryMax,
})
: retryAttempt !== null
? tAcp("claudeApiRetry.retryingAttempt", {
attempt: retryAttempt,
})
: tAcp("claudeApiRetry.retrying")
const delayLabel =
retryDelaySeconds !== null
? tAcp("claudeApiRetry.nextRetryIn", {
seconds: retryDelaySeconds,
})
: null
const retryLineText =
delayLabel !== null
? tAcp("claudeApiRetry.lineWithDelay", {
error: errorLabel,
status: statusLabel,
retry: retryLabel,
delay: delayLabel,
})
: tAcp("claudeApiRetry.line", {
error: errorLabel,
status: statusLabel,
retry: retryLabel,
})
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex-1 min-h-0">{children}</div>
@@ -145,6 +207,17 @@ export function ConversationShell({
/>
)}
{claudeApiRetry && (
<div className="border-t border-destructive/20 bg-destructive/5 px-4 py-2 text-xs text-destructive">
<div className="flex items-center gap-2 font-medium">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{retryLineText}
</span>
</div>
</div>
)}
{error && (
<div className="px-4 py-2 text-xs text-destructive bg-destructive/5 border-t border-destructive/20">
{error}

View File

@@ -877,6 +877,7 @@ const ConversationTabView = memo(function ConversationTabView({
defaultPath={workingDirForConnection}
agentName={AGENT_LABELS[selectedAgent]}
error={conn.error}
claudeApiRetry={conn.claudeApiRetry}
pendingPermission={conn.pendingPermission}
pendingQuestion={conn.pendingQuestion}
onFocus={handleFocus}

View File

@@ -77,6 +77,15 @@ export interface PendingQuestion {
question: string
}
export interface ClaudeApiRetryState {
sessionId: string
attempt: number | null
maxRetries: number | null
error: string | null
errorStatus: number | null
retryDelayMs: number | null
}
export type LiveContentBlock =
| { type: "text"; text: string }
| { type: "thinking"; text: string }
@@ -108,6 +117,7 @@ export interface ConnectionState {
liveMessage: LiveMessage | null
pendingPermission: PendingPermission | null
pendingQuestion: PendingQuestion | null
claudeApiRetry: ClaudeApiRetryState | null
error: string | null
}
@@ -221,6 +231,11 @@ type Action =
contextKey: string
entries: PlanEntryInfo[]
}
| {
type: "CLAUDE_API_RETRY"
contextKey: string
retry: ClaudeApiRetryState | null
}
| { type: "ERROR"; contextKey: string; message: string }
| {
type: "AVAILABLE_COMMANDS"
@@ -264,6 +279,37 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>
}
function asFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function parseClaudeApiRetryEvent(
event: Extract<AcpEvent, { type: "claude_sdk_message" }>
): ClaudeApiRetryState | null {
const message = asRecord(event.message)
if (!message) return null
if (message.type !== "system" || message.subtype !== "api_retry") return null
return {
sessionId:
typeof message.session_id === "string"
? message.session_id
: event.session_id,
attempt: asFiniteNumber(message.attempt),
maxRetries: asFiniteNumber(message.max_retries),
error: typeof message.error === "string" ? message.error : null,
errorStatus: asFiniteNumber(message.error_status),
retryDelayMs: asFiniteNumber(message.retry_delay_ms),
}
}
function extractPermissionToolCallId(toolCall: unknown): string | null {
const record = asRecord(toolCall)
if (!record) return null
@@ -554,6 +600,7 @@ function connectionsReducer(
liveMessage: null,
pendingPermission: null,
pendingQuestion: null,
claudeApiRetry: null,
error: null,
})
return next
@@ -581,7 +628,11 @@ function connectionsReducer(
startedAt: Date.now(),
}
updated.pendingQuestion = null
updated.claudeApiRetry = null
updated.error = null
} else if (conn.status === "prompting") {
// Prompt cycle ended: clear in-flight Claude API retry banner.
updated.claudeApiRetry = null
}
next.set(action.contextKey, updated)
return next
@@ -1086,12 +1137,24 @@ function connectionsReducer(
return next
}
case "CLAUDE_API_RETRY": {
const conn = state.get(action.contextKey)
if (!conn) return state
const next = new Map(state)
next.set(action.contextKey, {
...conn,
claudeApiRetry: action.retry,
})
return next
}
case "ERROR": {
const conn = state.get(action.contextKey)
if (!conn) return state
const next = new Map(state)
next.set(action.contextKey, {
...conn,
claudeApiRetry: null,
error: action.message,
})
return next
@@ -1582,6 +1645,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
case "thinking":
enqueueStreamingAction({ type: "THINKING", contextKey, text: e.text })
break
case "claude_sdk_message":
flushStreamingQueue()
dispatch({
type: "CLAUDE_API_RETRY",
contextKey,
retry: parseClaudeApiRetryEvent(e),
})
break
case "tool_call":
flushStreamingQueue()
dispatch({

View File

@@ -5,6 +5,7 @@ import {
useAcpActions,
useConnectionStore,
getCachedSelectors,
type ClaudeApiRetryState,
type ConnectionState,
type LiveMessage,
type PendingPermission,
@@ -40,6 +41,7 @@ export interface UseConnectionReturn {
liveMessage: LiveMessage | null
pendingPermission: PendingPermission | null
pendingQuestion: PendingQuestion | null
claudeApiRetry: ClaudeApiRetryState | null
error: string | null
connect: (
agentType: AgentType,
@@ -91,6 +93,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
const liveMessage = connection?.liveMessage ?? null
const pendingPermission = connection?.pendingPermission ?? null
const pendingQuestion = connection?.pendingQuestion ?? null
const claudeApiRetry = connection?.claudeApiRetry ?? null
const error = connection?.error ?? null
const connect = useCallback(
@@ -146,6 +149,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
liveMessage,
pendingPermission,
pendingQuestion,
claudeApiRetry,
error,
connect,
disconnect,
@@ -169,6 +173,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
liveMessage,
pendingPermission,
pendingQuestion,
claudeApiRetry,
error,
connect,
disconnect,

View File

@@ -1449,7 +1449,17 @@
"toolFallbackTitle": "أداة",
"eventErrorTitle": "خطأ الوكيل",
"notificationTurnComplete": "{agent} أنهى الاستجابة",
"notificationError": "{agent} خطأ: {message}"
"notificationError": "{agent} خطأ: {message}",
"claudeApiRetry": {
"fallbackError": "authentication_failed",
"retryingWithMax": "إعادة المحاولة {attempt}/{max}",
"retryingAttempt": "إعادة المحاولة رقم {attempt}",
"retrying": "جاري إعادة المحاولة",
"nextRetryIn": "المحاولة التالية خلال {seconds}ث",
"line": "{error}{status} · {retry}",
"lineWithDelay": "{error}{status} · {retry}، {delay}",
"httpStatus": " (HTTP {status})"
}
},
"connectionLifecycle": {
"tasks": {

View File

@@ -1449,7 +1449,17 @@
"toolFallbackTitle": "Werkzeug",
"eventErrorTitle": "Agentenfehler",
"notificationTurnComplete": "{agent} hat die Antwort abgeschlossen",
"notificationError": "{agent} Fehler: {message}"
"notificationError": "{agent} Fehler: {message}",
"claudeApiRetry": {
"fallbackError": "authentication_failed",
"retryingWithMax": "erneuter Versuch {attempt}/{max}",
"retryingAttempt": "erneuter Versuch {attempt}",
"retrying": "erneuter Versuch",
"nextRetryIn": "nächster in {seconds}s",
"line": "{error}{status} · {retry}",
"lineWithDelay": "{error}{status} · {retry}, {delay}",
"httpStatus": " (HTTP {status})"
}
},
"connectionLifecycle": {
"tasks": {

View File

@@ -1449,7 +1449,17 @@
"toolFallbackTitle": "Tool",
"eventErrorTitle": "Agent Error",
"notificationTurnComplete": "{agent} has finished responding",
"notificationError": "{agent} error: {message}"
"notificationError": "{agent} error: {message}",
"claudeApiRetry": {
"fallbackError": "authentication_failed",
"retryingWithMax": "retrying {attempt}/{max}",
"retryingAttempt": "retrying attempt {attempt}",
"retrying": "retrying",
"nextRetryIn": "next in {seconds}s",
"line": "{error}{status} · {retry}",
"lineWithDelay": "{error}{status} · {retry}, {delay}",
"httpStatus": " (HTTP {status})"
}
},
"connectionLifecycle": {
"tasks": {

View File

@@ -1449,7 +1449,17 @@
"toolFallbackTitle": "Herramienta",
"eventErrorTitle": "Error del agente",
"notificationTurnComplete": "{agent} ha terminado de responder",
"notificationError": "{agent} error: {message}"
"notificationError": "{agent} error: {message}",
"claudeApiRetry": {
"fallbackError": "authentication_failed",
"retryingWithMax": "reintentando {attempt}/{max}",
"retryingAttempt": "reintentando intento {attempt}",
"retrying": "reintentando",
"nextRetryIn": "siguiente en {seconds}s",
"line": "{error}{status} · {retry}",
"lineWithDelay": "{error}{status} · {retry}, {delay}",
"httpStatus": " (HTTP {status})"
}
},
"connectionLifecycle": {
"tasks": {

View File

@@ -1449,7 +1449,17 @@
"toolFallbackTitle": "Outil",
"eventErrorTitle": "Erreur de l'agent",
"notificationTurnComplete": "{agent} a terminé de répondre",
"notificationError": "{agent} erreur : {message}"
"notificationError": "{agent} erreur : {message}",
"claudeApiRetry": {
"fallbackError": "authentication_failed",
"retryingWithMax": "nouvelle tentative {attempt}/{max}",
"retryingAttempt": "nouvelle tentative {attempt}",
"retrying": "nouvelle tentative",
"nextRetryIn": "prochaine dans {seconds}s",
"line": "{error}{status} · {retry}",
"lineWithDelay": "{error}{status} · {retry}, {delay}",
"httpStatus": " (HTTP {status})"
}
},
"connectionLifecycle": {
"tasks": {

View File

@@ -1449,7 +1449,17 @@
"toolFallbackTitle": "ツール",
"eventErrorTitle": "エージェントエラー",
"notificationTurnComplete": "{agent} の応答が完了しました",
"notificationError": "{agent} エラー:{message}"
"notificationError": "{agent} エラー:{message}",
"claudeApiRetry": {
"fallbackError": "authentication_failed",
"retryingWithMax": "再試行中 {attempt}/{max}",
"retryingAttempt": "再試行中({attempt} 回目)",
"retrying": "再試行中",
"nextRetryIn": "{seconds}秒後に再試行",
"line": "{error}{status} · {retry}",
"lineWithDelay": "{error}{status} · {retry}、{delay}",
"httpStatus": " (HTTP {status})"
}
},
"connectionLifecycle": {
"tasks": {

View File

@@ -1449,7 +1449,17 @@
"toolFallbackTitle": "도구",
"eventErrorTitle": "에이전트 오류",
"notificationTurnComplete": "{agent} 응답이 완료되었습니다",
"notificationError": "{agent} 오류: {message}"
"notificationError": "{agent} 오류: {message}",
"claudeApiRetry": {
"fallbackError": "authentication_failed",
"retryingWithMax": "재시도 중 {attempt}/{max}",
"retryingAttempt": "{attempt}번째 재시도 중",
"retrying": "재시도 중",
"nextRetryIn": "{seconds}초 후 재시도",
"line": "{error}{status} · {retry}",
"lineWithDelay": "{error}{status} · {retry}, {delay}",
"httpStatus": " (HTTP {status})"
}
},
"connectionLifecycle": {
"tasks": {

View File

@@ -1449,7 +1449,17 @@
"toolFallbackTitle": "Ferramenta",
"eventErrorTitle": "Erro do agente",
"notificationTurnComplete": "{agent} terminou de responder",
"notificationError": "{agent} erro: {message}"
"notificationError": "{agent} erro: {message}",
"claudeApiRetry": {
"fallbackError": "authentication_failed",
"retryingWithMax": "tentando novamente {attempt}/{max}",
"retryingAttempt": "tentando novamente tentativa {attempt}",
"retrying": "tentando novamente",
"nextRetryIn": "próxima em {seconds}s",
"line": "{error}{status} · {retry}",
"lineWithDelay": "{error}{status} · {retry}, {delay}",
"httpStatus": " (HTTP {status})"
}
},
"connectionLifecycle": {
"tasks": {

View File

@@ -1449,7 +1449,17 @@
"toolFallbackTitle": "工具",
"eventErrorTitle": "Agent 错误",
"notificationTurnComplete": "{agent} 已完成响应",
"notificationError": "{agent} 错误:{message}"
"notificationError": "{agent} 错误:{message}",
"claudeApiRetry": {
"fallbackError": "authentication_failed",
"retryingWithMax": "正在重试 {attempt}/{max}",
"retryingAttempt": "正在重试(第 {attempt} 次)",
"retrying": "正在重试",
"nextRetryIn": "{seconds} 秒后重试",
"line": "{error}{status} · {retry}",
"lineWithDelay": "{error}{status} · {retry}{delay}",
"httpStatus": "HTTP {status}"
}
},
"connectionLifecycle": {
"tasks": {

View File

@@ -1449,7 +1449,17 @@
"toolFallbackTitle": "工具",
"eventErrorTitle": "Agent 錯誤",
"notificationTurnComplete": "{agent} 已完成回應",
"notificationError": "{agent} 錯誤:{message}"
"notificationError": "{agent} 錯誤:{message}",
"claudeApiRetry": {
"fallbackError": "authentication_failed",
"retryingWithMax": "正在重試 {attempt}/{max}",
"retryingAttempt": "正在重試(第 {attempt} 次)",
"retrying": "正在重試",
"nextRetryIn": "{seconds} 秒後重試",
"line": "{error}{status} · {retry}",
"lineWithDelay": "{error}{status} · {retry}{delay}",
"httpStatus": "HTTP {status}"
}
},
"connectionLifecycle": {
"tasks": {

View File

@@ -362,6 +362,12 @@ export interface SessionUsageUpdateInfo {
export type AcpEvent =
| { type: "content_delta"; connection_id: string; text: string }
| { type: "thinking"; connection_id: string; text: string }
| {
type: "claude_sdk_message"
connection_id: string
session_id: string
message: unknown
}
| {
type: "tool_call"
connection_id: string