diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 8455a148..2400a0a2 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -16161,12 +16161,11 @@ impl View for Input { ambient_agent_model.as_ref(app).should_show_status_footer() }); - if FeatureFlag::CloudMode.is_enabled() && should_show_status_footer { - self.render_ambient_agent_status_footer(app) - } else if FeatureFlag::AgentView.is_enabled() - && self.agent_view_controller.as_ref(app).is_active() + if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(app).is_active() { self.render_agent_input(app) + } else if FeatureFlag::CloudMode.is_enabled() && should_show_status_footer { + self.render_ambient_agent_status_footer(app) } else if FeatureFlag::AgentView.is_enabled() && !self.agent_view_controller.as_ref(app).is_active() && !should_render_ps1_prompt(&self.model.lock(), app) diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index edde1e42..2b475762 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -8367,21 +8367,6 @@ impl TerminalView { return false; } - // In cloud agent conversations, once the shared session is ready but before the first - // agent exchange arrives, we hide the interactive input view. A non-interactive footer is - // rendered instead (see `TerminalView::render`). - if !FeatureFlag::CloudModeSetupV2.is_enabled() - && !FeatureFlag::HandoffCloudCloud.is_enabled() - && ambient_agent::is_cloud_agent_pre_first_exchange( - self.ambient_agent_view_model.as_ref(), - &self.agent_view_controller, - model, - app, - ) - { - return false; - } - if self.has_active_init_project(app) && self.is_last_block_init_step(app) { return false; } @@ -8417,23 +8402,28 @@ impl TerminalView { } } - let active_ai_block = self.active_ai_block(app); - if active_ai_block.is_some_and(|ai_block| { - let ai_block = ai_block.as_ref(app); - ai_block.is_blocked_on_user_confirmation(app) - || ai_block.has_expanded_running_commands(app) - }) { - return false; - } - let active_command_block = model.block_list().active_block(); let is_active_and_long_running = active_command_block.is_active_and_long_running(); let is_oz_env_startup_command = active_command_block.is_oz_environment_startup_command(); let is_running_in_band_command = model.block_list().is_writing_or_executing_in_band_command(); + let has_active_long_running_agent_interaction = active_command_block + .is_agent_driving_command() + || active_command_block.is_agent_tagged_in(); + let is_agent_view_active = FeatureFlag::AgentView.is_enabled() + && self.agent_view_controller.as_ref(app).is_active(); - let has_active_long_running_agent_interaction = - active_command_block.is_agent_monitoring() || active_command_block.is_agent_tagged_in(); + let active_ai_block = self.active_ai_block(app); + if !is_agent_view_active + && !has_active_long_running_agent_interaction + && active_ai_block.is_some_and(|ai_block| { + let ai_block = ai_block.as_ref(app); + ai_block.is_blocked_on_user_confirmation(app) + || ai_block.has_expanded_running_commands(app) + }) + { + return false; + } if (active_ai_block.is_none() || has_active_long_running_agent_interaction) && is_active_and_long_running @@ -8441,11 +8431,9 @@ impl TerminalView { && !is_running_in_band_command && model.block_list().is_bootstrapped() { - // Show the input if: - // * The agent is control of the active, long running block, so long as the agent is not blocked. - // * OR the user has 'tagged in' the agent. - return (active_command_block.is_agent_in_control() - && !active_command_block.is_agent_blocked()) + // Keep the agent prompt available while the agent owns, starts, or waits on + // a long-running command; hide it for user-owned commands unless tagged in. + return active_command_block.is_agent_driving_command() || active_command_block.is_agent_tagged_in(); } diff --git a/app/src/terminal/view/ambient_agent/mod.rs b/app/src/terminal/view/ambient_agent/mod.rs index 2fd48acc..6fdc73dd 100644 --- a/app/src/terminal/view/ambient_agent/mod.rs +++ b/app/src/terminal/view/ambient_agent/mod.rs @@ -139,7 +139,7 @@ pub fn create_cloud_mode_view( /// Returns `true` when a cloud agent shared session is in any pre-first-exchange phase — /// either still spawning (loading screen) or running setup commands before the first -/// agent turn. In this state, we hide the interactive input and render a loading footer. +/// agent turn. pub fn is_cloud_agent_pre_first_exchange( ambient_agent_view_model: Option<&ModelHandle>, agent_view_controller: &ModelHandle, diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index a9fc1512..d9fb1e62 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -1787,6 +1787,60 @@ fn fresh_cloud_mode_setup_enters_agent_view_when_view_pending() { }); } +#[test] +fn legacy_cloud_mode_waiting_for_session_keeps_input_visible() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _agent_view = FeatureFlag::AgentView.override_enabled(true); + let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true); + let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(false); + let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(false); + + let terminal = add_window_with_cloud_mode_terminal(&mut app); + + terminal.update(&mut app, |view, ctx| { + view.model + .lock() + .set_shared_session_status(SharedSessionStatus::ViewPending); + view.enter_ambient_agent_setup(Some("write the tests".to_string()), ctx); + view.ambient_agent_view_model() + .expect("cloud mode terminal should have ambient model") + .update(ctx, |model, ctx| { + model.spawn_agent_with_request( + SpawnAgentRequest { + prompt: Some("write the tests".to_string()), + mode: UserQueryMode::Normal, + config: None, + title: None, + team: None, + agent_identity_uid: None, + skill: None, + attachments: vec![], + interactive: None, + parent_run_id: None, + runtime_skills: vec![], + referenced_attachments: vec![], + conversation_id: None, + initial_snapshot_token: None, + snapshot_disabled: None, + orchestration_handoff: None, + }, + ctx, + ); + }); + + let model = view.model.lock(); + assert!(ambient_agent::is_cloud_agent_pre_first_exchange( + view.ambient_agent_view_model.as_ref(), + &view.agent_view_controller, + &model, + ctx, + )); + assert!(view.is_input_box_visible(&model, ctx)); + }); + }); +} + #[test] fn shared_third_party_viewer_sync_enters_agent_view_and_retags_existing_block() { App::test((), |mut app| async move { @@ -5486,6 +5540,57 @@ fn inline_agent_view_exits_when_tagged_in_long_running_command_is_tagged_out() { }) } +#[test] +fn agent_view_keeps_input_visible_for_agent_requested_and_blocked_commands() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _agent_view = FeatureFlag::AgentView.override_enabled(true); + + let terminal = add_window_with_terminal(&mut app, None); + + terminal.update(&mut app, |view, ctx| { + let conversation_id = view.agent_view_controller().update(ctx, |controller, ctx| { + controller + .try_enter_agent_view( + None, + AgentViewEntryOrigin::Input { + was_prompt_autodetected: false, + }, + ctx, + ) + .expect("should enter agent view") + }); + bootstrap_with_long_running_block(view); + set_active_block_agent_driving(view, conversation_id); + + { + let model = view.model.lock(); + let active_block = model.block_list().active_block(); + assert!(active_block.is_agent_driving_command()); + assert!(view.is_input_box_visible(&model, ctx)); + } + + let task_id = TaskId::new("test-cli-subagent".to_owned()); + view.model + .lock() + .block_list_mut() + .active_block_mut() + .set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id) + .expect("agent-requested command should become monitored"); + view.model + .lock() + .block_list_mut() + .active_block_mut() + .update_is_agent_blocked(true); + + let model = view.model.lock(); + let active_block = model.block_list().active_block(); + assert!(active_block.is_agent_blocked()); + assert!(view.is_input_box_visible(&model, ctx)); + }); + }) +} + #[test] fn ctrl_c_after_stop_takeover_cancels_conversation() { App::test((), |mut app| async move { diff --git a/crates/galaxy_agent_rig/src/stream.rs b/crates/galaxy_agent_rig/src/stream.rs index 8b599fb8..eeb378e2 100644 --- a/crates/galaxy_agent_rig/src/stream.rs +++ b/crates/galaxy_agent_rig/src/stream.rs @@ -22,16 +22,23 @@ where let control_future = initial_control.receive().fuse(); futures::pin_mut!(stream_future, control_future); - let mut rig_stream = futures::select_biased! { + let stream_result = futures::select_biased! { command = control_future => match command { Ok(TurnCommand::Cancel) => { return Ok(stopped_before_stream(runtime_request_id)); } - Ok(TurnCommand::Steer { .. }) | Err(_) => { - stream_future.await.map_err(map_completion_error)? - } + Ok(TurnCommand::Steer { .. }) | Err(_) => stream_future.await, }, - result = stream_future => result.map_err(map_completion_error)?, + result = stream_future => result, + }; + let mut rig_stream = match stream_result { + Ok(stream) => stream, + Err(error) => { + if let Some(reason) = completion_error_stop_reason(&error) { + return Ok(stopped_with_reason(runtime_request_id, reason)); + } + return Err(map_completion_error(error)); + } }; let events = async_stream::stream! { @@ -156,16 +163,23 @@ where let control_future = initial_control.receive().fuse(); futures::pin_mut!(completion_future, control_future); - let response = futures::select_biased! { + let completion_result = futures::select_biased! { command = control_future => match command { Ok(TurnCommand::Cancel) => { return Ok(stopped_before_stream(runtime_request_id)); } - Ok(TurnCommand::Steer { .. }) | Err(_) => { - completion_future.await.map_err(map_completion_error)? - } + Ok(TurnCommand::Steer { .. }) | Err(_) => completion_future.await, }, - result = completion_future => result.map_err(map_completion_error)?, + result = completion_future => result, + }; + let response = match completion_result { + Ok(response) => response, + Err(error) => { + if let Some(reason) = completion_error_stop_reason(&error) { + return Ok(stopped_with_reason(runtime_request_id, reason)); + } + return Err(map_completion_error(error)); + } }; let events = async_stream::stream! { @@ -230,11 +244,13 @@ fn domain_tool_call(tool_call: rig_core::message::ToolCall) -> ToolCall { } fn stopped_before_stream(runtime_request_id: String) -> AgentEventStream { + stopped_with_reason(runtime_request_id, StopReason::Cancelled) +} + +fn stopped_with_reason(runtime_request_id: String, reason: StopReason) -> AgentEventStream { Box::pin(futures::stream::iter([ Ok(AgentEvent::TurnStarted { runtime_request_id }), - Ok(AgentEvent::TurnStopped { - reason: StopReason::Cancelled, - }), + Ok(AgentEvent::TurnStopped { reason }), ])) } @@ -248,6 +264,10 @@ pub(crate) fn map_usage(usage: rig_core::completion::Usage) -> Usage { } pub(crate) fn completion_error_stop_reason(error: &CompletionError) -> Option { + if completion_error_indicates_context_window_exceeded(error) { + return Some(StopReason::ContextWindowExceeded); + } + match error { // rig-bedrock 0.40 currently surfaces Bedrock's MaxTokens stop as a // provider error. Normalize it here so the UI sees the same semantic @@ -259,28 +279,74 @@ pub(crate) fn completion_error_stop_reason(error: &CompletionError) -> Option bool { + if let Ok(Some(value)) = error.provider_response_json() + && json_value_indicates_context_window_exceeded(&value) + { + return true; + } + + error + .provider_response_body() + .is_some_and(text_indicates_context_window_exceeded) + || text_indicates_context_window_exceeded(&error.to_string()) +} + +fn json_value_indicates_context_window_exceeded(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::String(text) => text_indicates_context_window_exceeded(text), + serde_json::Value::Array(values) => values + .iter() + .any(json_value_indicates_context_window_exceeded), + serde_json::Value::Object(map) => map + .values() + .any(json_value_indicates_context_window_exceeded), + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { + false + } + } +} + +fn text_indicates_context_window_exceeded(text: &str) -> bool { + let normalized = text.to_ascii_lowercase(); + normalized.contains("modelcontextwindowexceeded") + || normalized.contains("context_length_exceeded") + || normalized.contains("context window") + || normalized.contains("context length") + || normalized.contains("too many tokens") + || normalized.contains("maximum context") + || normalized.contains("input is too long") + || (normalized.contains("input exceeds") && normalized.contains("context")) + || normalized.contains("exceeds the context") +} + fn map_completion_error(error: CompletionError) -> AgentError { + let is_context_window_exceeded = completion_error_indicates_context_window_exceeded(&error); let status = error .provider_response_status() .map(|status| status.as_u16()); - let kind = match status { - Some(401 | 403) => AgentErrorKind::Authentication, - Some(429) => AgentErrorKind::RateLimited, - Some(400 | 404 | 413 | 422) => AgentErrorKind::InvalidRequest, - Some(500..=599) => AgentErrorKind::Provider, - Some(_) => AgentErrorKind::Provider, - None => match &error { - CompletionError::HttpError(_) - | CompletionError::UrlError(_) - | CompletionError::RequestError(_) => AgentErrorKind::Transport, - CompletionError::JsonError(_) | CompletionError::ResponseError(_) => { - AgentErrorKind::Protocol - } - CompletionError::ProviderError(_) | CompletionError::ProviderResponse(_) => { - AgentErrorKind::Provider - } - _ => AgentErrorKind::Provider, - }, + let kind = if is_context_window_exceeded { + AgentErrorKind::ContextWindowExceeded + } else { + match status { + Some(401 | 403) => AgentErrorKind::Authentication, + Some(429) => AgentErrorKind::RateLimited, + Some(400 | 404 | 413 | 422) => AgentErrorKind::InvalidRequest, + Some(500..=599) => AgentErrorKind::Provider, + Some(_) => AgentErrorKind::Provider, + None => match &error { + CompletionError::HttpError(_) + | CompletionError::UrlError(_) + | CompletionError::RequestError(_) => AgentErrorKind::Transport, + CompletionError::JsonError(_) | CompletionError::ResponseError(_) => { + AgentErrorKind::Protocol + } + CompletionError::ProviderError(_) | CompletionError::ProviderResponse(_) => { + AgentErrorKind::Provider + } + _ => AgentErrorKind::Provider, + }, + } }; let mut mapped = AgentError::new(kind, error.to_string()); mapped.recoverable = matches!( @@ -292,7 +358,10 @@ fn map_completion_error(error: CompletionError) -> AgentError { #[cfg(test)] mod tests { - use super::domain_tool_call; + use galaxy_agent_core::{AgentErrorKind, StopReason}; + use rig_core::completion::CompletionError; + + use super::{completion_error_stop_reason, domain_tool_call, map_completion_error}; #[test] fn domain_tool_call_prefers_responses_call_id() { @@ -326,4 +395,37 @@ mod tests { assert_eq!(call.id, "fc_item_123"); assert_eq!(call.name, "read_files"); } + + #[test] + fn provider_context_window_error_maps_to_semantic_stop_reason_and_kind() { + let status = rig_core::http_client::Response::builder() + .status(400) + .body(()) + .unwrap() + .status(); + let error = CompletionError::from_http_response( + status, + r#"{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","code":"400"}}"#, + ); + + assert_eq!( + completion_error_stop_reason(&error), + Some(StopReason::ContextWindowExceeded) + ); + let mapped = map_completion_error(error); + assert_eq!(mapped.kind, AgentErrorKind::ContextWindowExceeded); + assert!(!mapped.recoverable); + } + + #[test] + fn provider_context_length_string_maps_to_semantic_stop_reason() { + let error = CompletionError::ProviderError( + "context_length_exceeded: maximum context length is 128000 tokens".to_string(), + ); + + assert_eq!( + completion_error_stop_reason(&error), + Some(StopReason::ContextWindowExceeded) + ); + } }