diff --git a/crates/agent-gui/src-tauri/src/services/gateway/chat.rs b/crates/agent-gui/src-tauri/src/services/gateway/chat.rs index 8211aa9e4..61c91a2fa 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/chat.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/chat.rs @@ -398,18 +398,26 @@ impl GatewayController { &self, input: GatewayChatQueueEventInput, ) -> Result<(), String> { - self.send_agent_envelope(proto::AgentEnvelope { - request_id: format!("chat-queue-event-{}", Uuid::new_v4()), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::ChatQueueEvent( - proto::ChatQueueEvent { - conversation_id: input.conversation_id, - snapshot_json: input.snapshot_json, - revision: input.revision, - }, - )), - }) - .await + let result = self + .send_agent_envelope(build_chat_queue_event_envelope(input)) + .await; + ignore_gateway_outbound_offline(result) + } +} + +pub(crate) fn build_chat_queue_event_envelope( + input: GatewayChatQueueEventInput, +) -> proto::AgentEnvelope { + proto::AgentEnvelope { + request_id: format!("chat-queue-event-{}", Uuid::new_v4()), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::ChatQueueEvent( + proto::ChatQueueEvent { + conversation_id: input.conversation_id, + snapshot_json: input.snapshot_json, + revision: input.revision, + }, + )), } } diff --git a/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs b/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs index 53f8c86e8..a5f3e898a 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs @@ -727,11 +727,7 @@ impl GatewayController { .map(chat_run_report_from_entry) .collect(), ); - match self.send_agent_envelope(envelope).await { - Ok(()) => Ok(()), - Err(error) if error.contains("outbound stream is offline") => Ok(()), - Err(error) => Err(error), - } + ignore_gateway_outbound_offline(self.send_agent_envelope(envelope).await) } pub fn release_chat_request_lease( diff --git a/crates/agent-gui/src-tauri/src/services/gateway/connection.rs b/crates/agent-gui/src-tauri/src/services/gateway/connection.rs index 429b9dce9..32d37de9e 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/connection.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/connection.rs @@ -628,7 +628,7 @@ impl GatewayController { .lock() .map_err(|_| "gateway outbound sender lock poisoned".to_string())? .clone() - .ok_or_else(|| "gateway outbound stream is offline".to_string()) + .ok_or_else(|| GATEWAY_OUTBOUND_OFFLINE_ERROR.to_string()) } pub(crate) fn current_outbound_control_sender(&self) -> Result { @@ -879,6 +879,18 @@ pub(crate) fn is_remote_configured(config: &RemoteSettingsPayload) -> bool { !config.gateway_url.trim().is_empty() && !config.token.trim().is_empty() } +pub(crate) const GATEWAY_OUTBOUND_OFFLINE_ERROR: &str = "gateway outbound stream is offline"; + +/// Best-effort publishes (queue snapshots, runtime status) have nothing to +/// deliver while the Gateway is disabled or disconnected; that is the normal +/// state, not a failure. Real send errors still propagate. +pub(crate) fn ignore_gateway_outbound_offline(result: Result<(), String>) -> Result<(), String> { + match result { + Err(error) if error == GATEWAY_OUTBOUND_OFFLINE_ERROR => Ok(()), + other => other, + } +} + pub(crate) fn effective_agent_id(config: &RemoteSettingsPayload) -> Result { let agent_id = config.agent_id.trim(); if agent_id.is_empty() { diff --git a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs index fb91abc14..18cb1f9c8 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs @@ -1,13 +1,14 @@ use super::{ - attach_stt_secret_sync, build_gateway_runtime_status_envelope, + attach_stt_secret_sync, build_chat_queue_event_envelope, build_gateway_runtime_status_envelope, build_local_settings_update_event_payload, effective_agent_id, gateway_connection_needs_restart, gateway_connection_stale_after, gateway_reconnect_backoff, - history_share_resolve_error_code, is_chat_runtime_wake_request_id, - merge_settings_sync_snapshot, merge_settings_update_into_snapshot, proto, - removed_workspace_project_ids, required_terminal_project_path_key, set_disconnected_status, + history_share_resolve_error_code, ignore_gateway_outbound_offline, + is_chat_runtime_wake_request_id, merge_settings_sync_snapshot, + merge_settings_update_into_snapshot, proto, removed_workspace_project_ids, + required_terminal_project_path_key, set_disconnected_status, GatewayChatQueueEventInput, GatewayChatRequestEvent, GatewayController, GatewayStatusSnapshot, RemoteChatInboxRecord, - GATEWAY_CHAT_LEASE_MS, GATEWAY_CHAT_RUNNING_LEASE_MS, GATEWAY_RECONNECT_MAX, - GATEWAY_RECONNECT_MIN, GATEWAY_RECONNECT_STABLE_AFTER, + GATEWAY_CHAT_LEASE_MS, GATEWAY_CHAT_RUNNING_LEASE_MS, GATEWAY_OUTBOUND_OFFLINE_ERROR, + GATEWAY_RECONNECT_MAX, GATEWAY_RECONNECT_MIN, GATEWAY_RECONNECT_STABLE_AFTER, GATEWAY_RUNTIME_STATUS_REPUBLISH_MAX_AGE, GATEWAY_WEBVIEW_REPORT_FRESH_WINDOW, }; use crate::commands::settings::RemoteSettingsPayload; @@ -904,3 +905,45 @@ fn stalled_webview_status_refreshes_running_ledger_only_inside_grace_window() { ) ); } + +#[test] +fn gateway_offline_is_ignored_for_best_effort_publishes() { + assert_eq!( + ignore_gateway_outbound_offline(Err(GATEWAY_OUTBOUND_OFFLINE_ERROR.to_string())), + Ok(()) + ); + assert_eq!(ignore_gateway_outbound_offline(Ok(())), Ok(())); +} + +#[test] +fn gateway_publish_keeps_real_send_failures() { + for error in [ + "gateway outbound sender lock poisoned", + "gateway outbound control lane is offline", + "gateway outbound lane closed", + ] { + assert_eq!( + ignore_gateway_outbound_offline(Err(error.to_string())), + Err(error.to_string()) + ); + } +} + +#[test] +fn chat_queue_event_envelope_carries_snapshot() { + let envelope = build_chat_queue_event_envelope(GatewayChatQueueEventInput { + conversation_id: "conversation-1".to_string(), + snapshot_json: r#"{"items":[]}"#.to_string(), + revision: 7, + }); + + assert!(envelope.request_id.starts_with("chat-queue-event-")); + match envelope.payload { + Some(proto::agent_envelope::Payload::ChatQueueEvent(event)) => { + assert_eq!(event.conversation_id, "conversation-1"); + assert_eq!(event.snapshot_json, r#"{"items":[]}"#); + assert_eq!(event.revision, 7); + } + other => panic!("unexpected payload: {other:?}"), + } +}