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:
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use sacp::schema::McpServerStdio;
|
use sacp::schema::McpServerStdio;
|
||||||
@@ -706,6 +706,47 @@ fn resolve_working_dir(working_dir: Option<&str>) -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn claude_raw_sdk_session_meta(
|
||||||
|
agent_type: AgentType,
|
||||||
|
) -> Option<serde_json::Map<String, serde_json::Value>> {
|
||||||
|
if agent_type != AgentType::ClaudeCode {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut claude_code = serde_json::Map::new();
|
||||||
|
claude_code.insert(
|
||||||
|
"emitRawSDKMessages".to_string(),
|
||||||
|
serde_json::Value::Bool(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut meta = serde_json::Map::new();
|
||||||
|
meta.insert(
|
||||||
|
"claudeCode".to_string(),
|
||||||
|
serde_json::Value::Object(claude_code),
|
||||||
|
);
|
||||||
|
Some(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_new_session_request(agent_type: AgentType, cwd: &Path) -> NewSessionRequest {
|
||||||
|
let mut req = NewSessionRequest::new(cwd.to_path_buf());
|
||||||
|
if let Some(meta) = claude_raw_sdk_session_meta(agent_type) {
|
||||||
|
req = req.meta(meta);
|
||||||
|
}
|
||||||
|
req
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_load_session_request(
|
||||||
|
agent_type: AgentType,
|
||||||
|
session_id: SessionId,
|
||||||
|
cwd: &Path,
|
||||||
|
) -> LoadSessionRequest {
|
||||||
|
let mut req = LoadSessionRequest::new(session_id, cwd.to_path_buf());
|
||||||
|
if let Some(meta) = claude_raw_sdk_session_meta(agent_type) {
|
||||||
|
req = req.meta(meta);
|
||||||
|
}
|
||||||
|
req
|
||||||
|
}
|
||||||
|
|
||||||
/// The main ACP connection loop.
|
/// The main ACP connection loop.
|
||||||
async fn run_connection(
|
async fn run_connection(
|
||||||
agent: AcpAgent,
|
agent: AcpAgent,
|
||||||
@@ -926,7 +967,8 @@ async fn run_connection(
|
|||||||
|
|
||||||
if let Some(sid) = session_id {
|
if let Some(sid) = session_id {
|
||||||
// Load existing session via session/load
|
// Load existing session via session/load
|
||||||
let load_req = LoadSessionRequest::new(SessionId::new(sid.clone()), &cwd);
|
let load_req =
|
||||||
|
build_load_session_request(agent_type, SessionId::new(sid.clone()), &cwd);
|
||||||
let load_result = cx.send_request_to(Agent, load_req).block_task().await;
|
let load_result = cx.send_request_to(Agent, load_req).block_task().await;
|
||||||
|
|
||||||
match load_result {
|
match load_result {
|
||||||
@@ -963,7 +1005,11 @@ async fn run_connection(
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.otherwise_ignore();
|
.otherwise(async |dispatch| {
|
||||||
|
maybe_emit_claude_sdk_ext_notification(&cid, &h, dispatch);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if drained > 0 {
|
if drained > 0 {
|
||||||
@@ -1042,7 +1088,7 @@ async fn run_connection(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let new_resp = cx
|
let new_resp = cx
|
||||||
.send_request_to(Agent, NewSessionRequest::new(cwd.clone()))
|
.send_request_to(Agent, build_new_session_request(agent_type, &cwd))
|
||||||
.block_task()
|
.block_task()
|
||||||
.await?;
|
.await?;
|
||||||
let fallback_sid = new_resp.session_id.0.to_string();
|
let fallback_sid = new_resp.session_id.0.to_string();
|
||||||
@@ -1099,7 +1145,7 @@ async fn run_connection(
|
|||||||
} else {
|
} else {
|
||||||
// Create new session
|
// Create new session
|
||||||
let new_resp = cx
|
let new_resp = cx
|
||||||
.send_request_to(Agent, NewSessionRequest::new(cwd.clone()))
|
.send_request_to(Agent, build_new_session_request(agent_type, &cwd))
|
||||||
.block_task()
|
.block_task()
|
||||||
.await?;
|
.await?;
|
||||||
let sid = new_resp.session_id.0.to_string();
|
let sid = new_resp.session_id.0.to_string();
|
||||||
@@ -1778,7 +1824,11 @@ async fn run_conversation_loop<'a>(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.otherwise_ignore();
|
.otherwise(async |dispatch| {
|
||||||
|
maybe_emit_claude_sdk_ext_notification(&cid, &h, dispatch);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1879,7 +1929,11 @@ async fn run_conversation_loop<'a>(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.otherwise_ignore()
|
.otherwise(async |dispatch| {
|
||||||
|
maybe_emit_claude_sdk_ext_notification(&cid, &h, dispatch);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
eprintln!("[ACP] Ignoring dispatch parse error: {e}");
|
eprintln!("[ACP] Ignoring dispatch parse error: {e}");
|
||||||
}
|
}
|
||||||
@@ -2347,6 +2401,58 @@ fn map_plan_entries(plan: &Plan) -> Vec<PlanEntryInfo> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_claude_sdk_message_params(
|
||||||
|
params: &serde_json::Value,
|
||||||
|
) -> Option<(String, serde_json::Value)> {
|
||||||
|
let obj = params.as_object()?;
|
||||||
|
let session_id = obj.get("sessionId")?.as_str()?.to_string();
|
||||||
|
let message = obj.get("message")?.clone();
|
||||||
|
Some((session_id, message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_claude_api_retry_message(message: &serde_json::Value) -> bool {
|
||||||
|
let obj = match message.as_object() {
|
||||||
|
Some(obj) => obj,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let message_type = obj.get("type").and_then(|v| v.as_str());
|
||||||
|
let message_subtype = obj.get("subtype").and_then(|v| v.as_str());
|
||||||
|
matches!(message_type, Some("system")) && matches!(message_subtype, Some("api_retry"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_claude_sdk_ext_notification(
|
||||||
|
connection_id: &str,
|
||||||
|
notification: &UntypedMessage,
|
||||||
|
) -> Option<AcpEvent> {
|
||||||
|
if notification.method() != "_claude/sdkMessage" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (session_id, message) = parse_claude_sdk_message_params(notification.params())?;
|
||||||
|
if !is_claude_api_retry_message(&message) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(AcpEvent::ClaudeSdkMessage {
|
||||||
|
connection_id: connection_id.to_string(),
|
||||||
|
session_id,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_emit_claude_sdk_ext_notification(
|
||||||
|
connection_id: &str,
|
||||||
|
emitter: &EventEmitter,
|
||||||
|
dispatch: Dispatch,
|
||||||
|
) {
|
||||||
|
let Dispatch::Notification(notification) = dispatch else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(event) = map_claude_sdk_ext_notification(connection_id, ¬ification) {
|
||||||
|
crate::web::event_bridge::emit_event(emitter, "acp://event", event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fix null fields in `usage_update` notifications that would otherwise fail deserialization.
|
/// Fix null fields in `usage_update` notifications that would otherwise fail deserialization.
|
||||||
///
|
///
|
||||||
/// Some ACP agents send `"used": null` in usage_update notifications, but the
|
/// Some ACP agents send `"used": null` in usage_update notifications, but the
|
||||||
@@ -2524,3 +2630,110 @@ fn emit_conversation_update(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_raw_sdk_meta_enabled_only_for_claude() {
|
||||||
|
let claude_meta = claude_raw_sdk_session_meta(AgentType::ClaudeCode)
|
||||||
|
.expect("Claude must have raw SDK meta");
|
||||||
|
assert_eq!(
|
||||||
|
claude_meta
|
||||||
|
.get("claudeCode")
|
||||||
|
.and_then(|v| v.get("emitRawSDKMessages"))
|
||||||
|
.and_then(|v| v.as_bool()),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(claude_raw_sdk_session_meta(AgentType::Codex).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_claude_sdk_ext_notification_maps_valid_payload() {
|
||||||
|
let raw = UntypedMessage::new(
|
||||||
|
"_claude/sdkMessage",
|
||||||
|
serde_json::json!({
|
||||||
|
"sessionId": "session-123",
|
||||||
|
"message": {
|
||||||
|
"type": "system",
|
||||||
|
"subtype": "api_retry",
|
||||||
|
"attempt": 3,
|
||||||
|
"max_retries": 10
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let event = map_claude_sdk_ext_notification("conn-1", &raw)
|
||||||
|
.expect("valid sdk payload should map");
|
||||||
|
|
||||||
|
match event {
|
||||||
|
AcpEvent::ClaudeSdkMessage {
|
||||||
|
connection_id,
|
||||||
|
session_id,
|
||||||
|
message,
|
||||||
|
} => {
|
||||||
|
assert_eq!(connection_id, "conn-1");
|
||||||
|
assert_eq!(session_id, "session-123");
|
||||||
|
assert_eq!(message.get("type").and_then(|v| v.as_str()), Some("system"));
|
||||||
|
}
|
||||||
|
_ => panic!("expected ClaudeSdkMessage"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_claude_sdk_ext_notification_rejects_non_api_retry() {
|
||||||
|
let non_retry = UntypedMessage::new(
|
||||||
|
"_claude/sdkMessage",
|
||||||
|
serde_json::json!({
|
||||||
|
"sessionId": "session-123",
|
||||||
|
"message": {"type": "system", "subtype": "status"}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(map_claude_sdk_ext_notification("conn-1", &non_retry).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_claude_sdk_ext_notification_rejects_invalid_payload() {
|
||||||
|
let wrong_method = UntypedMessage::new(
|
||||||
|
"_other/method",
|
||||||
|
serde_json::json!({"sessionId": "s", "message": {}}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(map_claude_sdk_ext_notification("conn-1", &wrong_method).is_none());
|
||||||
|
|
||||||
|
let missing_fields = UntypedMessage::new(
|
||||||
|
"_claude/sdkMessage",
|
||||||
|
serde_json::json!({"sessionId": 1}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(map_claude_sdk_ext_notification("conn-1", &missing_fields).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_new_session_request_sets_claude_raw_meta() {
|
||||||
|
let cwd = std::path::PathBuf::from("/tmp/codeg");
|
||||||
|
let req = build_new_session_request(AgentType::ClaudeCode, &cwd);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
req.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.get("claudeCode"))
|
||||||
|
.and_then(|v| v.get("emitRawSDKMessages"))
|
||||||
|
.and_then(|v| v.as_bool()),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_load_session_request_skips_meta_for_non_claude() {
|
||||||
|
let cwd = std::path::PathBuf::from("/tmp/codeg");
|
||||||
|
let req =
|
||||||
|
build_load_session_request(AgentType::Codex, SessionId::new("abc".to_string()), &cwd);
|
||||||
|
|
||||||
|
assert!(req.meta.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ pub enum AcpEvent {
|
|||||||
ContentDelta { connection_id: String, text: String },
|
ContentDelta { connection_id: String, text: String },
|
||||||
/// Agent thinking/reasoning
|
/// Agent thinking/reasoning
|
||||||
Thinking { connection_id: String, text: String },
|
Thinking { connection_id: String, text: String },
|
||||||
|
/// Raw SDK message forwarded from Claude ACP extension notification
|
||||||
|
ClaudeSdkMessage {
|
||||||
|
connection_id: String,
|
||||||
|
session_id: String,
|
||||||
|
message: serde_json::Value,
|
||||||
|
},
|
||||||
/// Agent initiated a tool call
|
/// Agent initiated a tool call
|
||||||
ToolCall {
|
ToolCall {
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
AgentType,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
@@ -11,8 +12,10 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
PendingPermission,
|
PendingPermission,
|
||||||
PendingQuestion,
|
PendingQuestion,
|
||||||
|
ClaudeApiRetryState,
|
||||||
} from "@/contexts/acp-connections-context"
|
} from "@/contexts/acp-connections-context"
|
||||||
import type { QueuedMessage } from "@/hooks/use-message-queue"
|
import type { QueuedMessage } from "@/hooks/use-message-queue"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
import { ChatInput } from "@/components/chat/chat-input"
|
import { ChatInput } from "@/components/chat/chat-input"
|
||||||
import { PermissionDialog } from "@/components/chat/permission-dialog"
|
import { PermissionDialog } from "@/components/chat/permission-dialog"
|
||||||
import { QuestionDialog } from "@/components/chat/question-dialog"
|
import { QuestionDialog } from "@/components/chat/question-dialog"
|
||||||
@@ -23,6 +26,7 @@ interface ConversationShellProps {
|
|||||||
defaultPath?: string
|
defaultPath?: string
|
||||||
agentName?: string
|
agentName?: string
|
||||||
error: string | null
|
error: string | null
|
||||||
|
claudeApiRetry: ClaudeApiRetryState | null
|
||||||
pendingPermission: PendingPermission | null
|
pendingPermission: PendingPermission | null
|
||||||
pendingQuestion: PendingQuestion | null
|
pendingQuestion: PendingQuestion | null
|
||||||
onFocus: () => void
|
onFocus: () => void
|
||||||
@@ -64,6 +68,7 @@ export function ConversationShell({
|
|||||||
defaultPath,
|
defaultPath,
|
||||||
agentName,
|
agentName,
|
||||||
error,
|
error,
|
||||||
|
claudeApiRetry,
|
||||||
pendingPermission,
|
pendingPermission,
|
||||||
pendingQuestion,
|
pendingQuestion,
|
||||||
onFocus,
|
onFocus,
|
||||||
@@ -98,6 +103,63 @@ export function ConversationShell({
|
|||||||
onCancelQueueEdit,
|
onCancelQueueEdit,
|
||||||
onForkSend,
|
onForkSend,
|
||||||
}: ConversationShellProps) {
|
}: 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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="flex-1 min-h-0">{children}</div>
|
<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 && (
|
{error && (
|
||||||
<div className="px-4 py-2 text-xs text-destructive bg-destructive/5 border-t border-destructive/20">
|
<div className="px-4 py-2 text-xs text-destructive bg-destructive/5 border-t border-destructive/20">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -877,6 +877,7 @@ const ConversationTabView = memo(function ConversationTabView({
|
|||||||
defaultPath={workingDirForConnection}
|
defaultPath={workingDirForConnection}
|
||||||
agentName={AGENT_LABELS[selectedAgent]}
|
agentName={AGENT_LABELS[selectedAgent]}
|
||||||
error={conn.error}
|
error={conn.error}
|
||||||
|
claudeApiRetry={conn.claudeApiRetry}
|
||||||
pendingPermission={conn.pendingPermission}
|
pendingPermission={conn.pendingPermission}
|
||||||
pendingQuestion={conn.pendingQuestion}
|
pendingQuestion={conn.pendingQuestion}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ export interface PendingQuestion {
|
|||||||
question: string
|
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 =
|
export type LiveContentBlock =
|
||||||
| { type: "text"; text: string }
|
| { type: "text"; text: string }
|
||||||
| { type: "thinking"; text: string }
|
| { type: "thinking"; text: string }
|
||||||
@@ -108,6 +117,7 @@ export interface ConnectionState {
|
|||||||
liveMessage: LiveMessage | null
|
liveMessage: LiveMessage | null
|
||||||
pendingPermission: PendingPermission | null
|
pendingPermission: PendingPermission | null
|
||||||
pendingQuestion: PendingQuestion | null
|
pendingQuestion: PendingQuestion | null
|
||||||
|
claudeApiRetry: ClaudeApiRetryState | null
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +231,11 @@ type Action =
|
|||||||
contextKey: string
|
contextKey: string
|
||||||
entries: PlanEntryInfo[]
|
entries: PlanEntryInfo[]
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "CLAUDE_API_RETRY"
|
||||||
|
contextKey: string
|
||||||
|
retry: ClaudeApiRetryState | null
|
||||||
|
}
|
||||||
| { type: "ERROR"; contextKey: string; message: string }
|
| { type: "ERROR"; contextKey: string; message: string }
|
||||||
| {
|
| {
|
||||||
type: "AVAILABLE_COMMANDS"
|
type: "AVAILABLE_COMMANDS"
|
||||||
@@ -264,6 +279,37 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return value as Record<string, unknown>
|
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 {
|
function extractPermissionToolCallId(toolCall: unknown): string | null {
|
||||||
const record = asRecord(toolCall)
|
const record = asRecord(toolCall)
|
||||||
if (!record) return null
|
if (!record) return null
|
||||||
@@ -554,6 +600,7 @@ function connectionsReducer(
|
|||||||
liveMessage: null,
|
liveMessage: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
pendingQuestion: null,
|
pendingQuestion: null,
|
||||||
|
claudeApiRetry: null,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
return next
|
return next
|
||||||
@@ -581,7 +628,11 @@ function connectionsReducer(
|
|||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
}
|
}
|
||||||
updated.pendingQuestion = null
|
updated.pendingQuestion = null
|
||||||
|
updated.claudeApiRetry = null
|
||||||
updated.error = 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)
|
next.set(action.contextKey, updated)
|
||||||
return next
|
return next
|
||||||
@@ -1086,12 +1137,24 @@ function connectionsReducer(
|
|||||||
return next
|
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": {
|
case "ERROR": {
|
||||||
const conn = state.get(action.contextKey)
|
const conn = state.get(action.contextKey)
|
||||||
if (!conn) return state
|
if (!conn) return state
|
||||||
const next = new Map(state)
|
const next = new Map(state)
|
||||||
next.set(action.contextKey, {
|
next.set(action.contextKey, {
|
||||||
...conn,
|
...conn,
|
||||||
|
claudeApiRetry: null,
|
||||||
error: action.message,
|
error: action.message,
|
||||||
})
|
})
|
||||||
return next
|
return next
|
||||||
@@ -1582,6 +1645,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
case "thinking":
|
case "thinking":
|
||||||
enqueueStreamingAction({ type: "THINKING", contextKey, text: e.text })
|
enqueueStreamingAction({ type: "THINKING", contextKey, text: e.text })
|
||||||
break
|
break
|
||||||
|
case "claude_sdk_message":
|
||||||
|
flushStreamingQueue()
|
||||||
|
dispatch({
|
||||||
|
type: "CLAUDE_API_RETRY",
|
||||||
|
contextKey,
|
||||||
|
retry: parseClaudeApiRetryEvent(e),
|
||||||
|
})
|
||||||
|
break
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
flushStreamingQueue()
|
flushStreamingQueue()
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useAcpActions,
|
useAcpActions,
|
||||||
useConnectionStore,
|
useConnectionStore,
|
||||||
getCachedSelectors,
|
getCachedSelectors,
|
||||||
|
type ClaudeApiRetryState,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
type LiveMessage,
|
type LiveMessage,
|
||||||
type PendingPermission,
|
type PendingPermission,
|
||||||
@@ -40,6 +41,7 @@ export interface UseConnectionReturn {
|
|||||||
liveMessage: LiveMessage | null
|
liveMessage: LiveMessage | null
|
||||||
pendingPermission: PendingPermission | null
|
pendingPermission: PendingPermission | null
|
||||||
pendingQuestion: PendingQuestion | null
|
pendingQuestion: PendingQuestion | null
|
||||||
|
claudeApiRetry: ClaudeApiRetryState | null
|
||||||
error: string | null
|
error: string | null
|
||||||
connect: (
|
connect: (
|
||||||
agentType: AgentType,
|
agentType: AgentType,
|
||||||
@@ -91,6 +93,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
|
|||||||
const liveMessage = connection?.liveMessage ?? null
|
const liveMessage = connection?.liveMessage ?? null
|
||||||
const pendingPermission = connection?.pendingPermission ?? null
|
const pendingPermission = connection?.pendingPermission ?? null
|
||||||
const pendingQuestion = connection?.pendingQuestion ?? null
|
const pendingQuestion = connection?.pendingQuestion ?? null
|
||||||
|
const claudeApiRetry = connection?.claudeApiRetry ?? null
|
||||||
const error = connection?.error ?? null
|
const error = connection?.error ?? null
|
||||||
|
|
||||||
const connect = useCallback(
|
const connect = useCallback(
|
||||||
@@ -146,6 +149,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
|
|||||||
liveMessage,
|
liveMessage,
|
||||||
pendingPermission,
|
pendingPermission,
|
||||||
pendingQuestion,
|
pendingQuestion,
|
||||||
|
claudeApiRetry,
|
||||||
error,
|
error,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
@@ -169,6 +173,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
|
|||||||
liveMessage,
|
liveMessage,
|
||||||
pendingPermission,
|
pendingPermission,
|
||||||
pendingQuestion,
|
pendingQuestion,
|
||||||
|
claudeApiRetry,
|
||||||
error,
|
error,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
|
|||||||
@@ -1449,7 +1449,17 @@
|
|||||||
"toolFallbackTitle": "أداة",
|
"toolFallbackTitle": "أداة",
|
||||||
"eventErrorTitle": "خطأ الوكيل",
|
"eventErrorTitle": "خطأ الوكيل",
|
||||||
"notificationTurnComplete": "{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": {
|
"connectionLifecycle": {
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1449,7 +1449,17 @@
|
|||||||
"toolFallbackTitle": "Werkzeug",
|
"toolFallbackTitle": "Werkzeug",
|
||||||
"eventErrorTitle": "Agentenfehler",
|
"eventErrorTitle": "Agentenfehler",
|
||||||
"notificationTurnComplete": "{agent} hat die Antwort abgeschlossen",
|
"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": {
|
"connectionLifecycle": {
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1449,7 +1449,17 @@
|
|||||||
"toolFallbackTitle": "Tool",
|
"toolFallbackTitle": "Tool",
|
||||||
"eventErrorTitle": "Agent Error",
|
"eventErrorTitle": "Agent Error",
|
||||||
"notificationTurnComplete": "{agent} has finished responding",
|
"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": {
|
"connectionLifecycle": {
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1449,7 +1449,17 @@
|
|||||||
"toolFallbackTitle": "Herramienta",
|
"toolFallbackTitle": "Herramienta",
|
||||||
"eventErrorTitle": "Error del agente",
|
"eventErrorTitle": "Error del agente",
|
||||||
"notificationTurnComplete": "{agent} ha terminado de responder",
|
"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": {
|
"connectionLifecycle": {
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1449,7 +1449,17 @@
|
|||||||
"toolFallbackTitle": "Outil",
|
"toolFallbackTitle": "Outil",
|
||||||
"eventErrorTitle": "Erreur de l'agent",
|
"eventErrorTitle": "Erreur de l'agent",
|
||||||
"notificationTurnComplete": "{agent} a terminé de répondre",
|
"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": {
|
"connectionLifecycle": {
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1449,7 +1449,17 @@
|
|||||||
"toolFallbackTitle": "ツール",
|
"toolFallbackTitle": "ツール",
|
||||||
"eventErrorTitle": "エージェントエラー",
|
"eventErrorTitle": "エージェントエラー",
|
||||||
"notificationTurnComplete": "{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": {
|
"connectionLifecycle": {
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1449,7 +1449,17 @@
|
|||||||
"toolFallbackTitle": "도구",
|
"toolFallbackTitle": "도구",
|
||||||
"eventErrorTitle": "에이전트 오류",
|
"eventErrorTitle": "에이전트 오류",
|
||||||
"notificationTurnComplete": "{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": {
|
"connectionLifecycle": {
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1449,7 +1449,17 @@
|
|||||||
"toolFallbackTitle": "Ferramenta",
|
"toolFallbackTitle": "Ferramenta",
|
||||||
"eventErrorTitle": "Erro do agente",
|
"eventErrorTitle": "Erro do agente",
|
||||||
"notificationTurnComplete": "{agent} terminou de responder",
|
"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": {
|
"connectionLifecycle": {
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1449,7 +1449,17 @@
|
|||||||
"toolFallbackTitle": "工具",
|
"toolFallbackTitle": "工具",
|
||||||
"eventErrorTitle": "Agent 错误",
|
"eventErrorTitle": "Agent 错误",
|
||||||
"notificationTurnComplete": "{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": {
|
"connectionLifecycle": {
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1449,7 +1449,17 @@
|
|||||||
"toolFallbackTitle": "工具",
|
"toolFallbackTitle": "工具",
|
||||||
"eventErrorTitle": "Agent 錯誤",
|
"eventErrorTitle": "Agent 錯誤",
|
||||||
"notificationTurnComplete": "{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": {
|
"connectionLifecycle": {
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -362,6 +362,12 @@ export interface SessionUsageUpdateInfo {
|
|||||||
export type AcpEvent =
|
export type AcpEvent =
|
||||||
| { type: "content_delta"; connection_id: string; text: string }
|
| { type: "content_delta"; connection_id: string; text: string }
|
||||||
| { type: "thinking"; 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"
|
type: "tool_call"
|
||||||
connection_id: string
|
connection_id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user