fix(tui/exec): handle missing wait output

This commit is contained in:
James Peter
2025-12-28 19:10:44 +10:00
parent a481b540bf
commit d1cc1a2b6d
3 changed files with 480 additions and 7 deletions
+65 -7
View File
@@ -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, &note_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());
+177
View File
@@ -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;
+238
View File
@@ -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();