Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 77 additions & 6 deletions crates/tui/src/tui/footer_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,21 +171,44 @@ pub(crate) fn provider_wait_idle_secs(app: &App) -> u64 {
.unwrap_or(0)
}

/// `waiting for model` reason — kept short: just elapsed idle time. The
/// provider and model are already visible in the header bar, so repeating
/// them in the footer stall reason is noise. The structured incident logger
/// (`maybe_log_provider_wait_incident`) still captures the full detail for
/// diagnostics.
/// Idle threshold (seconds) above which the footer surfaces the elapsed
/// idle time during a provider wait. Below this threshold the footer shows
/// only the concise label without a running counter (#3189).
const PROVIDER_WAIT_IDLE_SHOW_SECS: u64 = 60;

/// `waiting for model` reason — kept short by default: only the label when
/// the idle time is below [`PROVIDER_WAIT_IDLE_SHOW_SECS`]. Once the idle
/// exceeds that threshold the elapsed seconds appear, and when the idle
/// approaches the stream-idle budget the full `Ns/Ms idle timeout` detail
/// surfaces so the user knows the stream is at risk of timing out (#3189).
/// Provider and model stay in the header bar; the structured incident logger
/// (`maybe_log_provider_wait_incident`) captures full diagnostics regardless
/// of the footer copy.
fn provider_wait_reason(app: &App) -> String {
let idle = provider_wait_idle_secs(app);
let budget = app.stream_chunk_timeout_secs;

if running_agent_count(app) == 0 {
if let Some((0, total)) = active_fanout_counts(app) {
return format!("waiting · fanout 0/{total}");
} else if app.pending_subagent_dispatch.is_some() {
return "waiting · dispatch pending".to_string();
}
}
format!("waiting for model · {idle}s")

// Normal wait — no countdown noise.
if idle < PROVIDER_WAIT_IDLE_SHOW_SECS {
return "waiting for model".to_string();
}

// Significant idle — surface the elapsed seconds so the user can judge
// whether the stream is making progress.
let near_timeout = budget > 0 && idle >= budget.saturating_mul(3) / 4; // ≥ 75%
if near_timeout {
format!("waiting for model · {idle}s/{budget}s idle timeout")
} else {
format!("waiting for model · {idle}s")
}
Comment on lines +199 to +211

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a user configures a small stream_chunk_timeout_secs (e.g., less than 60 seconds, such as 30 seconds), the early return if idle < PROVIDER_WAIT_IDLE_SHOW_SECS will prevent the near_timeout warning from ever being displayed, even when the stream is extremely close to timing out (e.g., at 25 seconds of idle time).

Checking near_timeout first ensures that the timeout warning is always surfaced when the stream is at risk, regardless of whether the idle time has crossed the 60-second threshold.

    let near_timeout = budget > 0 && idle >= budget.saturating_mul(3) / 4; // ≥ 75%
    if near_timeout {
        return format!("waiting for model  · {idle}s/{budget}s idle timeout");
    }

    // Normal wait — no countdown noise.
    if idle < PROVIDER_WAIT_IDLE_SHOW_SECS {
        return "waiting for model".to_string();
    }

    format!("waiting for model  · {idle}s")

}

/// Threshold after which a provider wait with a planned fanout is logged as
Expand Down Expand Up @@ -360,6 +383,54 @@ mod tests {
let (label, _) = footer_state_label(&app);
assert_eq!(label, "idle");
}

// #3189: provider-wait reason thresholds

#[test]
fn provider_wait_reason_fresh_show_only_label() {
let mut app = create_test_app();
app.stream_chunk_timeout_secs = 300;
app.turn_started_at = Some(std::time::Instant::now()); // < 60s
let reason = super::provider_wait_reason(&app);
assert_eq!(reason, "waiting for model");
assert!(!reason.contains("idle"));
assert!(!reason.contains("s/"));
}

#[test]
fn provider_wait_reason_thresholded_show_idle_seconds() {
let mut app = create_test_app();
app.stream_chunk_timeout_secs = 300;
// Simulate idle >= 60s
app.turn_started_at = Some(std::time::Instant::now() - std::time::Duration::from_secs(60));
let reason = super::provider_wait_reason(&app);
assert!(reason.contains("waiting for model"));
assert!(reason.contains("60s"));
// Should NOT show the full timeout budget yet (<75% of 300s = 225s)
assert!(!reason.contains("/300s"));
}

#[test]
fn provider_wait_reason_near_timeout_show_full_idle_budget() {
let mut app = create_test_app();
app.stream_chunk_timeout_secs = 300;
// ≥ 75% of 300s = 225s
app.turn_started_at = Some(std::time::Instant::now() - std::time::Duration::from_secs(240));
let reason = super::provider_wait_reason(&app);
assert!(reason.contains("waiting for model"));
assert!(reason.contains("/300s idle timeout"));
assert!(reason.contains("240s"));
}

#[test]
fn provider_wait_reason_dispatch_pending() {
let mut app = create_test_app();
app.stream_chunk_timeout_secs = 300;
app.turn_started_at = Some(std::time::Instant::now());
app.pending_subagent_dispatch = Some("test".to_string());
let reason = super::provider_wait_reason(&app);
assert_eq!(reason, "waiting · dispatch pending");
}
}

pub(crate) fn is_noisy_subagent_progress(status: &str) -> bool {
Expand Down
10 changes: 7 additions & 3 deletions crates/tui/src/tui/ui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4483,12 +4483,16 @@ fn fanout_interrupted_mailbox_drops_running_count() {
fn stall_reason_provider_wait_includes_route_and_idle_budget() {
let mut app = create_test_app();
app.is_loading = true;
app.turn_started_at = Some(Instant::now() - Duration::from_secs(45));
app.turn_last_activity_at = Some(Instant::now() - Duration::from_secs(40));
app.stream_chunk_timeout_secs = 300;
// Set idle to 65s so it exceeds the 60s threshold (#3189).
app.turn_started_at = Some(Instant::now() - Duration::from_secs(70));
app.turn_last_activity_at = Some(Instant::now() - Duration::from_secs(65));

let reason = crate::tui::footer_ui::stall_reason(&app).expect("stalled turn has a reason");
assert!(reason.contains("waiting for model"), "{reason}");
assert!(reason.contains("40s"), "{reason}");
// idle >= 60s, so the counter appears, but < 75% budget (225s) so no budget detail.
assert!(reason.contains("65s"), "{reason}");
assert!(!reason.contains("/300s"), "{reason}");
}

#[test]
Expand Down