Skip to content

Commit b9ec4f3

Browse files
authored
Use tombstone for failure UX for cloud mode (#10895)
## Description <!-- Please remember to add your design buddy onto the PR for review, if it contains any UI changes! --> This PR reverts back to using the tombstone, rather than the message bar, to display setup failures in cloud mode. In addition to showing the tombstone, we also: - Update `conversation_output_status_from_conversation` to return statuses even when the conversation doesn't have any tasks (e.g. failure before conversation running, third-party harness) - Deserialize an `error_code` from task status messages (already present in the GraphQL) so that for setup command failures we don't show the tombstone with Continue/Continue Locally actions (it doesn't make sense to continue for an environment that's borked). Loom: https://www.loom.com/share/685d737b881f4d89a1be0ade033da5ab ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? Manual testing is required for changes that can be manually tested, and almost all changes can be manually tested. If your change can be manually tested, please include screenshots or a screen recording that show it working end to end. You can run the app locally using `./script/run` - see WARP.md for more details on how to get set up. --> Added unit tests and tested a variety of failure modes in the Loom above. ### Screenshots / Videos <!-- Attach screenshots or a short video demonstrating the change, where appropriate. Remove this section if it is not relevant to your PR. --> Not included in the Loom, but setup command failure: <img width="1392" height="912" alt="image" src="https://github.com/user-attachments/assets/eefbb82c-1c0a-4ede-b92d-79d93a9faa8c" /> I don't think it's perfect but it's better than what we had before: <img width="602" height="126" alt="image" src="https://github.com/user-attachments/assets/7a2367b7-75c9-498d-a5e8-dbb5044e73bc" /> ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode
1 parent 98aaece commit b9ec4f3

12 files changed

Lines changed: 310 additions & 44 deletions

File tree

app/src/ai/agent_conversations_model_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1754,6 +1754,7 @@ fn test_task_status_maps_blocked_state_to_blocked() {
17541754
task.state = AmbientAgentTaskState::Blocked;
17551755
task.status_message = Some(TaskStatusMessage {
17561756
message: "Needs clarification".to_string(),
1757+
error_code: None,
17571758
});
17581759

17591760
app.update(|ctx| {

app/src/ai/ambient_agents/mod.rs

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,19 +78,32 @@ pub fn conversation_output_status_from_conversation(
7878
blocked_action: blocked_action.clone(),
7979
});
8080
}
81-
82-
let last_exchange = conversation.root_task_exchanges().last()?;
83-
if let AIAgentOutputStatus::Finished { finished_output } = &last_exchange.output_status {
84-
let status = match finished_output {
85-
FinishedAIAgentOutput::Cancelled { output: _, reason } => {
86-
AmbientConversationStatus::Cancelled { reason: *reason }
87-
}
88-
FinishedAIAgentOutput::Error { output: _, error } => AmbientConversationStatus::Error {
89-
error: error.clone(),
90-
},
91-
FinishedAIAgentOutput::Success { output: _ } => AmbientConversationStatus::Success,
92-
};
93-
return Some(status);
81+
if let Some(last_exchange) = conversation.root_task_exchanges().last() {
82+
if let AIAgentOutputStatus::Finished { finished_output } = &last_exchange.output_status {
83+
let status = match finished_output {
84+
FinishedAIAgentOutput::Cancelled { output: _, reason } => {
85+
AmbientConversationStatus::Cancelled { reason: *reason }
86+
}
87+
FinishedAIAgentOutput::Error { output: _, error } => {
88+
AmbientConversationStatus::Error {
89+
error: error.clone(),
90+
}
91+
}
92+
FinishedAIAgentOutput::Success { output: _ } => AmbientConversationStatus::Success,
93+
};
94+
return Some(status);
95+
}
96+
}
97+
if let ConversationStatus::Error = conversation.status() {
98+
if let Some(error_message) = conversation.status_error_message() {
99+
return Some(AmbientConversationStatus::Error {
100+
error: RenderableAIError::Other {
101+
error_message: error_message.to_string(),
102+
will_attempt_resume: false,
103+
waiting_for_network: false,
104+
},
105+
});
106+
}
94107
}
95108

96109
None

app/src/ai/ambient_agents/spawn_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ async fn followup_terminal_failure_surfaces_status_message() {
166166
let mut task = task_with(AmbientAgentTaskState::Error, None, None);
167167
task.status_message = Some(TaskStatusMessage {
168168
message: "failed to provision runtime".to_string(),
169+
error_code: None,
169170
});
170171
Ok(task)
171172
});

app/src/ai/ambient_agents/task.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,31 @@ pub struct TaskPrincipalInfo {
520520
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
521521
pub struct TaskStatusMessage {
522522
pub message: String,
523+
#[serde(default, alias = "errorCode")]
524+
pub error_code: Option<TaskStatusErrorCode>,
525+
}
526+
527+
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
528+
#[serde(rename_all = "snake_case")]
529+
pub enum TaskStatusErrorCode {
530+
#[serde(alias = "ENVIRONMENT_SETUP_FAILED")]
531+
EnvironmentSetupFailed,
532+
#[serde(other)]
533+
Unknown,
534+
}
535+
536+
impl TaskStatusErrorCode {
537+
pub fn is_environment_setup_failure(&self) -> bool {
538+
matches!(self, TaskStatusErrorCode::EnvironmentSetupFailed)
539+
}
540+
}
541+
542+
impl TaskStatusMessage {
543+
pub fn is_environment_setup_failure(&self) -> bool {
544+
self.error_code
545+
.as_ref()
546+
.is_some_and(TaskStatusErrorCode::is_environment_setup_failure)
547+
}
523548
}
524549

525550
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
@@ -563,3 +588,7 @@ pub fn cancel_task_silently<V: View>(task_id: AmbientAgentTaskId, ctx: &mut View
563588
},
564589
);
565590
}
591+
592+
#[cfg(test)]
593+
#[path = "task_tests.rs"]
594+
mod tests;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use super::{TaskStatusErrorCode, TaskStatusMessage};
2+
3+
#[test]
4+
fn task_status_error_code_deserializes_public_api_casing() {
5+
let message: TaskStatusMessage = serde_json::from_str(
6+
"{\"message\":\"setup failed\",\"error_code\":\"environment_setup_failed\"}",
7+
)
8+
.unwrap();
9+
10+
assert_eq!(
11+
message.error_code,
12+
Some(TaskStatusErrorCode::EnvironmentSetupFailed)
13+
);
14+
assert!(message.is_environment_setup_failure());
15+
}
16+
17+
#[test]
18+
fn task_status_error_code_deserializes_graphql_casing() {
19+
let message: TaskStatusMessage = serde_json::from_str(
20+
"{\"message\":\"setup failed\",\"errorCode\":\"ENVIRONMENT_SETUP_FAILED\"}",
21+
)
22+
.unwrap();
23+
24+
assert_eq!(
25+
message.error_code,
26+
Some(TaskStatusErrorCode::EnvironmentSetupFailed)
27+
);
28+
assert!(message.is_environment_setup_failure());
29+
}
30+
31+
#[test]
32+
fn task_status_error_code_deserializes_unknown_codes() {
33+
let message: TaskStatusMessage =
34+
serde_json::from_str("{\"message\":\"failed\",\"error_code\":\"new_error\"}").unwrap();
35+
36+
assert_eq!(message.error_code, Some(TaskStatusErrorCode::Unknown));
37+
assert!(!message.is_environment_setup_failure());
38+
}

app/src/ai/blocklist/block/status_bar.rs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -944,19 +944,6 @@ impl BlocklistAIStatusBar {
944944
]));
945945
}
946946

947-
if let Some(error_message) = ambient_agent_model.error_message() {
948-
return Some(Message::new(vec![
949-
MessageItem::Icon {
950-
icon: CoreIcon::Triangle,
951-
color: Some(error_color),
952-
},
953-
MessageItem::Text {
954-
content: error_message.to_owned().into(),
955-
color: Some(error_color),
956-
},
957-
]));
958-
}
959-
960947
if ambient_agent_model.is_cancelled() {
961948
let color = theme.disabled_text_color(theme.background()).into_solid();
962949
return Some(Message::new(vec![

app/src/terminal/view/ambient_agent/view_impl.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,16 @@ impl TerminalView {
230230
Some(error_message.clone()),
231231
ctx,
232232
);
233+
234+
if FeatureFlag::CloudModeSetupV2.is_enabled() {
235+
self.insert_conversation_ended_tombstone(ctx);
236+
}
237+
233238
// Refresh the details panel to show failed status
234239
if self.is_conversation_details_panel_open {
235240
self.fetch_and_update_conversation_details_panel(ctx);
236241
}
237-
// Re-render to show the error state in the footer.
242+
// Re-render to show the error state.
238243
ctx.emit(TerminalViewEvent::TerminalViewStateChanged);
239244
ctx.notify();
240245
}

app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ struct TombstoneDisplayData {
5353
working_directory: Option<String>,
5454
/// Artifacts from the conversation
5555
artifacts: Vec<Artifact>,
56+
hide_continue_actions: bool,
5657
/// Execution harness for the task. None until the task is loaded.
5758
#[cfg(not(target_family = "wasm"))]
5859
harness: Option<Harness>,
@@ -117,6 +118,7 @@ impl TombstoneDisplayData {
117118
credits: Some(format_credits(conversation.credits_spent())),
118119
working_directory: conversation.initial_working_directory(),
119120
artifacts: conversation.artifacts().to_vec(),
121+
hide_continue_actions: false,
120122
#[cfg(not(target_family = "wasm"))]
121123
harness: None,
122124
}
@@ -149,6 +151,9 @@ impl TombstoneDisplayData {
149151
if task.state.is_failure_like() {
150152
self.is_error = true;
151153
if let Some(status_message) = &task.status_message {
154+
if status_message.is_environment_setup_failure() {
155+
self.hide_continue_actions = true;
156+
}
152157
self.error_message = Some(status_message.message.clone());
153158
}
154159
}
@@ -195,17 +200,23 @@ impl ConversationEndedTombstoneView {
195200
.all_live_conversations_for_terminal_view(terminal_view_id)
196201
.next()
197202
.map(|c| c.id());
198-
199-
let display_data = conversation_id
200-
.map(|id| {
203+
let mut display_data = conversation_id
204+
.map(|conversation_id| {
201205
TombstoneDisplayData::from_conversation(
202-
id,
206+
conversation_id,
203207
terminal_view_id,
204208
task_id.is_some(),
205209
ctx,
206210
)
207211
})
208212
.unwrap_or_default();
213+
let failed_before_task_creation =
214+
display_data.is_error && task_id.is_none() && !display_data.conversation_is_transcript;
215+
if failed_before_task_creation {
216+
display_data.title = Some("Cloud agent failed to start".to_string());
217+
display_data.credits = None;
218+
display_data.hide_continue_actions = true;
219+
}
209220

210221
let artifact_buttons_view =
211222
ctx.add_typed_action_view(|ctx| ArtifactButtonsRow::new(&display_data.artifacts, ctx));
@@ -225,17 +236,21 @@ impl ConversationEndedTombstoneView {
225236
});
226237

227238
#[cfg(not(target_family = "wasm"))]
228-
let continue_locally_button = conversation_id.map(|conv_id| {
229-
ctx.add_typed_action_view(move |_| {
230-
ActionButton::new("Continue locally", PrimaryTheme)
231-
.with_tooltip("Fork this conversation locally")
232-
.on_click(move |ctx| {
233-
ctx.dispatch_typed_action(
234-
ConversationEndedTombstoneAction::ContinueLocally(conv_id),
235-
);
236-
})
239+
let continue_locally_button = if failed_before_task_creation {
240+
None
241+
} else {
242+
conversation_id.map(|conv_id| {
243+
ctx.add_typed_action_view(move |_| {
244+
ActionButton::new("Continue locally", PrimaryTheme)
245+
.with_tooltip("Fork this conversation locally")
246+
.on_click(move |ctx| {
247+
ctx.dispatch_typed_action(
248+
ConversationEndedTombstoneAction::ContinueLocally(conv_id),
249+
);
250+
})
251+
})
237252
})
238-
});
253+
};
239254

240255
// In wasm, continuing locally is impossible so we instead
241256
// offer to open the conversation in warp (where you can continue locally).
@@ -494,6 +509,9 @@ impl ConversationEndedTombstoneView {
494509
.with_spacing(8.);
495510

496511
let mut has_button = false;
512+
if self.display_data.hide_continue_actions {
513+
return Empty::new().finish();
514+
}
497515

498516
#[cfg(not(target_family = "wasm"))]
499517
{
@@ -535,6 +553,29 @@ impl ConversationEndedTombstoneView {
535553
}
536554
}
537555

556+
#[cfg(test)]
557+
impl ConversationEndedTombstoneView {
558+
pub(in crate::terminal::view) fn title_for_test(&self) -> Option<&str> {
559+
self.display_data.title.as_deref()
560+
}
561+
562+
pub(in crate::terminal::view) fn error_message_for_test(&self) -> Option<&str> {
563+
self.display_data.error_message.as_deref()
564+
}
565+
566+
pub(in crate::terminal::view) fn credits_for_test(&self) -> Option<&str> {
567+
self.display_data.credits.as_deref()
568+
}
569+
570+
pub(in crate::terminal::view) fn has_continue_locally_button_for_test(&self) -> bool {
571+
!self.display_data.hide_continue_actions && self.continue_locally_button.is_some()
572+
}
573+
574+
pub(in crate::terminal::view) fn has_continue_in_cloud_button_for_test(&self) -> bool {
575+
!self.display_data.hide_continue_actions && self.continue_in_cloud_button.is_some()
576+
}
577+
}
578+
538579
#[derive(Debug, Clone)]
539580
pub enum ConversationEndedTombstoneAction {
540581
#[cfg(not(target_family = "wasm"))]

app/src/terminal/view/shared_session/conversation_ended_tombstone_view_tests.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ use chrono::{Duration, Utc};
22
use warp_cli::agent::Harness;
33

44
use crate::ai::ambient_agents::task::{
5-
AgentConfigSnapshot, HarnessConfig, RequestUsage, TaskPrincipalInfo,
5+
AgentConfigSnapshot, HarnessConfig, RequestUsage, TaskPrincipalInfo, TaskStatusErrorCode,
6+
TaskStatusMessage,
67
};
78
use crate::ai::ambient_agents::{AmbientAgentTask, AmbientAgentTaskState};
89
use crate::ai::artifacts::Artifact;
@@ -67,6 +68,41 @@ fn data_with_conversation_values() -> TombstoneDisplayData {
6768
}
6869
}
6970

71+
#[test]
72+
fn task_failure_status_message_overrides_conversation_error() {
73+
let mut task = task_with_run_time_and_credits();
74+
task.state = AmbientAgentTaskState::Failed;
75+
task.status_message = Some(TaskStatusMessage {
76+
message: "task failed".to_string(),
77+
error_code: None,
78+
});
79+
let mut data = TombstoneDisplayData {
80+
is_error: true,
81+
error_message: Some("setup failed".to_string()),
82+
..Default::default()
83+
};
84+
85+
data.enrich_from_task(task);
86+
87+
assert!(data.is_error);
88+
assert_eq!(data.error_message.as_deref(), Some("task failed"));
89+
}
90+
91+
#[test]
92+
fn environment_setup_failure_hides_continue_actions() {
93+
let mut task = task_with_run_time_and_credits();
94+
task.state = AmbientAgentTaskState::Failed;
95+
task.status_message = Some(TaskStatusMessage {
96+
message: "Environment setup failed: Failed to run setup command: hi".to_string(),
97+
error_code: Some(TaskStatusErrorCode::EnvironmentSetupFailed),
98+
});
99+
let mut data = TombstoneDisplayData::default();
100+
101+
data.enrich_from_task(task);
102+
103+
assert!(data.hide_continue_actions);
104+
}
105+
70106
fn pr_artifact(branch: &str) -> Artifact {
71107
Artifact::PullRequest {
72108
url: format!("https://github.com/example/repo/pull/{branch}"),

app/src/terminal/view/shared_session/view_impl.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1672,8 +1672,7 @@ impl TerminalView {
16721672
}
16731673
let task_id = self.ambient_agent_task_id_for_details_panel(ctx);
16741674
let terminal_view_id = self.id();
1675-
1676-
let tombstone_view_handle = ctx.add_typed_action_view(|ctx| {
1675+
let tombstone_view_handle = ctx.add_typed_action_view(move |ctx| {
16771676
ConversationEndedTombstoneView::new(ctx, terminal_view_id, task_id)
16781677
});
16791678
#[cfg(not(target_family = "wasm"))]

0 commit comments

Comments
 (0)