diff --git a/crates/aionui-ai-agent/src/protocol/error.rs b/crates/aionui-ai-agent/src/protocol/error.rs index 0f907d62..df742f5f 100644 --- a/crates/aionui-ai-agent/src/protocol/error.rs +++ b/crates/aionui-ai-agent/src/protocol/error.rs @@ -111,9 +111,20 @@ pub enum AcpError { /// Agent requires authentication first. AuthRequired, + /// Agent returned JSON that the protocol layer could not parse. + ProtocolParseError { message: String }, + + /// Agent rejected a malformed JSON-RPC request object. + InvalidRequest { message: String }, + /// Agent-side session not found. SessionNotFound { session_id: String }, + /// Agent-side resource not found. This is distinct from stale ACP session + /// IDs; `session-not-found` payloads are normalized to `SessionNotFound` + /// before this variant is constructed. + ResourceNotFound { resource: Option, message: String }, + /// Agent does not support the requested method. MethodNotFound { method: String }, @@ -131,6 +142,15 @@ pub enum AcpError { data: Option, }, + /// Agent returned a custom JSON-RPC/ACP error code outside the standard + /// set. Keep it structured so UI can show a protocol error instead of an + /// unknown upstream failure. + OtherProtocolError { + code: i32, + message: String, + data: Option, + }, + // ── Local errors ────────────────────────────────────────────── /// Protocol not connected (used before connect or after disconnect). NotConnected, @@ -182,9 +202,19 @@ impl std::fmt::Display for AcpError { write!(f, "Agent process disconnected{detail}") } AcpError::AuthRequired => f.write_str("Authentication required"), + AcpError::ProtocolParseError { message } => { + write!(f, "Agent protocol parse error: {message}") + } + AcpError::InvalidRequest { message } => { + write!(f, "Agent rejected an invalid protocol request: {message}") + } AcpError::SessionNotFound { session_id } => { write!(f, "Session not found: {session_id}") } + AcpError::ResourceNotFound { resource, message } => match resource { + Some(resource) => write!(f, "Agent resource not found: {resource} ({message})"), + None => write!(f, "Agent resource not found: {message}"), + }, AcpError::MethodNotFound { method } => { write!(f, "Method not supported: {method}") } @@ -208,6 +238,14 @@ impl std::fmt::Display for AcpError { } Ok(()) } + AcpError::OtherProtocolError { code, message, data } => { + write!(f, "Agent protocol error (code {code}): {message}")?; + if let Some(data) = data { + let compact = serde_json::to_string(data).unwrap_or_else(|_| "".to_owned()); + write!(f, " ({compact})")?; + } + Ok(()) + } AcpError::NotConnected => f.write_str("ACP protocol not connected"), AcpError::InitTimeout { timeout_secs } => { write!(f, "Initialize handshake timed out after {timeout_secs}s") @@ -243,9 +281,18 @@ impl AcpError { pub fn from_sdk(err: SdkError, context: &str) -> Self { match err.code { ErrorCode::AuthRequired => AcpError::AuthRequired, - ErrorCode::ResourceNotFound => AcpError::SessionNotFound { - session_id: context.to_owned(), - }, + ErrorCode::ParseError => AcpError::ProtocolParseError { message: err.message }, + ErrorCode::InvalidRequest => AcpError::InvalidRequest { message: err.message }, + ErrorCode::ResourceNotFound => { + if let Some(sid) = extract_session_not_found(err.data.as_ref()) { + AcpError::SessionNotFound { session_id: sid } + } else { + AcpError::ResourceNotFound { + resource: extract_resource_not_found(err.data.as_ref()), + message: err.message, + } + } + } ErrorCode::MethodNotFound => AcpError::MethodNotFound { method: context.to_owned(), }, @@ -256,7 +303,7 @@ impl AcpError { AcpError::InvalidParams { message: err.message } } } - ErrorCode::ParseError | ErrorCode::InvalidRequest | ErrorCode::InternalError => { + ErrorCode::InternalError => { if let Some(sid) = extract_session_not_found(err.data.as_ref()) { AcpError::SessionNotFound { session_id: sid } } else { @@ -269,17 +316,18 @@ impl AcpError { } _ => { let code = i32::from(err.code); - // -32001, -32002: additional session-not-found codes used by some agents - if code == -32001 || code == -32002 { + // -32001: additional session-not-found code used by some agents. + // -32002 is ACP ResourceNotFound and is handled above by ErrorCode. + if code == -32001 { AcpError::SessionNotFound { session_id: context.to_owned(), } } else if let Some(sid) = extract_session_not_found(err.data.as_ref()) { AcpError::SessionNotFound { session_id: sid } } else { - AcpError::AgentInternal { - message: err.message, + AcpError::OtherProtocolError { code, + message: err.message, data: err.data, } } @@ -306,6 +354,16 @@ fn extract_session_not_found(data: Option<&serde_json::Value>) -> Option if sid.is_empty() { None } else { Some(sid.to_owned()) } } +fn extract_resource_not_found(data: Option<&serde_json::Value>) -> Option { + let value = data?; + let obj = match value { + serde_json::Value::Object(_) => value.clone(), + serde_json::Value::String(s) => serde_json::from_str(s).ok()?, + _ => return None, + }; + obj.get("uri").and_then(|uri| uri.as_str()).map(str::to_owned) +} + #[cfg(test)] mod tests { use super::*; @@ -457,13 +515,29 @@ mod tests { assert!(matches!(acp, AcpError::AuthRequired)); } + #[test] + fn from_sdk_parse_error_preserves_protocol_error() { + let sdk_err = SdkError::parse_error(); + let acp = AcpError::from_sdk(sdk_err, "initialize"); + assert!(matches!(acp, AcpError::ProtocolParseError { .. })); + } + + #[test] + fn from_sdk_invalid_request_preserves_protocol_error() { + let sdk_err = SdkError::invalid_request(); + let acp = AcpError::from_sdk(sdk_err, "session/new"); + assert!(matches!(acp, AcpError::InvalidRequest { .. })); + } + #[test] fn from_sdk_resource_not_found() { - let sdk_err = SdkError::resource_not_found(None); - let acp = AcpError::from_sdk(sdk_err, "sess-42"); + let sdk_err = SdkError::resource_not_found(Some("file:///missing.txt".to_owned())); + let acp = AcpError::from_sdk(sdk_err, "session/new"); match acp { - AcpError::SessionNotFound { session_id } => assert_eq!(session_id, "sess-42"), - other => panic!("Expected SessionNotFound, got {other:?}"), + AcpError::ResourceNotFound { resource, .. } => { + assert_eq!(resource.as_deref(), Some("file:///missing.txt")); + } + other => panic!("Expected ResourceNotFound, got {other:?}"), } } @@ -565,11 +639,11 @@ mod tests { let sdk_err = SdkError::new(-32099, "custom error"); let acp = AcpError::from_sdk(sdk_err, "ctx"); match acp { - AcpError::AgentInternal { code, message, .. } => { + AcpError::OtherProtocolError { code, message, .. } => { assert_eq!(code, -32099); assert_eq!(message, "custom error"); } - other => panic!("Expected AgentInternal, got {other:?}"), + other => panic!("Expected OtherProtocolError, got {other:?}"), } } @@ -642,19 +716,19 @@ mod tests { } #[test] - fn agent_internal_preserves_code_and_data() { + fn other_protocol_error_preserves_code_and_data() { let sdk_err = SdkError::new(-32099, "custom upstream error") .data(serde_json::json!({"reason": "rate_limited", "retry_after": 30})); let acp = AcpError::from_sdk(sdk_err, "context"); match acp { - AcpError::AgentInternal { code, message, data } => { + AcpError::OtherProtocolError { code, message, data } => { assert_eq!(code, -32099); assert_eq!(message, "custom upstream error"); let data = data.expect("data must be preserved"); assert_eq!(data["reason"], "rate_limited"); assert_eq!(data["retry_after"], 30); } - other => panic!("Expected AgentInternal, got {other:?}"), + other => panic!("Expected OtherProtocolError, got {other:?}"), } } diff --git a/crates/aionui-ai-agent/src/protocol/send_error.rs b/crates/aionui-ai-agent/src/protocol/send_error.rs index d381a3ef..b0cb88c7 100644 --- a/crates/aionui-ai-agent/src/protocol/send_error.rs +++ b/crates/aionui-ai-agent/src/protocol/send_error.rs @@ -10,6 +10,8 @@ const MAX_DETAIL_CHARS: usize = 1000; const OPENCLAW_BACKEND: &str = "openclaw"; const OPENCLAW_GATEWAY_MESSAGE: &str = "OpenClaw Gateway is not reachable"; const OPENCLAW_GATEWAY_DETAIL: &str = "OpenClaw Gateway is not running or cannot be reached at 127.0.0.1:18789.\n\nStart OpenClaw Gateway and try again. You can run:\nopenclaw gateway status\nopenclaw gateway start"; +const AWS_SSO_EXPIRED_DETAIL: &str = + "Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile."; #[derive(Debug, Clone)] pub struct AgentSendError { @@ -23,7 +25,7 @@ struct ClassifiedError { ownership: AgentErrorOwnership, retryable: bool, feedback_recommended: bool, - resolution_kind: AgentErrorResolutionKind, + resolution_kind: Option, resolution_target: Option, } @@ -36,7 +38,8 @@ impl ClassifiedError { Some(detail), self.retryable, self.feedback_recommended, - resolution(self.resolution_kind, self.resolution_target), + self.resolution_kind + .and_then(|kind| resolution(kind, self.resolution_target)), ) } } @@ -129,10 +132,7 @@ impl AgentSendError { Some(detail), true, false, - resolution( - AgentErrorResolutionKind::StartNewSession, - Some(AgentErrorResolutionTarget::NewConversation), - ), + None, ), AgentError::BadRequest(msg) if msg.contains("Method not supported") => Self::new( "The selected Agent does not support this operation", @@ -141,10 +141,7 @@ impl AgentSendError { Some(detail), false, false, - resolution( - AgentErrorResolutionKind::CheckAgentVersion, - Some(AgentErrorResolutionTarget::AgentSettings), - ), + None, ), AgentError::BadRequest(msg) if msg.contains("Invalid parameters") => Self::new( "The selected Agent rejected the request parameters", @@ -152,11 +149,8 @@ impl AgentSendError { AgentErrorOwnership::UserAgent, Some(detail), false, - true, - resolution( - AgentErrorResolutionKind::SendFeedback, - Some(AgentErrorResolutionTarget::Feedback), - ), + false, + None, ), AgentError::Timeout(_) => Self::new( "The model provider did not respond in time", @@ -294,6 +288,24 @@ impl AgentSendError { Some(AgentErrorResolutionTarget::AgentSettings), ), ), + AcpError::ProtocolParseError { .. } => Self::new( + "The selected Agent reported a protocol parse error", + AgentErrorCode::UserAgentProtocolParseError, + AgentErrorOwnership::UserAgent, + Some(detail), + false, + false, + None, + ), + AcpError::InvalidRequest { .. } => Self::new( + "The selected Agent reported an invalid protocol request", + AgentErrorCode::UserAgentInvalidRequest, + AgentErrorOwnership::UserAgent, + Some(detail), + false, + false, + None, + ), AcpError::SessionNotFound { .. } => Self::new( "The Agent session was not found", AgentErrorCode::UserAgentSessionNotFound, @@ -301,10 +313,16 @@ impl AgentSendError { Some(detail), true, false, - resolution( - AgentErrorResolutionKind::StartNewSession, - Some(AgentErrorResolutionTarget::NewConversation), - ), + None, + ), + AcpError::ResourceNotFound { .. } => Self::new( + "The selected Agent could not find a required resource", + AgentErrorCode::UserAgentResourceNotFound, + AgentErrorOwnership::UserAgent, + Some(detail), + false, + false, + None, ), AcpError::MethodNotFound { .. } => Self::new( "The selected Agent does not support this operation", @@ -313,10 +331,7 @@ impl AgentSendError { Some(detail), false, false, - resolution( - AgentErrorResolutionKind::CheckAgentVersion, - Some(AgentErrorResolutionTarget::AgentSettings), - ), + None, ), AcpError::InvalidParams { .. } => Self::new( "The selected Agent rejected the request parameters", @@ -324,11 +339,8 @@ impl AgentSendError { AgentErrorOwnership::UserAgent, Some(detail), false, - true, - resolution( - AgentErrorResolutionKind::SendFeedback, - Some(AgentErrorResolutionTarget::Feedback), - ), + false, + None, ), AcpError::NotConnected => Self::new( "AionUI lost its Agent protocol connection", @@ -342,6 +354,15 @@ impl AgentSendError { Some(AgentErrorResolutionTarget::AgentSettings), ), ), + AcpError::OtherProtocolError { .. } => Self::new( + "The selected Agent returned a non-standard protocol error", + AgentErrorCode::UserAgentProtocolError, + AgentErrorOwnership::UserAgent, + Some(detail), + false, + false, + None, + ), AcpError::AgentInternal { .. } => unknown_upstream_error(detail), } } @@ -370,9 +391,9 @@ impl From for AgentSendError { fn classify_acp_error(err: &AcpError) -> AgentSendError { match err { AcpError::AgentInternal { message, code, data } => { - let detail = acp_agent_internal_public_detail(*code); + let default_detail = acp_agent_internal_public_detail(*code); if *code != -32603 { - return unknown_upstream_error(detail); + return unknown_upstream_error(default_detail); } let classified = acp_agent_internal_texts(message, data.as_ref()) @@ -382,11 +403,15 @@ fn classify_acp_error(err: &AcpError) -> AgentSendError { classify_agent_lifecycle(&lower) .or_else(|| classify_provider_text(&lower)) .or_else(|| classify_aionui_state(&lower)) + .map(|classification| (classification, text)) }); match classified { - Some(classification) => classification.into_send_error(detail), - None => unknown_upstream_error(detail), + Some((classification, text)) => { + let detail = acp_agent_internal_detail_for_classification(*code, classification, text); + classification.into_send_error(detail) + } + None => unknown_upstream_error(default_detail), } } _ => AgentSendError::from_acp_non_internal(err, err.to_string()), @@ -397,6 +422,16 @@ fn acp_agent_internal_public_detail(code: i32) -> String { format!("Agent internal error (code {code})") } +fn acp_agent_internal_detail_for_classification(code: i32, classification: ClassifiedError, text: &str) -> String { + if classification.code == AgentErrorCode::UserLlmProviderAwsSsoExpired + && contains_sso_expired_auth_signature(&text.to_ascii_lowercase()) + { + return AWS_SSO_EXPIRED_DETAIL.to_owned(); + } + + acp_agent_internal_public_detail(code) +} + fn acp_agent_internal_texts<'a>(message: &'a str, data: Option<&'a serde_json::Value>) -> Vec<&'a str> { let mut texts = Vec::new(); if let Some(data) = data { @@ -463,9 +498,9 @@ fn unknown_upstream_classification() -> ClassifiedError { code: AgentErrorCode::UnknownUpstreamError, ownership: AgentErrorOwnership::UnknownUpstream, retryable: true, - feedback_recommended: true, - resolution_kind: AgentErrorResolutionKind::SendFeedback, - resolution_target: Some(AgentErrorResolutionTarget::Feedback), + feedback_recommended: false, + resolution_kind: None, + resolution_target: None, } } @@ -504,12 +539,15 @@ fn classify_agent_lifecycle(lower: &str) -> Option { )); } if lower.contains("protocol mismatch") || lower.contains("max reconnect attempts") { - return Some(agent_error( - "The selected Agent protocol is incompatible", - AgentErrorCode::UserAgentProtocolMismatch, - false, - AgentErrorResolutionKind::CheckAgentVersion, - )); + return Some(ClassifiedError { + message: "The selected Agent protocol is incompatible", + code: AgentErrorCode::UserAgentProtocolMismatch, + ownership: AgentErrorOwnership::UserAgent, + retryable: false, + feedback_recommended: false, + resolution_kind: None, + resolution_target: None, + }); } if lower.contains("no previous sessions found") { return Some(agent_session_error( @@ -562,6 +600,15 @@ fn classify_agent_lifecycle(lower: &str) -> Option { } fn classify_provider_text(lower: &str) -> Option { + if contains_sso_expired_auth_signature(lower) { + return Some(provider_error( + "The model provider rejected the request", + AgentErrorCode::UserLlmProviderAwsSsoExpired, + false, + AgentErrorResolutionKind::CheckProviderCredentials, + Some(AgentErrorResolutionTarget::ProviderSettings), + )); + } if contains_any( lower, &[ @@ -830,7 +877,7 @@ fn classify_aionui_state(lower: &str) -> Option { ownership: AgentErrorOwnership::Aionui, retryable: true, feedback_recommended: false, - resolution_kind: AgentErrorResolutionKind::WaitForCurrentResponse, + resolution_kind: Some(AgentErrorResolutionKind::WaitForCurrentResponse), resolution_target: Some(AgentErrorResolutionTarget::NewConversation), }); } @@ -850,7 +897,7 @@ fn agent_error( ownership: AgentErrorOwnership::UserAgent, retryable, feedback_recommended: false, - resolution_kind, + resolution_kind: Some(resolution_kind), resolution_target: Some(AgentErrorResolutionTarget::AgentSettings), } } @@ -862,8 +909,8 @@ fn agent_session_error(message: &'static str, code: AgentErrorCode) -> Classifie ownership: AgentErrorOwnership::UserAgent, retryable: true, feedback_recommended: false, - resolution_kind: AgentErrorResolutionKind::StartNewSession, - resolution_target: Some(AgentErrorResolutionTarget::NewConversation), + resolution_kind: None, + resolution_target: None, } } @@ -880,7 +927,7 @@ fn provider_error( ownership: AgentErrorOwnership::UserLlmProvider, retryable, feedback_recommended: false, - resolution_kind, + resolution_kind: Some(resolution_kind), resolution_target, } } @@ -896,6 +943,10 @@ fn contains_any(haystack: &str, needles: &[&str]) -> bool { needles.iter().any(|needle| haystack.contains(needle)) } +fn contains_sso_expired_auth_signature(lower: &str) -> bool { + contains_any(lower, &["token is expired", "aws sso login", "sso session"]) +} + fn is_openclaw_backend(backend: Option<&str>) -> bool { matches!(backend, Some(value) if value.eq_ignore_ascii_case(OPENCLAW_BACKEND)) } @@ -1064,6 +1115,14 @@ mod tests { assert_eq!(err.stream_error().resolution.map(|value| value.kind), Some(resolution)); } + fn assert_classification_without_resolution(detail: &str, code: AgentErrorCode, ownership: AgentErrorOwnership) { + let err = AgentSendError::from_agent_error(AgentError::bad_gateway(detail)); + assert_eq!(err.code(), Some(code)); + assert_eq!(err.ownership(), Some(ownership)); + assert!(err.stream_error().resolution.is_none()); + assert_eq!(err.stream_error().feedback_recommended, Some(false)); + } + fn assert_acp_classification( err: AcpError, code: AgentErrorCode, @@ -1193,7 +1252,50 @@ mod tests { assert_eq!(err.code(), Some(AgentErrorCode::UnknownUpstreamError)); assert_eq!(err.ownership(), Some(AgentErrorOwnership::UnknownUpstream)); - assert_eq!(err.stream_error().feedback_recommended, Some(true)); + assert_eq!(err.stream_error().feedback_recommended, Some(false)); + assert!(err.stream_error().resolution.is_none()); + } + + #[test] + fn classifies_acp_protocol_errors_without_unknown_upstream_fallback() { + let cases = [ + ( + AcpError::ProtocolParseError { + message: "Parse error".into(), + }, + AgentErrorCode::UserAgentProtocolParseError, + ), + ( + AcpError::InvalidRequest { + message: "Invalid request".into(), + }, + AgentErrorCode::UserAgentInvalidRequest, + ), + ( + AcpError::ResourceNotFound { + resource: Some("file:///missing.txt".into()), + message: "Resource not found".into(), + }, + AgentErrorCode::UserAgentResourceNotFound, + ), + ( + AcpError::OtherProtocolError { + code: -32099, + message: "custom protocol error".into(), + data: None, + }, + AgentErrorCode::UserAgentProtocolError, + ), + ]; + + for (err, code) in cases { + let send_error = AgentSendError::from(err); + assert_eq!(send_error.code(), Some(code)); + assert_eq!(send_error.ownership(), Some(AgentErrorOwnership::UserAgent)); + assert_ne!(send_error.code(), Some(AgentErrorCode::UnknownUpstreamError)); + assert!(send_error.stream_error().resolution.is_none()); + assert_eq!(send_error.stream_error().feedback_recommended, Some(false)); + } } #[test] @@ -1402,6 +1504,28 @@ mod tests { ); } + #[test] + fn classifies_acp_internal_bedrock_sso_expired_as_dedicated_provider_error() { + let err = AgentSendError::from(AcpError::AgentInternal { + message: "Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile.".into(), + code: -32603, + data: Some(json!({"errorKind": "unknown"})), + }); + + let payload = serde_json::to_value(err.stream_error()).expect("serialize stream error"); + assert_eq!(payload["code"], "USER_LLM_PROVIDER_AWS_SSO_EXPIRED"); + assert_eq!(err.ownership(), Some(AgentErrorOwnership::UserLlmProvider)); + assert_eq!( + err.stream_error().resolution.map(|value| value.kind), + Some(AgentErrorResolutionKind::CheckProviderCredentials) + ); + let detail = err.stream_error().detail.as_deref().expect("detail present"); + assert!(detail.contains("Token is expired")); + assert!(detail.contains("aws sso login")); + assert_eq!(err.stream_error().retryable, Some(false)); + assert_eq!(err.stream_error().feedback_recommended, Some(false)); + } + #[test] fn classifies_acp_internal_nested_provider_error_message() { assert_acp_classification( @@ -1473,23 +1597,20 @@ mod tests { #[test] fn classifies_agent_protocol_and_session_failures() { - assert_classification( + assert_classification_without_resolution( "Connection error: protocol mismatch Connection error: Max reconnect attempts (10) reached", AgentErrorCode::UserAgentProtocolMismatch, AgentErrorOwnership::UserAgent, - AgentErrorResolutionKind::CheckAgentVersion, ); - assert_classification( + assert_classification_without_resolution( "Agent internal error (code -32603) {\"details\":\"Session not found\"}", AgentErrorCode::UserAgentSessionNotFound, AgentErrorOwnership::UserAgent, - AgentErrorResolutionKind::StartNewSession, ); - assert_classification( + assert_classification_without_resolution( "Bad gateway: Agent internal error (code -32603) {\"details\":\"No previous sessions found for this project\"}", AgentErrorCode::UserAgentNoPreviousSession, AgentErrorOwnership::UserAgent, - AgentErrorResolutionKind::StartNewSession, ); } @@ -1687,22 +1808,16 @@ mod tests { AgentSendError::from_agent_error(AgentError::bad_request("Invalid parameters: malformed request")); assert_eq!(api_err.code(), Some(AgentErrorCode::UserAgentInvalidParams)); assert_eq!(api_err.stream_error().retryable, Some(false)); - assert_eq!(api_err.stream_error().feedback_recommended, Some(true)); - assert_eq!( - api_err.stream_error().resolution.map(|value| value.kind), - Some(AgentErrorResolutionKind::SendFeedback) - ); + assert_eq!(api_err.stream_error().feedback_recommended, Some(false)); + assert!(api_err.stream_error().resolution.is_none()); let acp_err = AgentSendError::from(AcpError::InvalidParams { message: "malformed request".into(), }); assert_eq!(acp_err.code(), Some(AgentErrorCode::UserAgentInvalidParams)); assert_eq!(acp_err.stream_error().retryable, Some(false)); - assert_eq!(acp_err.stream_error().feedback_recommended, Some(true)); - assert_eq!( - acp_err.stream_error().resolution.map(|value| value.kind), - Some(AgentErrorResolutionKind::SendFeedback) - ); + assert_eq!(acp_err.stream_error().feedback_recommended, Some(false)); + assert!(acp_err.stream_error().resolution.is_none()); } #[test] diff --git a/crates/aionui-ai-agent/src/routes/error_mapping.rs b/crates/aionui-ai-agent/src/routes/error_mapping.rs index c635f7da..aa20d893 100644 --- a/crates/aionui-ai-agent/src/routes/error_mapping.rs +++ b/crates/aionui-ai-agent/src/routes/error_mapping.rs @@ -28,10 +28,14 @@ fn acp_error_to_api_error(err: AcpError) -> ApiError { ApiError::BadGateway(acp_error_public_message(&err)) } AcpError::AuthRequired => ApiError::Unauthorized("Agent requires authentication".into()), + AcpError::ProtocolParseError { .. } => ApiError::BadGateway(acp_error_public_message(&err)), + AcpError::InvalidRequest { .. } => ApiError::BadRequest(acp_error_public_message(&err)), AcpError::SessionNotFound { .. } => ApiError::NotFound(acp_error_public_message(&err)), + AcpError::ResourceNotFound { .. } => ApiError::NotFound(acp_error_public_message(&err)), AcpError::MethodNotFound { .. } => ApiError::BadRequest(acp_error_public_message(&err)), AcpError::InvalidParams { .. } => ApiError::BadRequest(acp_error_public_message(&err)), AcpError::AgentInternal { .. } => ApiError::BadGateway(acp_error_public_message(&err)), + AcpError::OtherProtocolError { .. } => ApiError::BadGateway(acp_error_public_message(&err)), AcpError::NotConnected => ApiError::BadGateway(acp_error_public_message(&err)), AcpError::InitTimeout { .. } => ApiError::BadGateway(acp_error_public_message(&err)), } @@ -43,10 +47,14 @@ fn acp_error_public_message(err: &AcpError) -> String { "Agent process is unavailable.".to_owned() } AcpError::AuthRequired => "Agent requires authentication.".to_owned(), + AcpError::ProtocolParseError { .. } => "Agent returned malformed protocol data.".to_owned(), + AcpError::InvalidRequest { .. } => "Agent rejected an invalid protocol request.".to_owned(), AcpError::SessionNotFound { .. } => "Agent session was not found.".to_owned(), + AcpError::ResourceNotFound { .. } => "Agent resource was not found.".to_owned(), AcpError::MethodNotFound { .. } => "Agent method is not supported.".to_owned(), AcpError::InvalidParams { .. } => "Invalid ACP request parameters.".to_owned(), AcpError::AgentInternal { code, .. } => format!("Agent internal error (code {code})"), + AcpError::OtherProtocolError { code, .. } => format!("Agent protocol error (code {code})"), AcpError::NotConnected => "ACP protocol is not connected.".to_owned(), AcpError::InitTimeout { .. } => "Agent initialization timed out.".to_owned(), } @@ -62,10 +70,29 @@ mod tests { let cases = vec![ (AcpError::SpawnFailed { message: "x".into() }, StatusCode::BAD_GATEWAY), (AcpError::AuthRequired, StatusCode::UNAUTHORIZED), + ( + AcpError::ProtocolParseError { + message: "Parse error".into(), + }, + StatusCode::BAD_GATEWAY, + ), + ( + AcpError::InvalidRequest { + message: "Invalid request".into(), + }, + StatusCode::BAD_REQUEST, + ), ( AcpError::SessionNotFound { session_id: "s".into() }, StatusCode::NOT_FOUND, ), + ( + AcpError::ResourceNotFound { + resource: Some("file:///missing.txt".into()), + message: "Resource not found".into(), + }, + StatusCode::NOT_FOUND, + ), (AcpError::MethodNotFound { method: "m".into() }, StatusCode::BAD_REQUEST), (AcpError::InvalidParams { message: "p".into() }, StatusCode::BAD_REQUEST), ( @@ -76,6 +103,14 @@ mod tests { }, StatusCode::BAD_GATEWAY, ), + ( + AcpError::OtherProtocolError { + code: -32099, + message: "custom error".into(), + data: None, + }, + StatusCode::BAD_GATEWAY, + ), (AcpError::NotConnected, StatusCode::BAD_GATEWAY), (AcpError::InitTimeout { timeout_secs: 30 }, StatusCode::BAD_GATEWAY), ]; diff --git a/crates/aionui-api-types/src/agent_error.rs b/crates/aionui-api-types/src/agent_error.rs index 16a4c804..102ca792 100644 --- a/crates/aionui-api-types/src/agent_error.rs +++ b/crates/aionui-api-types/src/agent_error.rs @@ -31,11 +31,16 @@ pub enum AgentErrorCode { UserAgentAuthRequired, UserAgentSessionNotFound, UserAgentNoPreviousSession, + UserAgentProtocolParseError, + UserAgentInvalidRequest, + UserAgentResourceNotFound, + UserAgentProtocolError, UserAgentCommandNotFound, UserAgentMissingEnv, UserAgentUnsupportedMethod, UserAgentInvalidParams, UserLlmProviderAuthFailed, + UserLlmProviderAwsSsoExpired, UserLlmProviderPermissionDenied, UserLlmProviderBillingRequired, UserLlmProviderConfigError, diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index a8900deb..4c0ffea5 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -3739,7 +3739,7 @@ async fn send_message_persists_error_tip_when_agent_build_fails() { assert_eq!(content["error"]["code"], "UNKNOWN_UPSTREAM_ERROR"); assert_eq!(content["error"]["ownership"], "unknown_upstream"); assert_eq!(content["error"]["retryable"], true); - assert_eq!(content["error"]["feedback_recommended"], true); + assert_eq!(content["error"]["feedback_recommended"], false); assert_eq!(content["error"]["detail"], "ACP init failed: config file is invalid"); assert_eq!( content["content"],