mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(tui/exec): handle missing wait output
This commit is contained in:
@@ -1084,6 +1084,7 @@ use crate::history::state::{
|
||||
AssistantStreamState,
|
||||
DiffLineKind,
|
||||
DiffRecord,
|
||||
ExecStatus,
|
||||
ExecWaitNote,
|
||||
HistoryDomainEvent,
|
||||
HistoryDomainRecord,
|
||||
@@ -2769,6 +2770,24 @@ fn wait_exec_call_id_from_params(params: Option<&String>) -> Option<ExecCallId>
|
||||
.and_then(|json| json.get("call_id").and_then(|v| v.as_str()).map(|s| ExecCallId(s.to_string())))
|
||||
}
|
||||
|
||||
fn wait_result_missing_background_job(message: &str) -> bool {
|
||||
let trimmed = message.trim();
|
||||
trimmed.starts_with("No background job found for call_id=")
|
||||
|| trimmed == "No completed background job found"
|
||||
}
|
||||
|
||||
fn wait_result_interrupted(message: &str) -> bool {
|
||||
let trimmed = message.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
lower.contains("wait ended due to new user message")
|
||||
|| lower.contains("wait ended because the session was interrupted")
|
||||
|| lower.contains("wait interrupted so the assistant can adapt")
|
||||
|| (lower.contains("background job") && lower.contains("still running"))
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExecCallId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
@@ -13637,7 +13656,12 @@ impl ChatWidget<'_> {
|
||||
.remove(&ToolCallId(call_id.clone()))
|
||||
{
|
||||
let trimmed = content.trim();
|
||||
let wait_still_pending = !success && trimmed != "Cancelled by user.";
|
||||
let wait_missing_job = wait_result_missing_background_job(trimmed);
|
||||
let wait_interrupted = wait_result_interrupted(trimmed);
|
||||
let mut wait_still_pending =
|
||||
!success && trimmed != "Cancelled by user." && !wait_missing_job;
|
||||
let mut exec_running = false;
|
||||
let mut exec_completed = false;
|
||||
let mut note_lines: Vec<(String, bool)> = Vec::new();
|
||||
let suppress_json_notes = serde_json::from_str::<serde_json::Value>(
|
||||
trimmed,
|
||||
@@ -13663,6 +13687,7 @@ impl ChatWidget<'_> {
|
||||
let mut wait_total: Option<Duration> = None;
|
||||
let mut wait_notes_snapshot: Vec<(String, bool)> = Vec::new();
|
||||
if let Some(running) = self.exec.running_commands.get_mut(&exec_call_id) {
|
||||
exec_running = true;
|
||||
let base = running.wait_total.unwrap_or_default();
|
||||
let total = base.saturating_add(duration);
|
||||
running.wait_total = Some(total);
|
||||
@@ -13706,6 +13731,7 @@ impl ChatWidget<'_> {
|
||||
.index_of(id)
|
||||
.and_then(|idx| self.history_state.get(idx).cloned());
|
||||
if let Some(HistoryRecord::Exec(record)) = exec_record {
|
||||
exec_completed = !matches!(record.status, ExecStatus::Running);
|
||||
if wait_total.is_none() {
|
||||
let base = record.wait_total.unwrap_or_default();
|
||||
wait_total = Some(base.saturating_add(duration));
|
||||
@@ -13723,12 +13749,29 @@ impl ChatWidget<'_> {
|
||||
Self::append_wait_pairs(&mut wait_notes_snapshot, ¬e_lines);
|
||||
}
|
||||
}
|
||||
let _ = self.update_exec_wait_state_with_pairs(
|
||||
id,
|
||||
wait_total,
|
||||
wait_still_pending,
|
||||
&wait_notes_snapshot,
|
||||
);
|
||||
if exec_completed {
|
||||
wait_still_pending = false;
|
||||
} else if wait_interrupted && !exec_running {
|
||||
wait_still_pending = false;
|
||||
}
|
||||
|
||||
if !exec_completed {
|
||||
let _ = self.update_exec_wait_state_with_pairs(
|
||||
id,
|
||||
wait_total,
|
||||
wait_still_pending,
|
||||
&wait_notes_snapshot,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if exec_completed {
|
||||
self.bottom_pane
|
||||
.update_status_text("responding".to_string());
|
||||
self.maybe_hide_spinner();
|
||||
self.invalidate_height_cache();
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
if success {
|
||||
@@ -13739,6 +13782,21 @@ impl ChatWidget<'_> {
|
||||
} else if trimmed == "Cancelled by user." {
|
||||
self.bottom_pane
|
||||
.update_status_text("wait cancelled".to_string());
|
||||
} else if wait_missing_job || (wait_interrupted && !exec_running) {
|
||||
let finalized = exec_tools::finalize_wait_missing_exec(
|
||||
self,
|
||||
exec_call_id.clone(),
|
||||
trimmed,
|
||||
);
|
||||
if finalized {
|
||||
self.bottom_pane.update_status_text(
|
||||
"command finished (output unavailable)".to_string(),
|
||||
);
|
||||
} else {
|
||||
self.bottom_pane.update_status_text(
|
||||
"command status unavailable".to_string(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.bottom_pane
|
||||
.update_status_text("waiting for command".to_string());
|
||||
|
||||
@@ -686,6 +686,183 @@ pub(super) fn finalize_all_running_due_to_answer(chat: &mut ChatWidget<'_>) {
|
||||
chat.refresh_auto_drive_visuals();
|
||||
}
|
||||
|
||||
pub(super) fn finalize_wait_missing_exec(
|
||||
chat: &mut ChatWidget<'_>,
|
||||
call_id: super::ExecCallId,
|
||||
message: &str,
|
||||
) -> bool {
|
||||
const STREAM_TAIL_MAX_BYTES: usize = 64 * 1024;
|
||||
let trimmed = message.trim();
|
||||
let fallback_message = if trimmed.is_empty() {
|
||||
"Background job finished but output was unavailable."
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
|
||||
let history_id: Option<HistoryId>;
|
||||
let mut history_index: Option<usize> = None;
|
||||
let mut command: Vec<String> = Vec::new();
|
||||
let mut parsed: Vec<ParsedCommand> = Vec::new();
|
||||
let mut wait_total: Option<std::time::Duration> = None;
|
||||
let mut wait_notes_pairs: Vec<(String, bool)> = Vec::new();
|
||||
let mut explore_entry: Option<(usize, usize)> = None;
|
||||
|
||||
if let Some(running) = chat.exec.running_commands.remove(&call_id) {
|
||||
history_id = running
|
||||
.history_id
|
||||
.or_else(|| chat.history_state.history_id_for_exec_call(call_id.as_ref()))
|
||||
.or_else(|| {
|
||||
running
|
||||
.history_index
|
||||
.and_then(|idx| chat.history_cell_ids.get(idx).and_then(|slot| *slot))
|
||||
});
|
||||
history_index = running.history_index;
|
||||
command = running.command;
|
||||
parsed = running.parsed;
|
||||
wait_total = running.wait_total;
|
||||
wait_notes_pairs = running.wait_notes;
|
||||
explore_entry = running.explore_entry;
|
||||
} else {
|
||||
history_id = chat.history_state.history_id_for_exec_call(call_id.as_ref());
|
||||
if let Some(id) = history_id {
|
||||
if let Some(HistoryRecord::Exec(record)) = chat.history_state.record(id).cloned() {
|
||||
command = record.command.clone();
|
||||
parsed = record.parsed.clone();
|
||||
wait_total = record.wait_total;
|
||||
wait_notes_pairs = ChatWidget::wait_pairs_from_exec_notes(&record.wait_notes);
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = history_id {
|
||||
if let Some(record) = chat.history_state.record(id) {
|
||||
match record {
|
||||
HistoryRecord::Exec(exec_record) => {
|
||||
if exec_record.status != ExecStatus::Running {
|
||||
chat.maybe_hide_spinner();
|
||||
chat.refresh_auto_drive_visuals();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
HistoryRecord::MergedExec(_) => {
|
||||
chat.maybe_hide_spinner();
|
||||
chat.refresh_auto_drive_visuals();
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((agg_idx, entry_idx)) = explore_entry {
|
||||
let status = history_cell::ExploreEntryStatus::Error { exit_code: None };
|
||||
let updated =
|
||||
update_explore_entry_status(chat, Some(agg_idx), entry_idx, status).is_some();
|
||||
if updated && !chat
|
||||
.exec
|
||||
.running_commands
|
||||
.values()
|
||||
.any(|rc| rc.explore_entry.is_some())
|
||||
{
|
||||
chat.exec.running_explore_agg_index = None;
|
||||
}
|
||||
}
|
||||
|
||||
let wait_notes_record = exec_wait_notes_from_pairs(&wait_notes_pairs);
|
||||
let finish_mutation = chat
|
||||
.history_state
|
||||
.apply_domain_event(HistoryDomainEvent::FinishExec {
|
||||
id: history_id,
|
||||
call_id: Some(call_id.as_ref().to_string()),
|
||||
status: ExecStatus::Error,
|
||||
exit_code: Some(-1),
|
||||
completed_at: Some(SystemTime::now()),
|
||||
wait_total,
|
||||
wait_active: false,
|
||||
wait_notes: wait_notes_record,
|
||||
stdout_tail: None,
|
||||
stderr_tail: Some(fallback_message.to_string()),
|
||||
});
|
||||
|
||||
let mut handled_via_state = false;
|
||||
if let HistoryMutation::Replaced {
|
||||
id,
|
||||
record: HistoryRecord::Exec(exec_record),
|
||||
..
|
||||
} = finish_mutation
|
||||
{
|
||||
chat.update_cell_from_record(id, HistoryRecord::Exec(exec_record.clone()));
|
||||
if let Some(idx) = chat.cell_index_for_history_id(id) {
|
||||
crate::chatwidget::exec_tools::try_merge_completed_exec_at(chat, idx);
|
||||
}
|
||||
handled_via_state = true;
|
||||
}
|
||||
|
||||
if !handled_via_state {
|
||||
let mut stdout_so_far = String::new();
|
||||
let mut stderr_so_far = String::new();
|
||||
if let Some(history_id) = history_id {
|
||||
if let Some(record) = chat.history_state.record(history_id) {
|
||||
match record {
|
||||
HistoryRecord::Exec(exec_record) => {
|
||||
stdout_so_far = stream_chunks_tail_to_text(
|
||||
&exec_record.stdout_chunks,
|
||||
STREAM_TAIL_MAX_BYTES,
|
||||
);
|
||||
stderr_so_far = stream_chunks_tail_to_text(
|
||||
&exec_record.stderr_chunks,
|
||||
STREAM_TAIL_MAX_BYTES,
|
||||
);
|
||||
}
|
||||
HistoryRecord::MergedExec(merged) => {
|
||||
if let Some(last) = merged.segments.last() {
|
||||
stdout_so_far = stream_chunks_tail_to_text(
|
||||
&last.stdout_chunks,
|
||||
STREAM_TAIL_MAX_BYTES,
|
||||
);
|
||||
stderr_so_far = stream_chunks_tail_to_text(
|
||||
&last.stderr_chunks,
|
||||
STREAM_TAIL_MAX_BYTES,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !stderr_so_far.is_empty() {
|
||||
stderr_so_far.push('\n');
|
||||
}
|
||||
stderr_so_far.push_str(fallback_message);
|
||||
|
||||
let mut completed_cell = history_cell::new_completed_exec_command(
|
||||
command,
|
||||
parsed,
|
||||
CommandOutput {
|
||||
exit_code: -1,
|
||||
stdout: stdout_so_far,
|
||||
stderr: stderr_so_far,
|
||||
},
|
||||
);
|
||||
completed_cell.record.call_id = Some(call_id.as_ref().to_string());
|
||||
|
||||
if let Some(idx) = history_index {
|
||||
chat.history_replace_and_maybe_merge(idx, Box::new(completed_cell));
|
||||
} else {
|
||||
let key = chat.next_internal_key();
|
||||
let idx = chat.history_insert_with_key_global(Box::new(completed_cell), key);
|
||||
crate::chatwidget::exec_tools::try_merge_completed_exec_at(chat, idx);
|
||||
}
|
||||
}
|
||||
|
||||
chat.maybe_hide_spinner();
|
||||
chat.refresh_auto_drive_visuals();
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn try_merge_completed_exec_at(chat: &mut ChatWidget<'_>, idx: usize) {
|
||||
if idx == 0 || idx >= chat.history_cells.len() {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use code_core::protocol::{
|
||||
AgentMessageEvent,
|
||||
AgentMessageDeltaEvent,
|
||||
CustomToolCallBeginEvent,
|
||||
CustomToolCallEndEvent,
|
||||
Event,
|
||||
EventMsg,
|
||||
ErrorEvent,
|
||||
@@ -14,6 +16,7 @@ use code_core::protocol::{
|
||||
PatchApplyBeginEvent,
|
||||
PatchApplyEndEvent,
|
||||
};
|
||||
use code_core::parse_command::ParsedCommand as CoreParsedCommand;
|
||||
use code_tui::test_helpers::{render_chat_widget_to_vt100, ChatWidgetHarness};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -235,6 +238,241 @@ fn synthetic_end_clears_cancelled_exec_spinner() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_tool_missing_background_job_clears_exec_wait() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let mut seq = 0_u64;
|
||||
let exec_call_id = "call_wait_missing";
|
||||
let wait_call_id = "wait-1";
|
||||
let cwd = PathBuf::from("/tmp");
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "exec-begin".to_string(),
|
||||
event_seq: 0,
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: exec_call_id.to_string(),
|
||||
command: vec!["bash".into(), "-lc".into(), "gh run watch".into()],
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: Vec::new(),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "wait-begin".to_string(),
|
||||
event_seq: 1,
|
||||
msg: EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
|
||||
call_id: wait_call_id.to_string(),
|
||||
tool_name: "wait".to_string(),
|
||||
parameters: Some(serde_json::json!({"call_id": exec_call_id})),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "wait-end".to_string(),
|
||||
event_seq: 2,
|
||||
msg: EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
|
||||
call_id: wait_call_id.to_string(),
|
||||
tool_name: "wait".to_string(),
|
||||
parameters: Some(serde_json::json!({"call_id": exec_call_id})),
|
||||
duration: Duration::from_secs(5),
|
||||
result: Err(format!(
|
||||
"No background job found for call_id={exec_call_id}"
|
||||
)),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
let output = render_chat_widget_to_vt100(&mut harness, 80, 14);
|
||||
assert!(
|
||||
!output.contains("Waiting..."),
|
||||
"wait status should clear when background job is missing:\n{}",
|
||||
output
|
||||
);
|
||||
assert!(
|
||||
output.contains("No background job found for call_id=call_wait_missing"),
|
||||
"missing-job note should be visible:\n{}",
|
||||
output
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_interrupt_after_exec_end_does_not_mutate_exec() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let mut seq = 0_u64;
|
||||
let exec_call_id = "call_wait_interrupt";
|
||||
let wait_call_id = "wait-2";
|
||||
let cwd = PathBuf::from("/tmp");
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "exec-begin".to_string(),
|
||||
event_seq: 0,
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: exec_call_id.to_string(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo done".into()],
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: Vec::new(),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "wait-begin".to_string(),
|
||||
event_seq: 1,
|
||||
msg: EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
|
||||
call_id: wait_call_id.to_string(),
|
||||
tool_name: "wait".to_string(),
|
||||
parameters: Some(serde_json::json!({"call_id": exec_call_id})),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "exec-end".to_string(),
|
||||
event_seq: 2,
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: exec_call_id.to_string(),
|
||||
stdout: "done".to_string(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
duration: Duration::from_millis(5),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "wait-end".to_string(),
|
||||
event_seq: 3,
|
||||
msg: EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
|
||||
call_id: wait_call_id.to_string(),
|
||||
tool_name: "wait".to_string(),
|
||||
parameters: Some(serde_json::json!({"call_id": exec_call_id})),
|
||||
duration: Duration::from_secs(1),
|
||||
result: Err(format!(
|
||||
"wait ended due to new user message (background job {exec_call_id} still running)"
|
||||
)),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
let output = render_chat_widget_to_vt100(&mut harness, 80, 14);
|
||||
assert!(
|
||||
output.contains("done"),
|
||||
"exec output should remain visible:\n{}",
|
||||
output
|
||||
);
|
||||
assert!(
|
||||
!output.contains("wait ended due to new user message"),
|
||||
"wait interruption text should not overwrite completed exec:\n{}",
|
||||
output
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_missing_job_skips_merged_exec() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let mut seq = 0_u64;
|
||||
let exec_call_id = "call_wait_merge_a";
|
||||
let exec_call_id_b = "call_wait_merge_b";
|
||||
let wait_call_id = "wait-merge";
|
||||
let cwd = PathBuf::from("/tmp");
|
||||
let parsed_search = vec![CoreParsedCommand::Search {
|
||||
cmd: "rg foo".to_string(),
|
||||
query: Some("foo".to_string()),
|
||||
path: Some(".".to_string()),
|
||||
}];
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "exec-begin".to_string(),
|
||||
event_seq: 0,
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: exec_call_id.to_string(),
|
||||
command: vec!["rg".into(), "foo".into(), ".".into()],
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_search.clone(),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "wait-begin".to_string(),
|
||||
event_seq: 1,
|
||||
msg: EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
|
||||
call_id: wait_call_id.to_string(),
|
||||
tool_name: "wait".to_string(),
|
||||
parameters: Some(serde_json::json!({"call_id": exec_call_id})),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "exec-end".to_string(),
|
||||
event_seq: 2,
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: exec_call_id.to_string(),
|
||||
stdout: "match-1".to_string(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
duration: Duration::from_millis(5),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "exec-begin-2".to_string(),
|
||||
event_seq: 3,
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: exec_call_id_b.to_string(),
|
||||
command: vec!["rg".into(), "foo".into(), ".".into()],
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_search,
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "exec-end-2".to_string(),
|
||||
event_seq: 4,
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: exec_call_id_b.to_string(),
|
||||
stdout: "match-2".to_string(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
duration: Duration::from_millis(5),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "wait-end".to_string(),
|
||||
event_seq: 5,
|
||||
msg: EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
|
||||
call_id: wait_call_id.to_string(),
|
||||
tool_name: "wait".to_string(),
|
||||
parameters: Some(serde_json::json!({"call_id": exec_call_id})),
|
||||
duration: Duration::from_secs(1),
|
||||
result: Err(format!(
|
||||
"No background job found for call_id={exec_call_id}"
|
||||
)),
|
||||
}),
|
||||
order: Some(next_order_meta(1, &mut seq)),
|
||||
});
|
||||
|
||||
let output = render_chat_widget_to_vt100(&mut harness, 80, 16);
|
||||
let search_count = output.matches("Search foo in /tmp/.").count();
|
||||
assert!(
|
||||
search_count >= 2,
|
||||
"merged exec should retain both search entries:\n{}",
|
||||
output
|
||||
);
|
||||
assert!(
|
||||
!output.contains("No background job found"),
|
||||
"wait error should not overwrite merged exec:\n{}",
|
||||
output
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_begin_upgrades_running_tool_cell() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
|
||||
Reference in New Issue
Block a user