fix(acp): return authe error for acp provider from goose acp server (#11202)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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::<agent_client_protocol::Error>()
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,15 @@ enum AcpUpdate {
|
||||
response_tx: oneshot::Sender<RequestPermissionResponse>,
|
||||
},
|
||||
Complete(StopReason, Option<AcpUsage>),
|
||||
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<E: std::fmt::Debug>(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<Mutex<GooseMode>>,
|
||||
@@ -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<ErrorCode> {
|
||||
error.chain().find_map(|source| {
|
||||
source
|
||||
.downcast_ref::<agent_client_protocol::Error>()
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -157,6 +157,14 @@ impl<T, E: std::fmt::Display> ResultExt<T> for Result<T, E> {
|
||||
}
|
||||
}
|
||||
|
||||
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<agent_client_protocol::Error> {
|
||||
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 {
|
||||
|
||||
@@ -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::<agent_client_protocol::Error>()
|
||||
.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![
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export interface AcpChatSessionActions {
|
||||
promptAttemptId: string
|
||||
): AcpChatSessionSnapshot | undefined;
|
||||
waitForPromptCancellation(sessionId: string, promptAttemptId: string): Promise<void>;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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({
|
||||
</h3>
|
||||
<p className="text-sm">{sessionLoadError}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setView('chat');
|
||||
}}
|
||||
className="px-4 py-2 text-center cursor-pointer text-text-primary border border-border-primary hover:bg-background-secondary rounded-lg transition-all duration-150"
|
||||
>
|
||||
{intl.formatMessage(i18n.goHome)}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => void retrySessionLoad()}>
|
||||
{intl.formatMessage(i18n.retry)}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setView('chat')}>
|
||||
{intl.formatMessage(i18n.goHome)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<void> => {
|
||||
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,
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface UseChatSessionResult {
|
||||
userData: Record<string, unknown>
|
||||
) => Promise<boolean>;
|
||||
stopStreaming: () => void;
|
||||
retrySessionLoad: () => Promise<void>;
|
||||
sessionLoadError?: string;
|
||||
tokenState: TokenState;
|
||||
notifications: Map<string, NotificationEvent[]>;
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Verbindung unterbrochen. Verbindung wird wiederhergestellt…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "Wiederholen"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Erweiterung aktualisiert"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Connection lost. Reconnecting…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "Retry"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Extension Updated"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Conexión perdida. Reconectando…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "Reintentar"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Extensión actualizada"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Connexion perdue. Reconnexion…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "Réessayer"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Extension mise à jour"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "कनेक्शन टूट गया। फिर से कनेक्ट किया जा रहा है…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "पुनः प्रयास करें"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "एक्सटेंशन अपडेट किया गया"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Koneksi terputus. Menghubungkan kembali…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "Coba lagi"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Ekstensi Diperbarui"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Connessione persa. Riconnessione…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "Riprova"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Estensione aggiornata"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "接続が失われました。再接続しています…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "再試行"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "拡張機能を更新しました"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "연결이 끊어졌습니다. 다시 연결하는 중…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "다시 시도"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "익스텐션이 업데이트되었습니다."
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Sambungan terputus. Menyambung semula…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "Cuba semula"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Sambungan Dikemas Kini"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Conexão perdida. Reconectando…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "Tentar novamente"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Extensão atualizada"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Соединение потеряно. Повторное подключение…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "Повторить"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Расширение обновлено"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "连接已断开。正在重新连接…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "重试"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "扩展已更新"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "連線已中斷。正在重新連線…"
|
||||
},
|
||||
"baseChat.retry": {
|
||||
"defaultMessage": "重試"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "擴充功能已更新"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user