From 849b6f2ae84c2f8c0a8d90df3b29fafb1728d759 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Thu, 13 Aug 2026 09:28:59 +0000 Subject: [PATCH] fix(acp): return authe error for acp provider from goose acp server (#11202) --- .../src/conversation/message.rs | 18 +++- crates/goose/src/acp/mod.rs | 29 ++++++ crates/goose/src/acp/provider.rs | 88 +++++++++++++++++-- crates/goose/src/acp/server.rs | 54 +++++++++++- crates/goose/src/agents/agent.rs | 56 ++++++++++-- crates/goose/src/execution/manager.rs | 7 +- crates/goose/src/gateway/handler.rs | 19 +++- .../__tests__/chatSessionController.test.ts | 15 ++++ .../acp/__tests__/chatSessionStore.test.ts | 10 +-- ui/desktop/src/acp/__tests__/errors.test.ts | 11 ++- ui/desktop/src/acp/chatSessionController.ts | 17 ++-- ui/desktop/src/acp/chatSessionStore.ts | 6 +- ui/desktop/src/acp/errors.ts | 11 +++ ui/desktop/src/components/BaseChat.tsx | 22 +++-- ui/desktop/src/components/ChatInput.tsx | 5 +- ui/desktop/src/components/Hub.tsx | 3 + ui/desktop/src/hooks/useChatSession.ts | 18 ++-- ui/desktop/src/hooks/useChatSessionTypes.ts | 1 + ui/desktop/src/i18n/messages/de.json | 3 + ui/desktop/src/i18n/messages/en.json | 3 + ui/desktop/src/i18n/messages/es.json | 3 + ui/desktop/src/i18n/messages/fr.json | 3 + ui/desktop/src/i18n/messages/hi.json | 3 + ui/desktop/src/i18n/messages/id.json | 3 + ui/desktop/src/i18n/messages/it.json | 3 + ui/desktop/src/i18n/messages/ja.json | 3 + ui/desktop/src/i18n/messages/ko.json | 3 + ui/desktop/src/i18n/messages/ms.json | 3 + ui/desktop/src/i18n/messages/pt.json | 3 + ui/desktop/src/i18n/messages/ru.json | 3 + ui/desktop/src/i18n/messages/tr.json | 3 + ui/desktop/src/i18n/messages/vi.json | 3 + ui/desktop/src/i18n/messages/zh-CN.json | 3 + ui/desktop/src/i18n/messages/zh-TW.json | 3 + 34 files changed, 380 insertions(+), 58 deletions(-) diff --git a/crates/goose-provider-types/src/conversation/message.rs b/crates/goose-provider-types/src/conversation/message.rs index 13523f021..996ae6e63 100644 --- a/crates/goose-provider-types/src/conversation/message.rs +++ b/crates/goose-provider-types/src/conversation/message.rs @@ -262,6 +262,7 @@ pub struct SystemNotificationContent { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum MessageErrorKind { + Authentication, ContextLengthExceeded, CreditsExhausted, #[serde(other)] @@ -272,6 +273,7 @@ impl From<&crate::errors::ProviderError> for MessageErrorKind { fn from(err: &crate::errors::ProviderError) -> Self { use crate::errors::ProviderError; match err { + ProviderError::Authentication(_) => MessageErrorKind::Authentication, ProviderError::ContextLengthExceeded(_) => MessageErrorKind::ContextLengthExceeded, ProviderError::CreditsExhausted { .. } => MessageErrorKind::CreditsExhausted, _ => MessageErrorKind::Other, @@ -1320,9 +1322,10 @@ pub struct TokenState { #[cfg(test)] mod tests { use crate::conversation::message::{ - ActionRequiredData, Message, MessageContentBlock, MessageMetadata, ProviderMetadata, - ToolResponse, + ActionRequiredData, Message, MessageContentBlock, MessageErrorKind, MessageMetadata, + ProviderMetadata, ToolResponse, }; + use crate::errors::ProviderError; use base64::Engine; use rmcp::model::{ Annotations, CallToolResult, ElicitationAction, ErrorCode, ErrorData, ImageContent, @@ -1332,6 +1335,17 @@ mod tests { use rmcp::object; use serde_json::Value; + #[test] + fn provider_authentication_error_has_authentication_kind() { + let message = Message::from_provider_error(&ProviderError::Authentication( + "Authentication required".to_string(), + )); + + assert_eq!(message.error_kind(), Some(MessageErrorKind::Authentication)); + assert!(message.is_user_visible()); + assert!(!message.is_agent_visible()); + } + #[test] fn test_sanitize_with_text() { let malicious = "Hello\u{E0041}\u{E0042}\u{E0043}world"; // Invisible "ABC" diff --git a/crates/goose/src/acp/mod.rs b/crates/goose/src/acp/mod.rs index bba5e9dea..1e4283062 100644 --- a/crates/goose/src/acp/mod.rs +++ b/crates/goose/src/acp/mod.rs @@ -14,3 +14,32 @@ pub use goose_sdk_types::{custom_notifications, custom_requests}; pub use provider::{ resolve_extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, ACP_CURRENT_MODEL, }; + +pub(crate) fn is_auth_required(error: &anyhow::Error) -> bool { + error.chain().any(|source| { + source + .downcast_ref::() + .is_some_and(|error| { + error.code == agent_client_protocol::schema::v1::ErrorCode::AuthRequired + }) + }) +} + +#[cfg(test)] +mod tests { + use super::is_auth_required; + + #[test] + fn identifies_typed_auth_required_errors() { + let error = anyhow::Error::new(agent_client_protocol::Error::auth_required()); + + assert!(is_auth_required(&error)); + } + + #[test] + fn does_not_classify_other_acp_errors_as_authentication() { + let error = anyhow::Error::new(agent_client_protocol::Error::internal_error()); + + assert!(!is_auth_required(&error)); + } +} diff --git a/crates/goose/src/acp/provider.rs b/crates/goose/src/acp/provider.rs index 0f1c98586..d884d7d71 100644 --- a/crates/goose/src/acp/provider.rs +++ b/crates/goose/src/acp/provider.rs @@ -126,7 +126,15 @@ enum AcpUpdate { response_tx: oneshot::Sender, }, Complete(StopReason, Option), - Error(String), + Error(agent_client_protocol::Error), +} + +fn provider_error_from_acp(error: agent_client_protocol::Error) -> ProviderError { + if error.code == agent_client_protocol::schema::v1::ErrorCode::AuthRequired { + ProviderError::Authentication(error.to_string()) + } else { + ProviderError::RequestFailed(error.to_string()) + } } /// Per-tool-call buffer for accumulating ACP ToolCallUpdate fields across @@ -723,7 +731,7 @@ impl Provider for AcpProvider { break; } AcpUpdate::Error(e) => { - Err(ProviderError::RequestFailed(e))?; + Err(provider_error_from_acp(e))?; } } } @@ -1108,6 +1116,11 @@ fn log_undelivered(result: Result<(), E>, method: &str) { } } +fn acp_method_error(method: &str, error: agent_client_protocol::Error) -> anyhow::Error { + let message = format!("ACP {method} failed: {error}"); + anyhow::Error::new(error).context(message) +} + async fn handle_requests( config: AcpProviderConfig, goose_mode: Arc>, @@ -1164,10 +1177,7 @@ async fn handle_requests( .await?; apply_session_mode(&config, &goose_mode, &cx, session).await } - Err(err) => Err(anyhow::anyhow!( - "ACP {} failed: {err}", - AGENT_METHOD_NAMES.session_new - )), + Err(error) => Err(acp_method_error(AGENT_METHOD_NAMES.session_new, error)), }; log_undelivered(response_tx.send(result), AGENT_METHOD_NAMES.session_new); } @@ -1273,7 +1283,7 @@ async fn handle_requests( } Err(e) => { log_undelivered( - response_tx.try_send(AcpUpdate::Error(e.to_string())), + response_tx.try_send(AcpUpdate::Error(e)), AGENT_METHOD_NAMES.session_prompt, ); } @@ -1800,7 +1810,7 @@ mod tests { use super::*; use crate::agents::extension::Envs; use agent_client_protocol::schema::v1::{ - SessionConfigSelectOption, SessionMode, SessionModeId, + ErrorCode, SessionConfigSelectOption, SessionMode, SessionModeId, }; use test_case::test_case; @@ -1812,6 +1822,68 @@ mod tests { } } + fn acp_error_code(error: &anyhow::Error) -> Option { + error.chain().find_map(|source| { + source + .downcast_ref::() + .map(|error| error.code) + }) + } + + #[test] + fn session_new_error_preserves_auth_required_code() { + let error = acp_method_error( + AGENT_METHOD_NAMES.session_new, + agent_client_protocol::Error::auth_required(), + ); + + assert_eq!( + error.to_string(), + "ACP session/new failed: Authentication required" + ); + assert_eq!(acp_error_code(&error), Some(ErrorCode::AuthRequired)); + } + + #[test] + fn session_new_error_preserves_non_authentication_code() { + let error = acp_method_error( + AGENT_METHOD_NAMES.session_new, + agent_client_protocol::Error::internal_error(), + ); + + assert_eq!(acp_error_code(&error), Some(ErrorCode::InternalError)); + } + + #[tokio::test] + async fn prompt_error_update_preserves_auth_required_code() { + let (tx, mut rx) = mpsc::channel(1); + tx.send(AcpUpdate::Error( + agent_client_protocol::Error::auth_required().data("sign in"), + )) + .await + .unwrap(); + + let AcpUpdate::Error(error) = rx.recv().await.unwrap() else { + panic!("expected ACP error update"); + }; + assert_eq!(error.code, ErrorCode::AuthRequired); + assert_eq!(error.data, Some(serde_json::json!("sign in"))); + } + + #[test] + fn prompt_auth_error_maps_to_provider_authentication() { + let error = provider_error_from_acp(agent_client_protocol::Error::auth_required()); + + assert!(matches!(error, ProviderError::Authentication(_))); + } + + #[test] + fn prompt_internal_error_remains_request_failed() { + let error = provider_error_from_acp(agent_client_protocol::Error::internal_error()); + + assert!(matches!(error, ProviderError::RequestFailed(_))); + } + fn test_provider() -> (AcpProvider, ModelConfig) { test_provider_with_tx(None) } diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 6bb3f2597..50a2ca887 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -157,6 +157,14 @@ impl ResultExt for Result { } } +fn agent_creation_error(error: anyhow::Error, context: &str) -> agent_client_protocol::Error { + if crate::acp::is_auth_required(&error) { + agent_client_protocol::Error::auth_required() + } else { + agent_client_protocol::Error::internal_error().data(format!("{context}: {error}")) + } +} + pub(super) const DEFAULT_PROVIDER_ID: &str = "goose"; pub(super) const DEFAULT_PROVIDER_LABEL: &str = "Goose (Default)"; const PROVIDER_CONFIG_STATUS_CHECK_CONCURRENCY: usize = 16; @@ -760,7 +768,7 @@ impl GooseAcpAgent { }, ) .await - .internal_err_ctx("Failed to create agent") + .map_err(|error| agent_creation_error(error, "Failed to create agent")) } fn initial_session_extensions( @@ -1337,6 +1345,11 @@ fn prompt_error_from_message_content( content_item: &MessageContent, ) -> Option { match content_item { + MessageContent::Error(error) + if error.kind == crate::conversation::message::MessageErrorKind::Authentication => + { + Some(agent_client_protocol::Error::auth_required()) + } MessageContent::SystemNotification(notification) if notification.notification_type == SystemNotificationType::CreditsExhausted => { @@ -2358,6 +2371,30 @@ mod tests { use tempfile::NamedTempFile; use test_case::test_case; + #[test] + fn agent_creation_auth_error_maps_to_auth_required() { + let error = anyhow::Error::new(agent_client_protocol::Error::auth_required()); + + let error = agent_creation_error(error, "Failed to create agent"); + + assert_eq!( + error.code, + agent_client_protocol::schema::v1::ErrorCode::AuthRequired + ); + } + + #[test] + fn agent_creation_non_auth_error_remains_internal() { + let error = anyhow::Error::new(agent_client_protocol::Error::internal_error()); + + let error = agent_creation_error(error, "Failed to create agent"); + + assert_eq!( + error.code, + agent_client_protocol::schema::v1::ErrorCode::InternalError + ); + } + fn config_with_yaml(yaml: &str) -> (Config, NamedTempFile, NamedTempFile) { let config_file = NamedTempFile::new().unwrap(); let secrets_file = NamedTempFile::new().unwrap(); @@ -2607,6 +2644,21 @@ print(\"hello, world\") ); } + #[test] + fn test_authentication_message_maps_to_auth_required() { + let content = MessageContent::error( + crate::conversation::message::MessageErrorKind::Authentication, + "Authentication required", + ); + + let error = prompt_error_from_message_content(&content).expect("expected prompt error"); + + assert_eq!( + error.code, + agent_client_protocol::schema::v1::ErrorCode::AuthRequired + ); + } + #[test] fn test_non_credit_system_notification_does_not_map_to_prompt_error() { let content = MessageContent::SystemNotification(SystemNotificationContent { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index af8a4154c..4a9576ada 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -86,6 +86,11 @@ const EMPTY_TURN_MESSAGE: &str = "The model returned an empty response. Please resend your message to continue."; const DEFAULT_FRONTEND_INSTRUCTIONS: &str = "The following tools are provided directly by the frontend and will be executed by the frontend when called."; +fn provider_creation_error(error: anyhow::Error, context: impl fmt::Display) -> anyhow::Error { + let message = format!("{context}: {error}"); + error.context(message) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ToolCategory { Shell, @@ -3045,6 +3050,21 @@ impl Agent { exit_chat = true; break; } + Err(ref provider_err @ ProviderError::Authentication(_)) => { + provider_errored = true; + #[cfg(feature = "telemetry")] + crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); + error!("Error: {}", provider_err); + let message = persist_and_push_message_with_id( + &session_manager, + &session_config.id, + &mut conversation, + Message::from_provider_error(provider_err), + ) + .await?; + yield AgentEvent::Message(message); + break; + } Err(ref provider_err @ ProviderError::NetworkError(_)) => { provider_errored = true; #[cfg(feature = "telemetry")] @@ -3478,7 +3498,7 @@ impl Agent { session.working_dir.clone(), ) .await - .map_err(|e| anyhow!("Could not create provider: {}", e))?; + .map_err(|error| provider_creation_error(error, "Could not create provider"))?; self.update_provider(provider, model_config, session_id) .await?; @@ -3555,7 +3575,7 @@ impl Agent { session.working_dir.clone(), ) .await - .map_err(|e| anyhow!("Could not create provider: {}", e))?; + .map_err(|error| provider_creation_error(error, "Could not create provider"))?; (p, model_config, false) } else { let fallback_provider_name = config @@ -3592,12 +3612,12 @@ impl Agent { session.working_dir.clone(), ) .await - .map_err(|e| { - anyhow!( - "Could not create provider '{}' or fallback '{}': {}", - provider_name, - fallback_provider_name, - e + .map_err(|error| { + provider_creation_error( + error, + format!( + "Could not create provider '{provider_name}' or fallback '{fallback_provider_name}'" + ), ) })?; @@ -3957,6 +3977,26 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tempfile::TempDir; + #[test] + fn provider_creation_context_preserves_acp_error_code() { + let source = anyhow::Error::new(agent_client_protocol::Error::auth_required()) + .context("ACP session/new failed: Authentication required"); + + let error = provider_creation_error(source, "Could not create provider"); + + assert_eq!( + error.to_string(), + "Could not create provider: ACP session/new failed: Authentication required" + ); + assert!(error.chain().any(|source| { + source + .downcast_ref::() + .is_some_and(|error| { + error.code == agent_client_protocol::schema::v1::ErrorCode::AuthRequired + }) + })); + } + #[test] fn provider_session_id_comes_from_latest_inference() { let messages = vec![ diff --git a/crates/goose/src/execution/manager.rs b/crates/goose/src/execution/manager.rs index bac39df5e..96d929861 100644 --- a/crates/goose/src/execution/manager.rs +++ b/crates/goose/src/execution/manager.rs @@ -214,11 +214,14 @@ impl AgentManager { "Restoring evicted session {} (provider: {:?})", session_id, session.provider_name ); - if let Err(e) = agent.restore_provider_from_session(&session).await { + if let Err(error) = agent.restore_provider_from_session(&session).await { + if crate::acp::is_auth_required(&error) { + return Err(error); + } tracing::warn!( "Failed to restore provider for session {}: {}", session_id, - e + error ); } } diff --git a/crates/goose/src/gateway/handler.rs b/crates/goose/src/gateway/handler.rs index 7854dee1b..0d88a600f 100644 --- a/crates/goose/src/gateway/handler.rs +++ b/crates/goose/src/gateway/handler.rs @@ -422,10 +422,25 @@ impl GatewayHandler { .await?; } - let agent = self + let agent = match self .agent_manager .get_or_create_agent(session_id.to_string()) - .await?; + .await + { + Ok(agent) => agent, + Err(error) if crate::acp::is_auth_required(&error) => { + self.gateway + .send_message( + &message.user, + OutgoingMessage::Text { + body: format!("⚠️ Failed to configure provider: {error}"), + }, + ) + .await?; + return Ok(()); + } + Err(error) => return Err(error), + }; // Re-read the session after sync so restore picks up the new values. let session = self diff --git a/ui/desktop/src/acp/__tests__/chatSessionController.test.ts b/ui/desktop/src/acp/__tests__/chatSessionController.test.ts index 86f761274..6bbf5a529 100644 --- a/ui/desktop/src/acp/__tests__/chatSessionController.test.ts +++ b/ui/desktop/src/acp/__tests__/chatSessionController.test.ts @@ -169,6 +169,20 @@ describe('acpChatSessionController.loadSession', () => { ); }); + it('retries a failed cached session load', async () => { + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue({ + ...snapshotWithActivePrompt(null), + session: loadedSession(), + sessionLoadError: 'Sign in to your provider, then try again.', + }); + vi.mocked(isAcpSessionLoadInFlight).mockReturnValue(false); + + await acpChatSessionController.loadSession(SESSION_ID); + + expect(acpChatSessionActions.startSessionLoad).toHaveBeenCalledWith(SESSION_ID); + expect(acpLoadSession).toHaveBeenCalledWith(SESSION_ID); + }); + it('restores a cached session from the server', async () => { vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue({ ...snapshotWithActivePrompt(null), @@ -252,6 +266,7 @@ describe('acpChatSessionController.submitMessage', () => { expect(acpChatSessionActions.startPromptAttempt).not.toHaveBeenCalled(); expect(acpPromptSession).not.toHaveBeenCalled(); }); + }); describe('acpChatSessionController.updateMessage', () => { diff --git a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts index 431649bd4..c3fb846c6 100644 --- a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts +++ b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts @@ -242,13 +242,9 @@ describe('acpChatSessionStore', () => { acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-a'); acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-b'); - expect( - acpChatSessionActions.finishPromptAttemptIfCurrent( - currentSessionId, - 'attempt-a', - 'late error' - ) - ).toBe(false); + expect(acpChatSessionActions.finishPromptAttemptIfCurrent(currentSessionId, 'attempt-a')).toBe( + false + ); expect(acpChatSessionStore.getSnapshot(currentSessionId)).toMatchObject({ activePromptAttemptId: 'attempt-b', diff --git a/ui/desktop/src/acp/__tests__/errors.test.ts b/ui/desktop/src/acp/__tests__/errors.test.ts index 31c9b3b42..74dc4ca0d 100644 --- a/ui/desktop/src/acp/__tests__/errors.test.ts +++ b/ui/desktop/src/acp/__tests__/errors.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { parseAcpCreditsExhaustedError } from '../errors'; +import { RequestError } from '@agentclientprotocol/sdk'; +import { formatAcpError, parseAcpCreditsExhaustedError } from '../errors'; + +describe('formatAcpError', () => { + it('explains how to recover from an authentication error', () => { + expect(formatAcpError(RequestError.authRequired())).toBe( + 'Sign in to your provider, then try again.' + ); + }); +}); describe('parseAcpCreditsExhaustedError', () => { it('parses structured ACP credits exhausted errors', () => { diff --git a/ui/desktop/src/acp/chatSessionController.ts b/ui/desktop/src/acp/chatSessionController.ts index b80f1aa8c..394654577 100644 --- a/ui/desktop/src/acp/chatSessionController.ts +++ b/ui/desktop/src/acp/chatSessionController.ts @@ -3,7 +3,6 @@ import type { GooseExtension } from '@aaif/goose-sdk'; import { AppEvents } from '../constants/events'; import { ChatState } from '../types/chatState'; import type { Session } from '../types/session'; -import { errorMessage } from '../utils/conversionUtils'; import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; import { createUserMessage, @@ -17,7 +16,11 @@ import { type AcpChatSessionSnapshot, } from './chatSessionStore'; import { cancelAcpElicitationRequestsForSession } from './elicitationRequests'; -import { parseAcpCreditsExhaustedError, type AcpCreditsExhaustedError } from './errors'; +import { + formatAcpError, + parseAcpCreditsExhaustedError, + type AcpCreditsExhaustedError, +} from './errors'; import { cancelAcpPermissionRequestsForSession } from './permissionRequests'; import { acpCancelPrompt, acpPromptSession } from './prompt'; import { @@ -128,7 +131,7 @@ async function createSession( async function loadSession(sessionId: string, options: AcpLoadSessionOptions = {}): Promise { const cached = acpChatSessionStore.getSnapshot(sessionId); - if (cached?.session) { + if (cached?.session && !cached.sessionLoadError) { window.dispatchEvent( new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { sessionId } }) ); @@ -162,7 +165,7 @@ async function loadSessionFromServer( options.onSessionLoaded?.(); } catch (error) { console.error('Failed to load ACP session:', error); - acpChatSessionActions.failSessionLoad(sessionId, errorMessage(error)); + acpChatSessionActions.failSessionLoad(sessionId, formatAcpError(error)); } } @@ -211,10 +214,8 @@ async function submitMessage( return; } - const submitError = 'Submit error: ' + errorMessage(error); - if ( - acpChatSessionActions.finishPromptAttemptIfCurrent(sessionId, promptAttemptId, submitError) - ) { + const submitError = formatAcpError(error); + if (acpChatSessionActions.finishPromptAttemptIfCurrent(sessionId, promptAttemptId)) { void options.onFinish(submitError); } } diff --git a/ui/desktop/src/acp/chatSessionStore.ts b/ui/desktop/src/acp/chatSessionStore.ts index 19fa8dcfb..021b86691 100644 --- a/ui/desktop/src/acp/chatSessionStore.ts +++ b/ui/desktop/src/acp/chatSessionStore.ts @@ -104,7 +104,7 @@ export interface AcpChatSessionActions { promptAttemptId: string ): AcpChatSessionSnapshot | undefined; waitForPromptCancellation(sessionId: string, promptAttemptId: string): Promise; - finishPromptAttemptIfCurrent(sessionId: string, promptAttemptId: string, error?: string): boolean; + finishPromptAttemptIfCurrent(sessionId: string, promptAttemptId: string): boolean; clearActivePromptAttempt(sessionId: string): AcpChatSessionSnapshot | undefined; isCurrentPromptAttempt(sessionId: string, promptAttemptId: string): boolean; } @@ -403,8 +403,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { const finishPromptAttemptIfCurrent: AcpChatSessionActions['finishPromptAttemptIfCurrent'] = ( sessionId, - promptAttemptId, - error + promptAttemptId ) => { const entry = sessionsById.get(sessionId); if (!entry || entry.activePromptAttemptId !== promptAttemptId) { @@ -419,7 +418,6 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { discardPendingLocalSteerMessages(entry); entry.progressMessage = undefined; entry.chatState = ChatState.Idle; - entry.sessionLoadError = error; notify(sessionId, entry); return true; }; diff --git a/ui/desktop/src/acp/errors.ts b/ui/desktop/src/acp/errors.ts index 16dd95f3a..67ab0ec77 100644 --- a/ui/desktop/src/acp/errors.ts +++ b/ui/desktop/src/acp/errors.ts @@ -1,9 +1,13 @@ +import { RequestError } from '@agentclientprotocol/sdk'; +import { errorMessage } from '../utils/conversionUtils'; + export interface AcpCreditsExhaustedError { message: string; url?: string; } const CREDITS_EXHAUSTED_REASON = 'credits_exhausted'; +const AUTH_REQUIRED_CODE = -32000; // Kept in sync with RECIPE_PARAMS_CANCELLED_REASON in crates/goose/src/acp/server/recipe.rs. const RECIPE_PARAMS_CANCELLED_REASON = 'recipe_params_cancelled'; @@ -42,6 +46,13 @@ export function parseAcpCreditsExhaustedError(error: unknown): AcpCreditsExhaust }; } +export function formatAcpError(error: unknown): string { + if (error instanceof RequestError && error.code === AUTH_REQUIRED_CODE) { + return 'Sign in to your provider, then try again.'; + } + return errorMessage(error); +} + interface AcpJsonRpcError { message: string; data: Record; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 7bebcac2e..4073d4dfc 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -8,6 +8,7 @@ import ProgressiveMessageList from './ProgressiveMessageList'; import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ChatInputCard } from './ChatInputCard'; +import { Button } from './ui/button'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import { useFileDrop } from '../hooks/useFileDrop'; import { ChatState } from '../types/chatState'; @@ -45,6 +46,10 @@ const i18n = defineMessages({ id: 'baseChat.goHome', defaultMessage: 'Go home', }, + retry: { + id: 'baseChat.retry', + defaultMessage: 'Retry', + }, reconnecting: { id: 'baseChat.reconnecting', defaultMessage: 'Connection lost. Reconnecting…', @@ -106,6 +111,7 @@ export default function BaseChat({ onSteerQueuedMessage, submitElicitationResponse, stopStreaming, + retrySessionLoad, sessionLoadError, tokenState, notifications: toolCallNotifications, @@ -384,14 +390,14 @@ export default function BaseChat({

{sessionLoadError}

- +
+ + +
diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 1e4979600..b3df9387d 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1118,7 +1118,9 @@ export default function ChatInput({ setLastInterruption(null); } - clearInputState(); + if (sessionId !== null) { + clearInputState(); + } setHistoryIndex(-1); setSavedInput(''); setIsInGlobalHistory(false); @@ -1133,6 +1135,7 @@ export default function ChatInput({ handleSubmit, lastInterruption, clearInputState, + sessionId, ] ); diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 7deca289f..873af656c 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -25,6 +25,8 @@ import { selectNextChatExtensions, type NextChatExtensionDraft, } from '../utils/nextChatExtensions'; +import { formatAcpError } from '../acp/errors'; +import { toastError } from '../toasts'; const i18n = defineMessages({ goodMorning: { id: 'hub.goodMorning', defaultMessage: 'Good morning' }, @@ -116,6 +118,7 @@ export default function Hub({ }); } catch (error) { console.error('Failed to create session:', error); + toastError({ title: "Couldn't start chat", msg: formatAcpError(error) }); setIsCreatingSession(false); } }; diff --git a/ui/desktop/src/hooks/useChatSession.ts b/ui/desktop/src/hooks/useChatSession.ts index 6cb7783b5..3659dbd70 100644 --- a/ui/desktop/src/hooks/useChatSession.ts +++ b/ui/desktop/src/hooks/useChatSession.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { defineMessages, useIntl } from '../i18n'; import { AppEvents } from '../constants/events'; +import { toastError } from '../toasts'; import { ChatState } from '../types/chatState'; import type { TokenState } from '../types/chat'; @@ -108,7 +109,9 @@ export function useChatSession({ const onFinish = useCallback( async (error?: string): Promise => { - if (!error) { + if (error) { + toastError({ title: "Couldn't send message", msg: error }); + } else { try { const [notificationsEnabled, anyWindowFocused] = await Promise.all([ window.electron.getSetting('enableNotifications'), @@ -143,12 +146,15 @@ export function useChatSession({ [getCurrentSnapshot, onFinish, sessionId] ); + const retrySessionLoad = useCallback( + () => acpChatSessionController.loadSession(sessionId, { onSessionLoaded }), + [sessionId, onSessionLoaded] + ); + // Load session on mount or sessionId change useEffect(() => { - if (!sessionId) return; - - void acpChatSessionController.loadSession(sessionId, { onSessionLoaded }); - }, [sessionId, onSessionLoaded]); + void retrySessionLoad(); + }, [retrySessionLoad]); const handleSubmit = useCallback( async (input: UserInput) => { @@ -302,7 +308,6 @@ export function useChatSession({ } catch (error) { const errorMsg = errorMessage(error); console.error('Failed to edit message:', error); - const { toastError } = await import('../toasts'); toastError({ title: 'Failed to edit message', msg: errorMsg, @@ -344,6 +349,7 @@ export function useChatSession({ onSteerQueuedMessage, submitElicitationResponse, stopStreaming, + retrySessionLoad, tokenState, notifications: notificationsMap, pauseQueueOnStop: false, diff --git a/ui/desktop/src/hooks/useChatSessionTypes.ts b/ui/desktop/src/hooks/useChatSessionTypes.ts index c042986ed..ccd5fbad9 100644 --- a/ui/desktop/src/hooks/useChatSessionTypes.ts +++ b/ui/desktop/src/hooks/useChatSessionTypes.ts @@ -22,6 +22,7 @@ export interface UseChatSessionResult { userData: Record ) => Promise; stopStreaming: () => void; + retrySessionLoad: () => Promise; sessionLoadError?: string; tokenState: TokenState; notifications: Map; diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index 1394f52d0..594a301b5 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Verbindung unterbrochen. Verbindung wird wiederhergestellt…" }, + "baseChat.retry": { + "defaultMessage": "Wiederholen" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Erweiterung aktualisiert" }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 97dccaec4..6159d33dc 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Connection lost. Reconnecting…" }, + "baseChat.retry": { + "defaultMessage": "Retry" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Extension Updated" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index a36515f56..357c2e5b2 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Conexión perdida. Reconectando…" }, + "baseChat.retry": { + "defaultMessage": "Reintentar" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Extensión actualizada" }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index f9b34b292..62f4847ba 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Connexion perdue. Reconnexion…" }, + "baseChat.retry": { + "defaultMessage": "Réessayer" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Extension mise à jour" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 53f8843f5..16da17425 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "कनेक्शन टूट गया। फिर से कनेक्ट किया जा रहा है…" }, + "baseChat.retry": { + "defaultMessage": "पुनः प्रयास करें" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "एक्सटेंशन अपडेट किया गया" }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index 171c459ba..801b24d8b 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Koneksi terputus. Menghubungkan kembali…" }, + "baseChat.retry": { + "defaultMessage": "Coba lagi" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Ekstensi Diperbarui" }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index dc9ef517f..438d25d56 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Connessione persa. Riconnessione…" }, + "baseChat.retry": { + "defaultMessage": "Riprova" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Estensione aggiornata" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index c6ace5135..9ea61dc08 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "接続が失われました。再接続しています…" }, + "baseChat.retry": { + "defaultMessage": "再試行" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "拡張機能を更新しました" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index 8ae95337d..a97e65788 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "연결이 끊어졌습니다. 다시 연결하는 중…" }, + "baseChat.retry": { + "defaultMessage": "다시 시도" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "익스텐션이 업데이트되었습니다." }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index 5f5034466..ce7f3bd9c 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Sambungan terputus. Menyambung semula…" }, + "baseChat.retry": { + "defaultMessage": "Cuba semula" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Sambungan Dikemas Kini" }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index 99080d278..bb0a2b735 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Conexão perdida. Reconectando…" }, + "baseChat.retry": { + "defaultMessage": "Tentar novamente" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Extensão atualizada" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 7314709eb..50c2a4e75 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Соединение потеряно. Повторное подключение…" }, + "baseChat.retry": { + "defaultMessage": "Повторить" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Расширение обновлено" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index 2a8ea5731..fbe9938f2 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Bağlantı kesildi. Yeniden bağlanılıyor…" }, + "baseChat.retry": { + "defaultMessage": "Yeniden dene" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Uzantı Güncellendi" }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index ccdf799d2..bb4da665c 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "Mất kết nối. Đang kết nối lại…" }, + "baseChat.retry": { + "defaultMessage": "Thử lại" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "Đã cập nhật tiện ích mở rộng" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index a21fd2a47..54ad5d5dc 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "连接已断开。正在重新连接…" }, + "baseChat.retry": { + "defaultMessage": "重试" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "扩展已更新" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index edd14fc6e..c9492db50 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -134,6 +134,9 @@ "baseChat.reconnecting": { "defaultMessage": "連線已中斷。正在重新連線…" }, + "baseChat.retry": { + "defaultMessage": "重試" + }, "bottomMenuExtensionSelection.extensionUpdated": { "defaultMessage": "擴充功能已更新" },