use super::*;
use crate::tui_internal::bottom_pane::goal_status_indicator_line;
use crate::tui_internal::chatwidget::rate_limits::NUDGE_MODEL_SLUG;
use crate::tui_internal::chatwidget::rate_limits::get_limits_duration;
use lemurclaw_core::app_server_protocol::SpendControlLimitSnapshot;
use pretty_assertions::assert_eq;
use ratatui::backend::TestBackend;
use serial_test::serial;
fn enable_test_ambient_pet(chat: &mut ChatWidget) {
chat.set_pet_image_support_for_tests(crate::tui_internal::pets::PetImageSupport::Supported(
crate::tui_internal::pets::ImageProtocol::Kitty,
));
chat.install_test_ambient_pet_for_tests( false);
}
fn take_workspace_headline_request_id(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> u64 {
match rx.try_recv() {
Ok(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }) => request_id,
event => panic!("expected workspace headline refresh, got {event:?}"),
}
}
#[tokio::test]
async fn token_count_none_resets_context_indicator() {
let (mut chat, _rx, _ops) = make_chatwidget_manual( None).await;
let context_window = 13_000;
let pre_compact_tokens = 12_700;
handle_token_count(
&mut chat,
Some(make_token_info(pre_compact_tokens, context_window)),
);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(30));
handle_token_count(&mut chat, None);
assert_eq!(chat.bottom_pane.context_window_percent(), None);
}
#[tokio::test]
async fn app_server_cyber_policy_error_renders_dedicated_notice() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual( None).await;
handle_error(
&mut chat,
"server fallback message",
Some(CodexErrorInfo::CyberPolicy),
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(rendered.contains("This content can't be shown"));
assert!(rendered.contains("extra caution with cybersecurity requests"));
assert!(!rendered.contains("server fallback message"));
}
#[tokio::test]
async fn app_server_model_verification_renders_warning() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual( None).await;
handle_model_verification(
&mut chat,
vec![AppServerModelVerification::TrustedAccessForCyber],
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(rendered.contains("multiple flags for possible cybersecurity risk"));
assert!(rendered.contains("extra safety checks are on"));
assert!(rendered.contains("Trusted Access for Cyber"));
assert!(rendered.contains("https://chatgpt.com/cyber"));
}
#[tokio::test]
async fn context_indicator_shows_used_tokens_when_window_unknown() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")).await;
chat.config.model_context_window = None;
let auto_compact_limit = 200_000;
chat.config.model_auto_compact_token_limit = Some(auto_compact_limit);
let total_tokens = 106_000;
let token_usage = TokenUsage {
total_tokens,
..TokenUsage::default()
};
let token_info = TokenUsageInfo {
total_token_usage: token_usage.clone(),
last_token_usage: token_usage,
model_context_window: None,
};
handle_token_count(&mut chat, Some(token_info));
assert_eq!(chat.bottom_pane.context_window_percent(), None);
assert_eq!(
chat.bottom_pane.context_window_used_tokens(),
Some(total_tokens)
);
}
#[tokio::test]
async fn token_usage_update_uses_runtime_context_window() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual( None).await;
chat.config.model_context_window = Some(1_000_000);
handle_token_count(
&mut chat,
Some(make_token_info(
0, 950_000,
)),
);
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::ContextWindowSize),
Some("950K window".to_string())
);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(100));
chat.add_status_output(
false, None,
);
let cells = drain_insert_history(&mut rx);
let context_line = cells
.last()
.expect("status output inserted")
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.find(|line| line.contains("Context window"))
.expect("context window line");
assert!(
context_line.contains("950K"),
"expected /status to use runtime context window, got: {context_line}"
);
assert!(
!context_line.contains("1M"),
"expected /status to avoid raw config context window, got: {context_line}"
);
}
#[tokio::test]
async fn status_line_git_summary_items_render_values() {
let (mut chat, _rx, _ops) = make_chatwidget_manual( None).await;
chat.status_line_git_summary = Some(StatusLineGitSummary {
pull_request: Some(crate::tui_internal::branch_summary::StatusLinePullRequest {
number: 20_252,
url: "https://github.com/openai/codex/pull/20252".to_string(),
}),
branch_change_stats: Some(crate::tui_internal::branch_summary::GitBranchDiffStats {
additions: 143,
deletions: 22,
}),
});
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::PullRequestNumber),
Some("PR #20252".to_string())
);
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::BranchChanges),
Some("+143 -22".to_string())
);
}
#[tokio::test]
async fn raw_output_status_line_value_only_shows_when_enabled() {
let (mut chat, _rx, _ops) = make_chatwidget_manual( None).await;
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::RawOutput),
None
);
chat.set_raw_output_mode( true);
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::RawOutput),
Some("raw output".to_string())
);
}
#[tokio::test]
async fn status_line_branch_changes_render_no_changes() {
let (mut chat, _rx, _ops) = make_chatwidget_manual( None).await;
chat.status_line_git_summary = Some(StatusLineGitSummary {
pull_request: None,
branch_change_stats: Some(crate::tui_internal::branch_summary::GitBranchDiffStats {
additions: 0,
deletions: 0,
}),
});
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::BranchChanges),
Some("No changes".to_string())
);
}
#[tokio::test]
async fn stale_status_line_git_summary_update_is_ignored() {
let (mut chat, _rx, _ops) = make_chatwidget_manual( None).await;
chat.status_line_git_summary_cwd = Some(PathBuf::from("/expected"));
chat.status_line_git_summary_pending = true;
chat.set_status_line_git_summary(
PathBuf::from("/other"),
StatusLineGitSummary {
pull_request: Some(crate::tui_internal::branch_summary::StatusLinePullRequest {
number: 20_252,
url: "https://github.com/openai/codex/pull/20252".to_string(),
}),
branch_change_stats: Some(crate::tui_internal::branch_summary::GitBranchDiffStats {
additions: 143,
deletions: 22,
}),
},
);
assert!(chat.status_line_git_summary.is_none());
assert!(!chat.status_line_git_summary_pending);
}
#[tokio::test]
async fn raw_output_mode_can_change_without_inserting_notice() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual( None).await;
chat.set_raw_output_mode( true);
assert!(chat.raw_output_mode());
assert!(drain_insert_history(&mut rx).is_empty());
chat.set_raw_output_mode_and_notify( false);
assert!(!chat.raw_output_mode());
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
history.contains("Raw output mode off: rich transcript rendering restored."),
"expected raw output notice, got {history:?}"
);
}
#[tokio::test]
async fn flush_answer_stream_keeps_default_reflow_for_plain_text_tail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller = crate::tui_internal::streaming::controller::StreamController::new(
Some(80),
cwd.as_path(),
HistoryRenderMode::Rich,
);
assert!(controller.push("plain response line\n"));
chat.stream_controller = Some(controller);
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_consolidate = false;
let mut saw_insert_history = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(_) => saw_insert_history = true,
AppEvent::ConsolidateAgentMessage {
scrollback_reflow,
deferred_history_cell,
..
} => {
saw_consolidate = true;
assert_eq!(
scrollback_reflow,
crate::tui_internal::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan
);
assert!(deferred_history_cell.is_none());
}
_ => {}
}
}
assert!(
saw_consolidate,
"expected stream finalization to consolidate"
);
assert!(
saw_insert_history,
"plain text should still insert history before consolidation"
);
}
#[tokio::test]
async fn flush_answer_stream_requests_scrollback_reflow_for_live_table_tail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller = crate::tui_internal::streaming::controller::StreamController::new(
Some(80),
cwd.as_path(),
HistoryRenderMode::Rich,
);
controller.push("| Name | Notes |\n");
controller.push("| --- | --- |\n");
controller.push("| alpha | tail held until final table render |\n");
assert!(
controller.has_live_tail(),
"expected table holdback to leave a live tail for this regression",
);
chat.stream_controller = Some(controller);
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_consolidate = false;
let mut saw_insert_history = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(_) => saw_insert_history = true,
AppEvent::ConsolidateAgentMessage {
scrollback_reflow,
deferred_history_cell,
..
} => {
saw_consolidate = true;
assert_eq!(
scrollback_reflow,
crate::tui_internal::app_event::ConsolidationScrollbackReflow::Required
);
assert!(
deferred_history_cell.is_some(),
"live table tail should be staged for consolidation",
);
}
_ => {}
}
}
assert!(
saw_consolidate,
"expected stream finalization to consolidate"
);
assert!(
!saw_insert_history,
"live table tail should not be inserted before canonical reflow"
);
}
#[tokio::test]
async fn completed_plan_table_tail_skips_provisional_history_insert() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller = crate::tui_internal::streaming::controller::PlanStreamController::new(
Some(80),
cwd.as_path(),
HistoryRenderMode::Rich,
);
controller.push("| Step | Owner |\n");
controller.push("| --- | --- |\n");
controller.push("| Verify | Codex |\n");
assert!(
controller.has_live_tail(),
"expected plan table holdback to leave a live tail",
);
chat.plan_stream_controller = Some(controller);
chat.transcript.plan_delta_buffer =
"| Step | Owner |\n| --- | --- |\n| Verify | Codex |\n".to_string();
while rx.try_recv().is_ok() {}
chat.on_plan_item_completed(String::new());
let mut saw_source_backed_plan = false;
let mut saw_stream_plan = false;
let mut rendered_plan = String::new();
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
if cell.as_any().is::<history_cell::ProposedPlanCell>() {
saw_source_backed_plan = true;
rendered_plan = lines_to_single_string(&cell.display_lines( 80));
}
saw_stream_plan |= cell.as_any().is::<history_cell::ProposedPlanStreamCell>();
}
}
assert!(saw_source_backed_plan, "expected source-backed plan insert");
assert!(
rendered_plan.contains('━'),
"expected completed plan table to render with separators, got: {rendered_plan:?}"
);
assert!(
!saw_stream_plan,
"live plan table tail should not be inserted provisionally"
);
}
#[tokio::test]
#[cfg_attr(target_os = "windows", ignore = "disabled on windows")]
async fn configured_pet_load_is_deferred_until_after_construction() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut cfg = test_config().await;
cfg.tui_pet = Some(crate::tui_internal::pets::DEFAULT_PET_ID.to_string());
crate::tui_internal::pets::write_test_pack(&cfg.codex_home);
let resolved_model = get_model_offline_for_tests(cfg.model.as_deref());
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
let init = ChatWidgetInit {
config: cfg.clone(),
frame_requester: FrameRequester::test_dummy(),
app_event_tx: tx,
workspace_command_runner: None,
initial_user_message: None,
enhanced_keys_supported: false,
has_chatgpt_account: false,
has_codex_backend_auth: false,
model_catalog: test_model_catalog(&cfg),
feedback: lemurclaw_core::feedback::CodexFeedback::new(),
is_first_run: true,
status_account_display: None,
runtime_model_provider_base_url: None,
initial_plan_type: None,
model: Some(resolved_model),
startup_tooltip_override: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
session_telemetry,
};
let chat = ChatWidget::new_with_app_event(init);
assert!(!chat.ambient_pet_image_enabled());
let event = tokio::time::timeout(std::time::Duration::from_secs( 30), rx.recv())
.await
.unwrap()
.unwrap();
assert_matches!(
event,
AppEvent::ConfiguredPetLoaded { pet_id, result } => {
assert_eq!(pet_id, crate::tui_internal::pets::DEFAULT_PET_ID);
assert!(result.unwrap().is_some());
}
);
}
#[tokio::test]
async fn prefetch_rate_limits_is_gated_on_chatgpt_auth_provider() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
assert!(!chat.should_prefetch_rate_limits());
set_chatgpt_auth(&mut chat);
assert!(chat.should_prefetch_rate_limits());
chat.config.model_provider.requires_openai_auth = false;
assert!(!chat.should_prefetch_rate_limits());
chat.prefetch_rate_limits();
assert!(!chat.should_prefetch_rate_limits());
}
#[tokio::test]
async fn rate_limit_warnings_emit_thresholds() {
let mut state = RateLimitWarningState::default();
let mut warnings: Vec<String> = Vec::new();
warnings.extend(state.take_warnings(Some(10.0), Some(10079), Some(55.0), Some(299)));
warnings.extend(state.take_warnings(Some(55.0), Some(10081), Some(10.0), Some(299)));
warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(80.0), Some(299)));
warnings.extend(state.take_warnings(Some(80.0), Some(10081), Some(10.0), Some(299)));
warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(95.0), Some(299)));
warnings.extend(state.take_warnings(Some(95.0), Some(10079), Some(10.0), Some(299)));
assert_eq!(
warnings,
vec![
String::from(
"Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown."
),
String::from(
"Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.",
),
String::from(
"Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown."
),
String::from(
"Heads up, you have less than 5% of your weekly limit left. Run /status for a breakdown.",
),
],
"expected one warning per limit for the highest crossed threshold"
);
}
#[tokio::test]
async fn test_rate_limit_warnings_monthly() {
let mut state = RateLimitWarningState::default();
let mut warnings: Vec<String> = Vec::new();
warnings.extend(state.take_warnings(
Some(75.0),
Some(43199),
None,
None,
));
assert_eq!(
warnings,
vec![String::from(
"Heads up, you have less than 25% of your monthly limit left. Run /status for a breakdown.",
),],
"expected one warning per limit for the highest crossed threshold"
);
}
#[test]
fn rate_limit_duration_labels_only_render_supported_windows() {
assert_eq!(get_limits_duration(2 * 60), None);
assert_eq!(get_limits_duration(24 * 60).as_deref(), Some("daily"));
assert_eq!(
get_limits_duration(365 * 24 * 60).as_deref(),
Some("annual")
);
}
#[tokio::test]
async fn test_rate_limit_warnings_use_generic_fallback_labels() {
let mut state = RateLimitWarningState::default();
assert_eq!(
state.take_warnings(
Some(75.0),
None,
Some(75.0),
None,
),
vec![
String::from(
"Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.",
),
String::from(
"Heads up, you have less than 25% of your usage limit left. Run /status for a breakdown.",
),
],
);
}
#[tokio::test]
async fn test_rate_limit_warnings_use_secondary_fallback_for_unsupported_window() {
let mut state = RateLimitWarningState::default();
assert_eq!(
state.take_warnings(
Some(75.0),
Some(2 * 60),
None,
None,
),
vec![String::from(
"Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.",
)],
);
}
#[tokio::test]
async fn status_line_uses_secondary_fallback_for_unsupported_window() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: Some(RateLimitWindow {
used_percent: 50,
window_duration_mins: Some(2 * 60),
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: None,
spend_control_reached: None,
rate_limit_reached_type: None,
}));
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::WeeklyLimit),
Some("secondary usage 50% left".to_string())
);
}
#[tokio::test]
async fn status_line_legacy_limit_items_prefer_matching_windows() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 94,
window_duration_mins: Some(7 * 24 * 60),
resets_at: None,
}),
secondary: Some(RateLimitWindow {
used_percent: 40,
window_duration_mins: Some(5 * 60),
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: None,
spend_control_reached: None,
rate_limit_reached_type: None,
}));
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::FiveHourLimit),
Some("5h 60% left".to_string())
);
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::WeeklyLimit),
Some("weekly 6% left".to_string())
);
}
#[tokio::test]
async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 94,
window_duration_mins: Some(7 * 24 * 60),
resets_at: None,
}),
secondary: Some(RateLimitWindow {
used_percent: 35,
window_duration_mins: Some(30 * 24 * 60),
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: None,
spend_control_reached: None,
rate_limit_reached_type: None,
}));
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::FiveHourLimit),
Some("monthly 65% left".to_string())
);
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::WeeklyLimit),
Some("weekly 6% left".to_string())
);
}
#[tokio::test]
async fn status_line_five_hour_item_omits_weekly_only_limit() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 9,
window_duration_mins: Some(7 * 24 * 60),
resets_at: None,
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
spend_control_reached: None,
rate_limit_reached_type: None,
}));
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::FiveHourLimit),
None
);
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::WeeklyLimit),
Some("weekly 91% left".to_string())
);
}
#[tokio::test]
async fn status_line_single_monthly_primary_omits_weekly_limit_item() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 35,
window_duration_mins: Some(30 * 24 * 60),
resets_at: None,
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
spend_control_reached: None,
rate_limit_reached_type: None,
}));
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::FiveHourLimit),
Some("monthly 65% left".to_string())
);
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::WeeklyLimit),
None
);
}
#[tokio::test]
async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: Some(RateLimitWindow {
used_percent: 35,
window_duration_mins: Some(30 * 24 * 60),
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: None,
spend_control_reached: None,
rate_limit_reached_type: None,
}));
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::FiveHourLimit),
None
);
assert_eq!(
chat.status_line_value_for_item(crate::tui_internal::bottom_pane::StatusLineItem::WeeklyLimit),
Some("monthly 65% left".to_string())
);
}
#[tokio::test]
async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("17.5".to_string()),
}),
individual_limit: None,
plan_type: None,
spend_control_reached: None,
rate_limit_reached_type: None,
}));
let initial_balance = chat
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|snapshot| snapshot.credits.as_ref())
.and_then(|credits| credits.balance.as_deref());
assert_eq!(initial_balance, Some("17.5"));
chat.on_rolling_rate_limit_snapshot(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 80,
window_duration_mins: Some(60),
resets_at: Some(123),
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
spend_control_reached: None,
rate_limit_reached_type: None,
});
let display = chat
.rate_limit_snapshots_by_limit_id
.get("codex")
.expect("rate limits should be cached");
let credits = display
.credits
.as_ref()
.expect("credits should persist when headers omit them");
assert_eq!(credits.balance.as_deref(), Some("17.5"));
assert!(!credits.unlimited);
assert_eq!(
display.primary.as_ref().map(|window| window.used_percent),
Some(80.0)
);
}
#[tokio::test]
async fn rolling_rate_limit_snapshot_preserves_prior_individual_limit() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
let mut usage_limits = snapshot( 10.0);
usage_limits.individual_limit = Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: 1_800_000_000,
});
chat.on_rate_limit_snapshot(Some(usage_limits));
chat.on_rolling_rate_limit_snapshot(snapshot( 20.0));
let display = chat
.rate_limit_snapshots_by_limit_id
.get("codex")
.expect("rate limits should be cached");
let individual_limit = display
.individual_limit
.as_ref()
.expect("rolling updates should preserve monthly limits");
assert_eq!(individual_limit.used, "8,000");
assert_eq!(individual_limit.limit, "25,000");
assert_eq!(individual_limit.percent_remaining, 68.0);
chat.on_rate_limit_snapshot(Some(snapshot( 30.0)));
let display = chat
.rate_limit_snapshots_by_limit_id
.get("codex")
.expect("rate limits should be cached");
assert!(display.individual_limit.is_none());
}
#[tokio::test]
async fn rate_limit_snapshot_updates_and_retains_plan_type() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 10,
window_duration_mins: Some(60),
resets_at: None,
}),
secondary: Some(RateLimitWindow {
used_percent: 5,
window_duration_mins: Some(300),
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: Some(PlanType::Plus),
spend_control_reached: None,
rate_limit_reached_type: None,
}));
assert_eq!(chat.plan_type, Some(PlanType::Plus));
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 25,
window_duration_mins: Some(30),
resets_at: Some(123),
}),
secondary: Some(RateLimitWindow {
used_percent: 15,
window_duration_mins: Some(300),
resets_at: Some(234),
}),
credits: None,
individual_limit: None,
plan_type: Some(PlanType::Pro),
spend_control_reached: None,
rate_limit_reached_type: None,
}));
assert_eq!(chat.plan_type, Some(PlanType::Pro));
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 30,
window_duration_mins: Some(60),
resets_at: Some(456),
}),
secondary: Some(RateLimitWindow {
used_percent: 18,
window_duration_mins: Some(300),
resets_at: Some(567),
}),
credits: None,
individual_limit: None,
plan_type: None,
spend_control_reached: None,
rate_limit_reached_type: None,
}));
assert_eq!(chat.plan_type, Some(PlanType::Pro));
}
#[tokio::test]
async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: Some(RateLimitWindow {
used_percent: 20,
window_duration_mins: Some(300),
resets_at: Some(100),
}),
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("5.00".to_string()),
}),
individual_limit: None,
plan_type: Some(PlanType::Pro),
spend_control_reached: None,
rate_limit_reached_type: None,
}));
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
primary: Some(RateLimitWindow {
used_percent: 90,
window_duration_mins: Some(60),
resets_at: Some(200),
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(PlanType::Pro),
spend_control_reached: None,
rate_limit_reached_type: None,
}));
let codex = chat
.rate_limit_snapshots_by_limit_id
.get("codex")
.expect("codex snapshot should exist");
let other = chat
.rate_limit_snapshots_by_limit_id
.get("codex_other")
.expect("codex_other snapshot should exist");
assert_eq!(codex.primary.as_ref().map(|w| w.used_percent), Some(20.0));
assert_eq!(
codex
.credits
.as_ref()
.and_then(|credits| credits.balance.as_deref()),
Some("5.00")
);
assert_eq!(other.primary.as_ref().map(|w| w.used_percent), Some(90.0));
assert!(other.credits.is_none());
}
#[tokio::test]
async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() {
let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(snapshot( 95.0)));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Idle
));
}
#[tokio::test]
async fn rate_limit_switch_prompt_skips_non_codex_limit() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
primary: Some(RateLimitWindow {
used_percent: 95,
window_duration_mins: Some(60),
resets_at: None,
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
spend_control_reached: None,
rate_limit_reached_type: None,
}));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Idle
));
}
#[tokio::test]
async fn rate_limit_usage_warnings_follow_workspace_credit_flags() {
for (credits, should_warn) in [
(
CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
},
false,
),
(
CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some(String::new()),
},
false,
),
(
CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("0".to_string()),
},
false,
),
(
CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("not-a-number".to_string()),
},
false,
),
(
CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("25.00".to_string()),
},
false,
),
(
CreditsSnapshot {
has_credits: false,
unlimited: true,
balance: None,
},
false,
),
(
CreditsSnapshot {
has_credits: false,
unlimited: false,
balance: Some("25.00".to_string()),
},
true,
),
] {
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
let mut rate_limit_snapshot = snapshot( 95.0);
rate_limit_snapshot.credits = Some(credits);
chat.on_rate_limit_snapshot(Some(rate_limit_snapshot));
assert_eq!(!drain_insert_history(&mut rx).is_empty(), should_warn);
assert_eq!(
matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
),
should_warn
);
assert_eq!(chat.rate_limit_warnings.primary_index > 0, should_warn);
}
}
#[tokio::test]
async fn rate_limit_usage_warnings_show_when_authoritative_snapshot_clears_credits() {
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
let mut initial_snapshot = snapshot( 0.0);
initial_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
chat.on_rate_limit_snapshot(Some(initial_snapshot));
chat.on_rate_limit_snapshot(Some(snapshot( 95.0)));
assert!(
!drain_insert_history(&mut rx).is_empty(),
"an authoritative snapshot without credits should clear stale credit availability"
);
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
}
#[tokio::test]
async fn rate_limit_switch_prompt_clears_pending_when_workspace_credits_become_usable() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(snapshot( 95.0)));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
let mut funded_snapshot = snapshot( 95.0);
funded_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
chat.on_rate_limit_snapshot(Some(funded_snapshot));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Idle
));
}
#[tokio::test]
async fn rate_limit_switch_prompt_dismisses_shown_when_workspace_credits_become_usable() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(snapshot( 95.0)));
chat.maybe_show_pending_rate_limit_prompt();
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
));
assert!(!chat.bottom_pane.no_modal_or_popup_active());
let mut funded_snapshot = snapshot( 95.0);
funded_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
chat.on_rate_limit_snapshot(Some(funded_snapshot));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
));
assert!(chat.bottom_pane.no_modal_or_popup_active());
chat.on_rate_limit_snapshot(Some(snapshot( 0.0)));
chat.on_rate_limit_snapshot(Some(snapshot( 95.0)));
chat.maybe_show_pending_rate_limit_prompt();
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
));
assert!(chat.bottom_pane.no_modal_or_popup_active());
}
#[tokio::test]
async fn rate_limit_usage_warnings_preserve_workspace_limit_for_sparse_snapshots() {
for rate_limit_reached_type in [
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
] {
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
let mut blocked_snapshot = snapshot( 0.0);
blocked_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
blocked_snapshot.rate_limit_reached_type = Some(rate_limit_reached_type);
chat.on_rate_limit_snapshot(Some(blocked_snapshot));
chat.on_rolling_rate_limit_snapshot(snapshot( 95.0));
assert!(
!drain_insert_history(&mut rx).is_empty(),
"an explicit workspace hard stop should keep proactive usage warnings enabled"
);
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
}
}
#[tokio::test]
async fn rate_limit_usage_warnings_keep_workspace_limit_after_rolling_credits() {
for rate_limit_reached_type in [
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
] {
for credits in [
CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
},
CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some(String::new()),
},
CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("25.00".to_string()),
},
CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("0".to_string()),
},
CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("not-a-number".to_string()),
},
CreditsSnapshot {
has_credits: false,
unlimited: true,
balance: None,
},
] {
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
let mut blocked_snapshot = snapshot( 0.0);
blocked_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
blocked_snapshot.rate_limit_reached_type = Some(rate_limit_reached_type);
chat.on_rolling_rate_limit_snapshot(blocked_snapshot);
let mut rolling_snapshot = snapshot( 95.0);
rolling_snapshot.credits = Some(credits);
chat.on_rolling_rate_limit_snapshot(rolling_snapshot);
assert!(
!drain_insert_history(&mut rx).is_empty(),
"usable rolling workspace credits must not suppress an existing workspace hard stop"
);
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
assert_eq!(
chat.codex_rate_limit_reached_type,
Some(rate_limit_reached_type)
);
}
}
}
#[tokio::test]
async fn rate_limit_usage_warnings_keep_explicit_rolling_workspace_limit() {
for rate_limit_reached_type in [
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
] {
for spend_control_reached in [None, Some(false)] {
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
let mut rolling_snapshot = snapshot( 95.0);
rolling_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
rolling_snapshot.rate_limit_reached_type = Some(rate_limit_reached_type);
rolling_snapshot.spend_control_reached = spend_control_reached;
chat.on_rolling_rate_limit_snapshot(rolling_snapshot);
assert!(
!drain_insert_history(&mut rx).is_empty(),
"an explicit rolling workspace hard stop should keep proactive usage warnings enabled"
);
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
assert_eq!(
chat.codex_rate_limit_reached_type,
Some(rate_limit_reached_type)
);
}
}
}
#[tokio::test]
async fn rate_limit_usage_warnings_keep_newly_reached_workspace_limit() {
for (limit_id, should_warn) in [("codex", true), ("codex_other", false)] {
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
let mut initial_snapshot = snapshot( 0.0);
initial_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
initial_snapshot.spend_control_reached = Some(false);
chat.on_rate_limit_snapshot(Some(initial_snapshot));
let mut capped_snapshot = snapshot( 95.0);
capped_snapshot.limit_id = Some(limit_id.to_string());
capped_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
capped_snapshot.rate_limit_reached_type =
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
chat.on_rolling_rate_limit_snapshot(capped_snapshot);
assert_eq!(!drain_insert_history(&mut rx).is_empty(), should_warn);
assert_eq!(
matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
),
should_warn
);
assert_eq!(
chat.codex_rate_limit_reached_type,
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
);
chat.on_rate_limit_error(
RateLimitErrorKind::UsageLimit,
"Usage limit reached.".to_string(),
);
let popup = render_bottom_popup(&chat, 100);
assert!(popup.contains("Request a limit increase from your owner"));
}
}
#[tokio::test]
async fn rate_limit_usage_warnings_preserve_and_clear_spend_control_state() {
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
let mut blocked_snapshot = snapshot( 0.0);
blocked_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
blocked_snapshot.spend_control_reached = Some(true);
blocked_snapshot.rate_limit_reached_type =
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
chat.on_rate_limit_snapshot(Some(blocked_snapshot));
assert_eq!(chat.codex_spend_control_reached, Some(true));
chat.on_rolling_rate_limit_snapshot(snapshot( 95.0));
assert!(
!drain_insert_history(&mut rx).is_empty(),
"a sparse rolling snapshot should preserve a reached spend control"
);
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
assert_eq!(chat.codex_spend_control_reached, Some(true));
let mut recovered_snapshot = snapshot( 95.0);
recovered_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
recovered_snapshot.spend_control_reached = Some(false);
chat.on_rolling_rate_limit_snapshot(recovered_snapshot);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
assert_eq!(chat.codex_spend_control_reached, Some(false));
assert_eq!(
chat.codex_rate_limit_reached_type,
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
);
chat.on_rolling_rate_limit_snapshot(snapshot( 95.0));
assert!(
drain_insert_history(&mut rx).is_empty(),
"a later sparse rolling snapshot should not clear an existing workspace hard stop"
);
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
assert_eq!(chat.codex_spend_control_reached, Some(false));
assert_eq!(
chat.codex_rate_limit_reached_type,
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
);
}
#[tokio::test]
async fn rolling_credits_preserve_depleted_workspace_error_routing() {
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
let mut blocked_snapshot = snapshot( 0.0);
blocked_snapshot.rate_limit_reached_type =
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
chat.on_rate_limit_snapshot(Some(blocked_snapshot));
let mut rolling_snapshot = snapshot( 95.0);
rolling_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("0".to_string()),
});
rolling_snapshot.spend_control_reached = Some(false);
chat.on_rolling_rate_limit_snapshot(rolling_snapshot);
assert!(!drain_insert_history(&mut rx).is_empty());
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
assert_eq!(
chat.codex_rate_limit_reached_type,
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted)
);
chat.on_rate_limit_error(
RateLimitErrorKind::Generic,
"Usage limit reached.".to_string(),
);
let popup = render_bottom_popup(&chat, 100);
assert!(
popup.contains("Ask your workspace owner to add more"),
"popup: {popup}"
);
}
#[tokio::test]
async fn rate_limit_usage_warnings_clear_workspace_limit_from_authoritative_snapshot() {
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
let mut blocked_snapshot = snapshot( 0.0);
blocked_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
blocked_snapshot.rate_limit_reached_type =
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
chat.on_rate_limit_snapshot(Some(blocked_snapshot));
let mut recovered_snapshot = snapshot( 0.0);
recovered_snapshot.credits = Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
});
chat.on_rate_limit_snapshot(Some(recovered_snapshot));
chat.on_rolling_rate_limit_snapshot(snapshot( 95.0));
assert!(
drain_insert_history(&mut rx).is_empty(),
"an authoritative recovery should prevent sparse updates from restoring a stale limit"
);
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Idle
));
assert_eq!(chat.codex_rate_limit_reached_type, None);
}
#[tokio::test]
async fn rate_limit_switch_prompt_shows_once_per_session() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(snapshot( 90.0)));
assert!(
chat.rate_limit_warnings.primary_index >= 1,
"warnings not emitted"
);
chat.maybe_show_pending_rate_limit_prompt();
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
));
chat.on_rate_limit_snapshot(Some(snapshot( 95.0)));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
));
}
#[tokio::test]
async fn account_update_clears_derived_usage_limit_state_and_prompt() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
set_chatgpt_auth(&mut chat);
let mut limits = snapshot( 95.0);
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
limits.spend_control_reached = Some(true);
chat.on_rate_limit_snapshot(Some(limits));
chat.maybe_show_pending_rate_limit_prompt();
assert!(chat.rate_limit_warnings.primary_index > 0);
assert!(chat.codex_rate_limit_reached_type.is_some());
assert_eq!(chat.codex_spend_control_reached, Some(true));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
));
assert!(!chat.bottom_pane.no_modal_or_popup_active());
chat.update_account_state(
None, None,
true, true,
);
assert_eq!(chat.rate_limit_warnings.primary_index, 0);
assert_eq!(chat.rate_limit_warnings.secondary_index, 0);
assert_eq!(chat.codex_rate_limit_reached_type, None);
assert_eq!(chat.codex_spend_control_reached, None);
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Idle
));
assert!(chat.rate_limit_snapshots_by_limit_id.is_empty());
assert!(chat.bottom_pane.no_modal_or_popup_active());
}
#[tokio::test]
async fn rate_limit_switch_prompt_respects_hidden_notice() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.config.notices.hide_rate_limit_model_nudge = Some(true);
chat.on_rate_limit_snapshot(Some(snapshot( 95.0)));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Idle
));
}
#[tokio::test]
async fn rate_limit_switch_prompt_defers_until_task_complete() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.bottom_pane.set_task_running( true);
chat.on_rate_limit_snapshot(Some(snapshot( 90.0)));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
chat.bottom_pane.set_task_running( false);
chat.maybe_show_pending_rate_limit_prompt();
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
));
}
#[tokio::test]
async fn rate_limit_switch_prompt_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(snapshot( 92.0)));
chat.maybe_show_pending_rate_limit_prompt();
let popup = render_bottom_popup(&chat, 80);
assert_chatwidget_snapshot!("rate_limit_switch_prompt_popup", popup);
}
#[tokio::test]
async fn workspace_member_credits_depleted_prompts_and_sends_credits() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let mut limits = snapshot( 100.0);
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
chat.on_rate_limit_snapshot(Some(limits));
chat.on_rate_limit_error(
RateLimitErrorKind::Generic,
"Usage limit reached.".to_string(),
);
let popup = render_bottom_popup(&chat, 90);
assert_chatwidget_snapshot!("workspace_member_credits_depleted_prompt", popup);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let event = next_send_add_credits_nudge_email_event(&mut rx);
assert_eq!(event, AddCreditsNudgeCreditType::Credits);
}
#[tokio::test]
async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let mut limits = snapshot( 100.0);
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
chat.on_rate_limit_snapshot(Some(limits));
chat.on_rate_limit_error(
RateLimitErrorKind::UsageLimit,
"Usage limit reached.".to_string(),
);
let popup = render_bottom_popup(&chat, 100);
assert_chatwidget_snapshot!("workspace_member_usage_limit_prompt", popup);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let event = next_send_add_credits_nudge_email_event(&mut rx);
assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit);
}
#[tokio::test]
async fn sparse_rate_limit_snapshot_preserves_member_limit_type_for_error_prompt() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let mut usage_limits = snapshot( 100.0);
usage_limits.rate_limit_reached_type =
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
chat.on_rate_limit_snapshot(Some(usage_limits));
let mut rolling_limits = snapshot( 100.0);
rolling_limits.rate_limit_reached_type = None;
chat.on_rolling_rate_limit_snapshot(rolling_limits);
chat.on_rate_limit_error(
RateLimitErrorKind::UsageLimit,
"Usage limit reached.".to_string(),
);
let popup = render_bottom_popup(&chat, 100);
assert!(
popup.contains("Request a limit increase from your owner"),
"popup: {popup}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let event = next_send_add_credits_nudge_email_event(&mut rx);
assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit);
}
#[tokio::test]
async fn usage_limit_error_remaps_stale_member_credits_state_to_usage_limit_prompt() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let mut limits = snapshot( 100.0);
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
chat.on_rate_limit_snapshot(Some(limits));
chat.on_rate_limit_error(
RateLimitErrorKind::UsageLimit,
"Usage limit reached.".to_string(),
);
let popup = render_bottom_popup(&chat, 100);
assert!(
popup.contains("Request a limit increase from your owner"),
"popup: {popup}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let event = next_send_add_credits_nudge_email_event(&mut rx);
assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit);
}
#[tokio::test]
async fn workspace_owner_limit_states_do_not_prompt_for_owner_nudge() {
for (limit_type, error_kind) in [
(
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
RateLimitErrorKind::Generic,
),
(
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
RateLimitErrorKind::UsageLimit,
),
(
RateLimitReachedType::RateLimitReached,
RateLimitErrorKind::Generic,
),
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let mut limits = snapshot( 100.0);
limits.rate_limit_reached_type = Some(limit_type);
chat.on_rate_limit_snapshot(Some(limits));
chat.on_rate_limit_error(error_kind, "Usage limit reached.".to_string());
let popup = render_bottom_popup(&chat, 90);
assert!(!popup.contains("workspace owner"));
assert_no_owner_nudge_or_rate_limit_refresh(&mut rx);
}
}
#[tokio::test]
async fn workspace_owner_limit_states_render_state_specific_messages() {
let cases = [
(
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
RateLimitErrorKind::Generic,
"You're out of credits. Your workspace is out of credits. Add credits to continue using Codex.",
),
(
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
RateLimitErrorKind::UsageLimit,
"Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex.",
),
];
let mut rendered_cases = Vec::new();
for (limit_type, error_kind, expected) in cases {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let mut limits = snapshot( 100.0);
limits.rate_limit_reached_type = Some(limit_type);
chat.on_rate_limit_snapshot(Some(limits));
chat.on_rate_limit_error(error_kind, "Usage limit reached.".to_string());
let rendered = drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.collect::<String>();
assert!(rendered.contains(expected), "rendered: {rendered}");
rendered_cases.push(rendered);
}
assert_chatwidget_snapshot!(
"workspace_owner_limit_state_messages",
rendered_cases.join("\n---\n")
);
}
#[tokio::test]
async fn missing_rate_limit_reached_type_does_not_prompt_or_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_rate_limit_snapshot(Some(snapshot( 100.0)));
chat.on_rate_limit_error(
RateLimitErrorKind::UsageLimit,
"Usage limit reached.".to_string(),
);
let popup = render_bottom_popup(&chat, 90);
assert!(!popup.contains("workspace owner"));
assert_no_owner_nudge_or_rate_limit_refresh(&mut rx);
}
#[tokio::test]
async fn workspace_owner_nudge_default_no_dismisses_without_sending() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let mut limits = snapshot( 100.0);
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
chat.on_rate_limit_snapshot(Some(limits));
chat.on_rate_limit_error(
RateLimitErrorKind::Generic,
"Usage limit reached.".to_string(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_no_owner_nudge_or_rate_limit_refresh(&mut rx);
}
#[tokio::test]
async fn workspace_owner_nudge_reappears_after_dismissing_no() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let mut limits = snapshot( 100.0);
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
chat.on_rate_limit_snapshot(Some(limits));
chat.on_rate_limit_error(
RateLimitErrorKind::UsageLimit,
"Usage limit reached.".to_string(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_no_owner_nudge_or_rate_limit_refresh(&mut rx);
chat.on_rate_limit_error(
RateLimitErrorKind::UsageLimit,
"Usage limit reached.".to_string(),
);
let popup = render_bottom_popup(&chat, 100);
assert!(
popup.contains("Request a limit increase from your owner"),
"popup: {popup}"
);
}
#[tokio::test]
async fn workspace_owner_credits_nudge_completion_renders_feedback() {
let cases = [
(
Ok(AddCreditsNudgeEmailStatus::Sent),
"Workspace owner notified.",
),
(
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
"Workspace owner was already notified recently.",
),
(
Err("request failed".to_string()),
"Could not notify your workspace owner. Please try again.",
),
];
let mut rendered_cases = Vec::new();
for (result, expected) in cases {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits);
chat.finish_add_credits_nudge_email_request(result);
let rendered = drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.collect::<String>();
assert!(rendered.contains(expected), "rendered: {rendered}");
rendered_cases.push(rendered);
}
assert_chatwidget_snapshot!(
"workspace_owner_credits_nudge_completion_feedback",
rendered_cases.join("\n---\n")
);
}
#[tokio::test]
async fn workspace_owner_usage_limit_nudge_completion_renders_feedback() {
let cases = [
(
Ok(AddCreditsNudgeEmailStatus::Sent),
"Limit increase requested.",
),
(
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
"A limit increase was already requested recently.",
),
(
Err("request failed".to_string()),
"Could not request a limit increase. Please try again.",
),
];
let mut rendered_cases = Vec::new();
for (result, expected) in cases {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit);
chat.finish_add_credits_nudge_email_request(result);
let rendered = drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.collect::<String>();
assert!(rendered.contains(expected), "rendered: {rendered}");
rendered_cases.push(rendered);
}
assert_chatwidget_snapshot!(
"workspace_owner_usage_limit_nudge_completion_feedback",
rendered_cases.join("\n---\n")
);
}
fn next_send_add_credits_nudge_email_event(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> AddCreditsNudgeCreditType {
while let Ok(event) = rx.try_recv() {
if let AppEvent::SendAddCreditsNudgeEmail { credit_type } = event {
return credit_type;
}
}
panic!("expected SendAddCreditsNudgeEmail app event");
}
fn assert_no_owner_nudge_or_rate_limit_refresh(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) {
while let Ok(event) = rx.try_recv() {
assert!(
!matches!(
event,
AppEvent::SendAddCreditsNudgeEmail { .. } | AppEvent::RefreshRateLimits { .. }
),
"unexpected event: {event:?}"
);
}
}
#[tokio::test]
async fn streaming_final_answer_keeps_task_running_state() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual( None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert!(chat.bottom_pane.is_task_running());
assert!(!chat.bottom_pane.status_indicator_visible());
chat.bottom_pane
.set_composer_text("queued submission".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.input_queue.queued_user_messages.front().unwrap().text,
"queued submission"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
match op_rx.try_recv() {
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
}
#[tokio::test]
async fn single_line_final_answer_hides_working_status_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.thread_id = Some(ThreadId::new());
complete_user_message(&mut chat, "user-1", "count to 1");
chat.on_task_started();
complete_assistant_message(
&mut chat,
"msg-final-single-line",
"1",
Some(MessagePhase::FinalAnswer),
);
assert!(chat.bottom_pane.is_task_running());
assert!(!chat.bottom_pane.status_indicator_visible());
let width: u16 = 40;
let vt_height: u16 = 10;
let ui_height = chat.desired_height(width);
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
let backend = VT100Backend::new(width, vt_height);
let mut terminal = crate::tui_internal::custom_terminal::Terminal::with_options(backend).expect("terminal");
terminal.set_viewport_area(viewport);
for lines in drain_insert_history(&mut rx) {
crate::tui_internal::insert_history::insert_history_lines(&mut terminal, lines)
.expect("insert history lines");
}
terminal
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
.expect("draw final answer");
assert_chatwidget_snapshot!(
"single_line_final_answer_hides_working_status",
normalize_snapshot_paths(terminal.backend().vt100().screen().contents())
);
}
#[tokio::test]
async fn ctrl_c_interrupt_pauses_active_goal_turn() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual( None).await;
let thread_id = start_active_goal_turn(&mut chat);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
next_interrupt_op(&mut op_rx);
assert_goal_paused_event(&mut rx, thread_id);
}
#[tokio::test]
async fn esc_interrupt_pauses_active_goal_turn() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.show_welcome_banner = false;
let thread_id = start_active_goal_turn(&mut chat);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt)));
assert_goal_paused_event(&mut rx, thread_id);
update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(TestBackend::new(width, height)).expect("terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw goal paused footer");
let snapshot = normalized_backend_snapshot(terminal.backend());
#[cfg(target_os = "windows")]
insta::with_settings!({ snapshot_suffix => "windows" }, {
assert_chatwidget_snapshot!("esc_interrupt_goal_paused_footer", snapshot);
});
#[cfg(not(target_os = "windows"))]
assert_chatwidget_snapshot!("esc_interrupt_goal_paused_footer", snapshot);
}
#[tokio::test]
async fn request_user_input_interrupt_pauses_active_goal_turn() {
for key_event in [
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let thread_id = start_active_goal_turn(&mut chat);
chat.handle_request_user_input_now(ToolRequestUserInputParams {
thread_id: thread_id.to_string(),
item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: Vec::new(),
auto_resolution_ms: None,
});
chat.handle_key_event(key_event);
assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt)));
assert_goal_paused_event(&mut rx, thread_id);
}
}
fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId {
let thread_id = ThreadId::new();
chat.set_feature_enabled(Feature::Goals, true);
chat.thread_id = Some(thread_id);
update_thread_goal(chat, thread_id, AppThreadGoalStatus::Active);
chat.on_task_started();
thread_id
}
fn update_thread_goal(chat: &mut ChatWidget, thread_id: ThreadId, status: AppThreadGoalStatus) {
let mut goal = test_thread_goal(
status,
Some(50_000),
40_000,
);
let thread_id = thread_id.to_string();
goal.thread_id = thread_id.clone();
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
lemurclaw_core::app_server_protocol::ThreadGoalUpdatedNotification {
thread_id,
turn_id: None,
goal,
},
),
None,
);
}
fn assert_goal_paused_event(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
thread_id: ThreadId,
) {
assert_matches!(
rx.try_recv(),
Ok(AppEvent::SetThreadGoalStatus {
thread_id: event_thread_id,
status: AppThreadGoalStatus::Paused,
}) if event_thread_id == thread_id
);
}
#[tokio::test]
async fn idle_commit_ticks_do_not_restore_status_without_commentary_completion() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_task_started();
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
assert_eq!(chat.bottom_pane.is_task_running(), true);
chat.on_commit_tick();
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
}
#[tokio::test]
async fn final_answer_completion_restores_status_indicator_for_pending_steer() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual( None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
chat.on_agent_message_delta("Long output line 1\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
chat.on_agent_message_delta("Long output line 2\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
assert_eq!(chat.bottom_pane.is_task_running(), true);
chat.bottom_pane.set_composer_text(
"Please summarize the rest more briefly.".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.input_queue.pending_steers.len(), 1);
let items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(
items,
vec![UserInput::Text {
text: "Please summarize the rest more briefly.".to_string(),
text_elements: Vec::new(),
}]
);
complete_assistant_message(
&mut chat,
"msg-final",
"Long output line 1\nLong output line 2\n",
Some(MessagePhase::FinalAnswer),
);
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
assert_eq!(chat.bottom_pane.is_task_running(), true);
complete_user_message(
&mut chat,
"user-steer",
"Please summarize the rest more briefly.",
);
assert!(chat.input_queue.pending_steers.is_empty());
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
assert_eq!(chat.bottom_pane.is_task_running(), true);
}
#[tokio::test]
async fn commentary_completion_restores_status_indicator_before_exec_begin() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_task_started();
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
chat.on_agent_message_delta("Preamble line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
complete_assistant_message(
&mut chat,
"msg-commentary",
"Preamble line\n",
Some(MessagePhase::Commentary),
);
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
assert_eq!(chat.bottom_pane.is_task_running(), true);
begin_exec(&mut chat, "call-1", "echo hi");
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
}
#[tokio::test]
async fn fast_status_indicator_requires_chatgpt_auth() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
#[tokio::test]
async fn fast_status_indicator_is_hidden_for_models_without_fast_support() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.2").supports_fast_mode());
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.2").supports_fast_mode());
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
#[tokio::test]
async fn fast_status_indicator_is_hidden_when_fast_mode_is_off() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
#[tokio::test]
async fn ui_snapshots_small_heights_idle() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
for h in [1u16, 2, 3] {
let name = format!("chat_small_idle_h{h}");
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw chat idle");
assert_chatwidget_snapshot!(name, normalized_backend_snapshot(terminal.backend()));
}
}
#[tokio::test]
async fn ui_snapshots_small_heights_task_running() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
handle_turn_started(&mut chat, "turn-1");
handle_agent_reasoning_delta(&mut chat, "**Thinking**");
for h in [1u16, 2, 3] {
let name = format!("chat_small_running_h{h}");
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw chat running");
assert_chatwidget_snapshot!(name, normalized_backend_snapshot(terminal.backend()));
}
}
#[tokio::test]
#[serial]
async fn ambient_pet_stays_hidden_until_a_pet_is_selected() {
use ratatui::layout::Rect;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.set_pet_image_support_for_tests(crate::tui_internal::pets::PetImageSupport::Supported(
crate::tui_internal::pets::ImageProtocol::Kitty,
));
assert!(chat.ambient_pet.is_none());
crate::tui_internal::pets::write_test_pack(&chat.config.codex_home);
chat.set_tui_pet(Some("codex".to_string()));
let area = Rect::new(
0, 0, 60, 20,
);
let draw = chat
.ambient_pet_draw(area, area.bottom())
.expect("ambient pet draw request");
assert_eq!(draw.x, 51);
assert_eq!(draw.y, 14);
assert_eq!(draw.columns, 9);
assert_eq!(draw.rows, 5);
assert_eq!(
draw.y.saturating_add(draw.rows),
area.bottom().saturating_sub( 1)
);
handle_turn_started(&mut chat, "turn-1");
handle_agent_reasoning_delta(&mut chat, "**Thinking**");
let draw_with_status = chat
.ambient_pet_draw(area, area.bottom())
.expect("ambient pet draw request with status");
assert_eq!(draw_with_status.y, draw.y);
assert_eq!(
draw_with_status.y.saturating_add(draw_with_status.rows),
area.bottom().saturating_sub( 1)
);
}
#[tokio::test]
#[serial]
async fn ambient_pet_screen_bottom_anchor_uses_terminal_bottom() {
use lemurclaw_core::config::types::TuiPetAnchor;
use ratatui::layout::Rect;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
enable_test_ambient_pet(&mut chat);
let terminal_area = Rect::new(
0, 0, 80, 24,
);
let composer_bottom_y = 20;
let default_draw = chat
.ambient_pet_draw(terminal_area, composer_bottom_y)
.expect("composer-anchored pet draw request");
assert_eq!(default_draw.y, 14);
chat.config.tui_pet_anchor = TuiPetAnchor::ScreenBottom;
let screen_bottom_draw = chat
.ambient_pet_draw(terminal_area, composer_bottom_y)
.expect("screen-bottom anchored pet draw request");
assert_eq!(screen_bottom_draw.y, 18);
}
#[tokio::test]
#[serial]
async fn ambient_pet_can_be_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.set_tui_pet(Some(crate::tui_internal::pets::DISABLED_PET_ID.to_string()));
assert!(chat.ambient_pet.is_none());
}
#[tokio::test]
#[serial]
async fn ambient_pet_reserves_history_wrap_width() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
enable_test_ambient_pet(&mut chat);
assert_eq!(chat.history_wrap_width( 80), 69);
chat.set_tui_pet(Some(crate::tui_internal::pets::DISABLED_PET_ID.to_string()));
assert_eq!(chat.history_wrap_width( 80), 80);
}
#[tokio::test]
#[serial]
async fn ambient_pet_reduces_stream_width_and_composer_text_width() {
use ratatui::Terminal;
let (mut with_pet, _with_pet_rx, _with_pet_op_rx) =
make_chatwidget_manual( None).await;
enable_test_ambient_pet(&mut with_pet);
with_pet.last_rendered_width.set(Some(80));
let stream_width_with_pet = with_pet.current_stream_width( 2);
let (mut disabled, _disabled_rx, _disabled_op_rx) =
make_chatwidget_manual( None).await;
disabled.set_tui_pet(Some(crate::tui_internal::pets::DISABLED_PET_ID.to_string()));
disabled.last_rendered_width.set(Some(80));
let stream_width_without_pet = disabled.current_stream_width( 2);
assert_eq!(
stream_width_with_pet,
crate::tui_internal::width::usable_content_width( 69, 2)
);
assert_eq!(
stream_width_without_pet,
crate::tui_internal::width::usable_content_width( 80, 2)
);
assert!(stream_width_with_pet < stream_width_without_pet);
let draft =
"Minim commodo esse elit Lorem exercitation elit ipsum proident labore. Esse culpa aliqua"
.to_string();
with_pet
.bottom_pane
.set_composer_text(draft.clone(), Vec::new(), Vec::new());
disabled
.bottom_pane
.set_composer_text(draft, Vec::new(), Vec::new());
let mut with_pet_terminal =
Terminal::new(TestBackend::new( 80, 6)).expect("create terminal");
with_pet_terminal
.draw(|f| with_pet.render(f.area(), f.buffer_mut()))
.expect("draw pet-enabled chat");
let mut disabled_terminal =
Terminal::new(TestBackend::new( 80, 6)).expect("create terminal");
disabled_terminal
.draw(|f| disabled.render(f.area(), f.buffer_mut()))
.expect("draw disabled-pet chat");
let pet_row = buffer_row_containing(with_pet_terminal.backend().buffer(), "Minim")
.expect("pet-enabled composer row should render draft");
let disabled_row = buffer_row_containing(disabled_terminal.backend().buffer(), "Minim")
.expect("disabled-pet composer row should render draft");
assert!(row_tail_is_blank(&pet_row, 69));
assert!(!row_tail_is_blank(&disabled_row, 69));
}
fn buffer_row_containing(buffer: &ratatui::buffer::Buffer, text: &str) -> Option<String> {
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer.cell((x, y)).expect("cell should exist").symbol())
.collect::<String>()
})
.find(|row| row.contains(text))
}
fn row_tail_is_blank(row: &str, start_col: usize) -> bool {
row.chars().skip(start_col).all(char::is_whitespace)
}
#[tokio::test]
#[serial]
async fn ambient_pet_draw_uses_terminal_screen_area_not_short_inline_viewport() {
use ratatui::layout::Rect;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
enable_test_ambient_pet(&mut chat);
assert!(
chat.ambient_pet_draw(
Rect::new(
0, 21, 80, 3,
),
24
)
.is_none(),
"a normal short inline viewport cannot fit the ambient pet"
);
let draw = chat
.ambient_pet_draw(
Rect::new(
0, 0, 80, 24,
),
24,
)
.expect("full terminal screen has room for the ambient pet");
assert_eq!(draw.x, 71);
assert_eq!(draw.y, 18);
}
#[tokio::test]
#[serial]
async fn ambient_pet_hides_notification_text_overlay() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
enable_test_ambient_pet(&mut chat);
for (kind, label) in [
(crate::tui_internal::pets::PetNotificationKind::Running, "Running"),
(crate::tui_internal::pets::PetNotificationKind::Waiting, "Needs input"),
(crate::tui_internal::pets::PetNotificationKind::Review, "Ready"),
(crate::tui_internal::pets::PetNotificationKind::Failed, "Blocked"),
] {
chat.set_ambient_pet_notification(kind, None);
let mut terminal = Terminal::new(TestBackend::new(60, 20)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw ambient pet notification");
assert!(
!normalized_backend_snapshot(terminal.backend()).contains(label),
"did not expect {label} notification text to render"
);
}
}
#[tokio::test]
async fn status_widget_and_approval_modal_snapshot() {
use crate::tui_internal::approval_events::ExecApprovalRequestEvent;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
handle_turn_started(&mut chat, "turn-1");
handle_agent_reasoning_delta(&mut chat, "**Analyzing**");
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-exec".into(),
approval_id: Some("call-approve-exec".into()),
turn_id: "turn-approve-exec".into(),
environment_id: None,
command: vec!["echo".into(), "hello world".into()],
cwd: test_path_buf("/tmp").abs(),
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
network_approval_context: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
command: vec!["echo".into(), "hello world".into()],
}),
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
};
handle_exec_approval_request(&mut chat, "sub-approve-exec", ev);
let width: u16 = 100;
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw status + approval modal");
assert_chatwidget_snapshot!(
"status_widget_and_approval_modal",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_widget_active_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
handle_turn_started(&mut chat, "turn-1");
handle_agent_reasoning_delta(&mut chat, "**Analyzing**");
let height = chat.desired_height( 80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw status widget");
assert_chatwidget_snapshot!(
"status_widget_active",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn stream_error_updates_status_indicator() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.bottom_pane.set_task_running( true);
let msg = "Reconnecting... 2/5";
let details = "Idle timeout waiting for SSE";
handle_stream_error(&mut chat, msg, Some(details.to_string()));
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
"expected no history cell for StreamError event"
);
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), msg);
assert_eq!(status.details(), Some(details));
}
#[tokio::test]
async fn stream_error_restores_hidden_status_indicator() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_task_started();
chat.on_agent_message_delta("Preamble line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert!(!chat.bottom_pane.status_indicator_visible());
let msg = "Reconnecting... 2/5";
let details = "Idle timeout waiting for SSE";
handle_stream_error(&mut chat, msg, Some(details.to_string()));
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), msg);
assert_eq!(status.details(), Some(details));
}
#[tokio::test]
async fn warning_event_adds_warning_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_warning(&mut chat, "test warning message");
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one warning history cell");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("test warning message"),
"warning cell missing content: {rendered}"
);
}
#[tokio::test]
async fn unsupported_code_mode_warning_renders_as_warning_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_warning(
&mut chat,
"Code Mode is enabled in configuration, but model `gpt-5.4` does not advertise Code Mode support. This may degrade model performance. Disable `features.code_mode` and `features.code_mode_only`, or select a model whose metadata enables Code Mode.",
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one warning history cell");
insta::assert_snapshot!(
"unsupported_code_mode_warning",
lines_to_single_string(&cells[0])
);
}
#[tokio::test]
async fn repeated_model_metadata_warning_is_hidden_for_same_slug() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let warning = "Model metadata for `unknown-model` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.";
handle_warning(&mut chat, warning);
handle_warning(&mut chat, warning);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one warning history cell");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("unknown-model"),
"warning cell missing model slug: {rendered}"
);
}
#[tokio::test]
async fn repeated_generic_warning_is_not_hidden() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_warning(&mut chat, "test warning message");
handle_warning(&mut chat, "test warning message");
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 2, "expected both warning history cells");
}
#[tokio::test]
async fn status_line_invalid_items_warn_once() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.config.tui_status_line = Some(vec![
"model_name".to_string(),
"bogus_item".to_string(),
"lines_changed".to_string(),
"bogus_item".to_string(),
]);
chat.thread_id = Some(ThreadId::new());
chat.refresh_status_line();
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one warning history cell");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("bogus_item"),
"warning cell missing invalid item content: {rendered}"
);
chat.refresh_status_line();
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
"expected invalid status line warning to emit only once"
);
}
#[tokio::test]
async fn status_line_context_used_renders_labeled_percent() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec!["context-used".to_string()]);
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Context 0% used".to_string()));
assert!(
drain_insert_history(&mut rx).is_empty(),
"context-used should remain a valid status line item"
);
}
#[tokio::test]
async fn status_line_context_remaining_renders_labeled_percent() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec!["context-remaining".to_string()]);
chat.refresh_status_line();
assert_eq!(
status_line_text(&chat),
Some("Context 100% left".to_string())
);
assert!(
drain_insert_history(&mut rx).is_empty(),
"context-remaining should remain a valid status line item"
);
}
#[tokio::test]
async fn status_line_legacy_context_usage_renders_context_used_percent() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec!["context-usage".to_string()]);
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Context 0% used".to_string()));
assert!(
drain_insert_history(&mut rx).is_empty(),
"legacy context-usage should remain a valid status line item"
);
}
#[tokio::test]
async fn status_line_workspace_headline_renders_cached_value() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string());
chat.refresh_status_line();
assert_eq!(
status_line_text(&chat),
Some("Workspace maintenance starts at 5pm".to_string())
);
assert!(
drain_insert_history(&mut rx).is_empty(),
"workspace-headline should be a valid status line item"
);
}
#[tokio::test]
async fn status_line_workspace_headline_omits_when_unavailable() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec![
"workspace-headline".to_string(),
"run-state".to_string(),
]);
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Ready".to_string()));
assert!(
drain_insert_history(&mut rx).is_empty(),
"workspace-headline should be omitted without warning when no headline is cached"
);
}
#[tokio::test]
async fn workspace_headline_update_applies_feature_disabled_result() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.status_line_workspace_headline = Some("Old headline".to_string());
let request_id = 3;
chat.status_line_workspace_headline_pending_request_id = Some(request_id);
assert!(chat.set_status_line_workspace_headline(
request_id,
Ok(crate::tui_internal::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled),
));
assert_eq!(status_line_text(&chat), None);
assert!(chat.status_line_workspace_messages_disabled);
}
#[tokio::test]
async fn workspace_headline_update_applies_available_headline() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
let request_id = 4;
chat.status_line_workspace_headline_pending_request_id = Some(request_id);
assert!(chat.set_status_line_workspace_headline(
request_id,
Ok(
crate::tui_internal::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some(
"Fresh workspace headline".to_string(),
))
),
));
assert_eq!(
status_line_text(&chat),
Some("Fresh workspace headline".to_string())
);
assert!(!chat.status_line_workspace_messages_disabled);
}
#[tokio::test]
async fn account_update_clears_workspace_headline_state() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.status_line_workspace_headline = Some("Old workspace headline".to_string());
chat.status_line_workspace_headline_pending_request_id = Some(5);
chat.status_line_workspace_headline_last_requested_at = Some(Instant::now());
chat.status_line_workspace_messages_disabled = true;
chat.update_account_state(
None, None,
false, false,
);
assert_eq!(
(
status_line_text(&chat),
chat.status_line_workspace_headline_pending_request_id,
chat.status_line_workspace_headline_last_requested_at,
chat.status_line_workspace_messages_disabled,
),
(None, None, None, false)
);
}
#[tokio::test]
async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.update_account_state(
None, None,
false, true,
);
let request_id = take_workspace_headline_request_id(&mut rx);
assert_eq!(
chat.status_line_workspace_headline_pending_request_id,
Some(request_id)
);
}
#[tokio::test]
async fn account_update_discards_stale_workspace_headline_results() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.update_account_state(
Some(StatusAccountDisplay::ChatGpt {
email: Some("first@example.com".to_string()),
plan: None,
}),
None,
true,
true,
);
let stale_request_id = take_workspace_headline_request_id(&mut rx);
chat.update_account_state(
Some(StatusAccountDisplay::ChatGpt {
email: Some("second@example.com".to_string()),
plan: None,
}),
None,
true,
true,
);
let current_request_id = take_workspace_headline_request_id(&mut rx);
assert_ne!(stale_request_id, current_request_id);
assert!(!chat.set_status_line_workspace_headline(
stale_request_id,
Ok(
crate::tui_internal::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some(
"First account headline".to_string(),
))
),
));
assert_eq!(
(
chat.status_line_workspace_headline.clone(),
chat.status_line_workspace_headline_pending_request_id,
chat.status_line_workspace_messages_disabled,
),
(None, Some(current_request_id), false)
);
assert!(chat.set_status_line_workspace_headline(
current_request_id,
Ok(
crate::tui_internal::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some(
"Second account headline".to_string(),
))
),
));
assert!(!chat.set_status_line_workspace_headline(
stale_request_id,
Ok(crate::tui_internal::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled),
));
assert_eq!(
(
status_line_text(&chat),
chat.status_line_workspace_headline_pending_request_id,
chat.status_line_workspace_messages_disabled,
),
(Some("Second account headline".to_string()), None, false,)
);
}
#[tokio::test]
async fn status_line_branch_state_resets_when_git_branch_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.status_line_branch = Some("main".to_string());
chat.status_line_branch_pending = true;
chat.status_line_branch_lookup_complete = true;
chat.config.tui_status_line = Some(vec!["model_name".to_string()]);
chat.refresh_status_line();
assert_eq!(chat.status_line_branch, None);
assert!(!chat.status_line_branch_pending);
assert!(!chat.status_line_branch_lookup_complete);
}
#[tokio::test]
async fn status_line_branch_refreshes_after_turn_complete() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
install_noop_workspace_command_runner(&mut chat);
chat.config.tui_status_line = Some(vec!["git-branch".to_string()]);
chat.status_line_branch_lookup_complete = true;
chat.status_line_branch_pending = false;
handle_turn_completed(&mut chat, "turn-1", None);
assert!(chat.status_line_branch_pending);
}
#[tokio::test]
async fn status_line_branch_refreshes_after_interrupt() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
install_noop_workspace_command_runner(&mut chat);
chat.config.tui_status_line = Some(vec!["git-branch".to_string()]);
chat.status_line_branch_lookup_complete = true;
chat.status_line_branch_pending = false;
handle_turn_interrupted(&mut chat, "turn-1");
assert!(chat.status_line_branch_pending);
}
fn install_noop_workspace_command_runner(chat: &mut ChatWidget) {
chat.workspace_command_runner = Some(std::sync::Arc::new(NoopWorkspaceCommandRunner));
}
struct NoopWorkspaceCommandRunner;
impl crate::tui_internal::workspace_command::WorkspaceCommandExecutor for NoopWorkspaceCommandRunner {
fn run(
&self,
_command: crate::tui_internal::workspace_command::WorkspaceCommand,
) -> std::pin::Pin<
Box<
dyn std::future::Future<
Output = Result<
crate::tui_internal::workspace_command::WorkspaceCommandOutput,
crate::tui_internal::workspace_command::WorkspaceCommandError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::tui_internal::workspace_command::WorkspaceCommandOutput {
exit_code: 1,
stdout: String::new(),
stderr: String::new(),
})
})
}
}
#[tokio::test]
async fn interrupted_turn_clears_visible_running_hook() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"pre-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PreToolUse,
Some("checking command policy"),
),
);
reveal_running_hooks(&mut chat);
let before_interrupt = active_hook_blob(&chat);
handle_turn_interrupted(&mut chat, "turn-1");
assert_chatwidget_snapshot!(
"interrupted_turn_clears_visible_running_hook",
format!(
"before interrupt:\n{before_interrupt}after interrupt:\n{}",
active_hook_blob(&chat)
)
);
}
#[tokio::test]
async fn completed_turn_clears_visible_running_hook() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
None,
),
);
reveal_running_hooks(&mut chat);
let before_completion = active_hook_blob(&chat);
handle_turn_completed(&mut chat, "turn-1", None);
assert_chatwidget_snapshot!(
"completed_turn_clears_visible_running_hook",
format!(
"before completion:\n{before_completion}after completion:\n{}",
active_hook_blob(&chat)
)
);
}
#[tokio::test]
async fn status_line_fast_mode_renders_on_and_off() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Fast off".to_string()));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Fast on".to_string()));
}
#[tokio::test]
async fn status_line_fast_mode_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
chat.refresh_status_line();
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw fast-mode footer");
assert_chatwidget_snapshot!(
"status_line_fast_mode_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.config.cwd = test_project_path().abs();
chat.config.tui_status_line = Some(vec![
"model-with-reasoning".to_string(),
"context-used".to_string(),
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.refresh_status_line();
let test_cwd = test_path_display("/tmp/project");
assert_eq!(
status_line_text(&chat),
Some(format!("gpt-5.4 xhigh fast · Context 0% used · {test_cwd}"))
);
chat.set_model("gpt-5.2");
chat.refresh_status_line();
assert_eq!(
status_line_text(&chat),
Some(format!("gpt-5.2 xhigh · Context 0% used · {test_cwd}"))
);
}
#[tokio::test]
async fn terminal_title_model_updates_on_model_change_without_manual_refresh() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.config.tui_terminal_title = Some(vec!["model".to_string()]);
chat.refresh_terminal_title();
assert_eq!(chat.last_terminal_title, Some("gpt-5.4".to_string()));
chat.set_model("gpt-5.2");
assert_eq!(chat.last_terminal_title, Some("gpt-5.2".to_string()));
}
#[tokio::test]
async fn status_line_and_terminal_title_reasoning_render_only_effort() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.config.tui_status_line = Some(vec!["reasoning".to_string()]);
chat.config.tui_terminal_title = Some(vec!["reasoning".to_string()]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
chat.refresh_status_line();
chat.refresh_terminal_title();
assert_eq!(status_line_text(&chat), Some("xhigh".to_string()));
assert_eq!(chat.last_terminal_title, Some("xhigh".to_string()));
}
#[tokio::test]
async fn status_line_reasoning_updates_on_mode_switch_without_manual_refresh() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.set_feature_enabled(Feature::CollaborationModes, true);
chat.config.tui_status_line = Some(vec!["reasoning".to_string()]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
assert_eq!(status_line_text(&chat), Some("high".to_string()));
let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
.expect("expected plan collaboration mode");
chat.set_collaboration_mask(plan_mask);
assert_eq!(status_line_text(&chat), Some("medium".to_string()));
}
#[tokio::test]
async fn status_line_model_with_reasoning_updates_on_mode_switch_without_manual_refresh() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.set_feature_enabled(Feature::CollaborationModes, true);
chat.config.tui_status_line = Some(vec!["model-with-reasoning".to_string()]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
assert_eq!(status_line_text(&chat), Some("gpt-5.2 high".to_string()));
let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
.expect("expected plan collaboration mode");
chat.set_collaboration_mask(plan_mask);
assert_eq!(status_line_text(&chat), Some("gpt-5.2 medium".to_string()));
let default_mask = collaboration_modes::default_mask(chat.model_catalog.as_ref())
.expect("expected default collaboration mode");
chat.set_collaboration_mask(default_mask);
assert_eq!(status_line_text(&chat), Some("gpt-5.2 high".to_string()));
}
#[tokio::test]
async fn status_line_model_with_reasoning_plan_mode_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.show_welcome_banner = false;
chat.set_feature_enabled(Feature::CollaborationModes, true);
chat.config.tui_status_line = Some(vec!["model-with-reasoning".to_string()]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
.expect("expected plan collaboration mode");
chat.set_collaboration_mask(plan_mask);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw plan-mode footer");
assert_chatwidget_snapshot!(
"status_line_model_with_reasoning_plan_mode_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn renamed_thread_footer_title_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec![
"model-with-reasoning".to_string(),
"thread-title".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
chat.refresh_status_line();
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.handle_server_notification(
ServerNotification::ThreadNameUpdated(
lemurclaw_core::app_server_protocol::ThreadNameUpdatedNotification {
thread_id: thread_id.to_string(),
thread_name: Some("Roadmap cleanup".to_string()),
},
),
None,
);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw renamed-thread footer");
assert_chatwidget_snapshot!(
"renamed_thread_footer_title",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_model_with_reasoning_fast_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.show_welcome_banner = false;
chat.config.cwd = test_project_path().abs();
chat.config.tui_status_line = Some(vec![
"model-with-reasoning".to_string(),
"context-used".to_string(),
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.refresh_status_line();
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw model-with-reasoning footer");
assert_chatwidget_snapshot!(
"status_line_model_with_reasoning_fast_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.show_welcome_banner = false;
chat.config.cwd = test_project_path().abs();
chat.config.tui_status_line = Some(vec![
"model-with-reasoning".to_string(),
"context-remaining".to_string(),
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.refresh_status_line();
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw model-with-reasoning footer");
assert_chatwidget_snapshot!(
"status_line_model_with_reasoning_context_remaining_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_goal_active_token_budget_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, true);
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
chat.refresh_status_line();
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
lemurclaw_core::app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::Active,
Some(50_000),
40_000,
),
},
),
None,
);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw goal status footer");
assert_chatwidget_snapshot!(
"status_line_goal_active_token_budget_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_goal_complete_elapsed_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, true);
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
chat.refresh_status_line();
let mut goal = test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::Complete,
None,
40_000,
);
goal.time_used_seconds = 2 * 24 * 60 * 60 + 23 * 60 * 60 + 42 * 60;
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
lemurclaw_core::app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal,
},
),
None,
);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw goal status footer");
assert_chatwidget_snapshot!(
"status_line_goal_complete_elapsed_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn session_configured_clears_goal_status_footer() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, true);
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
lemurclaw_core::app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::Active,
Some(50_000),
40_000,
),
},
),
None,
);
assert_eq!(
chat.current_goal_status_indicator,
Some(GoalStatusIndicator::Active {
usage: Some("40K / 50K".to_string())
})
);
chat.turn_lifecycle
.budget_limited_turn_ids
.insert("turn-1".to_string());
let rollout_file = NamedTempFile::new().unwrap();
chat.handle_thread_session(crate::tui_internal::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-5.4".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
});
assert_eq!(chat.current_goal_status_indicator, None);
assert!(chat.turn_lifecycle.budget_limited_turn_ids.is_empty());
}
#[tokio::test]
async fn thread_goal_update_for_other_thread_is_ignored() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, true);
chat.thread_id = Some(ThreadId::new());
let other_thread_id = ThreadId::new().to_string();
let mut goal = test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::BudgetLimited,
Some(50_000),
50_000,
);
goal.thread_id = other_thread_id.clone();
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
lemurclaw_core::app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: other_thread_id,
turn_id: Some("turn-other".to_string()),
goal,
},
),
None,
);
assert_eq!(chat.current_goal_status_indicator, None);
assert!(chat.current_goal_status.is_none());
assert!(chat.turn_lifecycle.budget_limited_turn_ids.is_empty());
}
#[test]
fn goal_status_indicator_formats_statuses_and_budgets() {
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::Active,
Some(50_000),
40_000,
)),
Some(GoalStatusIndicator::Active {
usage: Some("40K / 50K".to_string()),
})
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::Active,
None,
0,
)),
Some(GoalStatusIndicator::Active {
usage: Some("30m".to_string()),
})
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::Blocked,
None,
0,
)),
Some(GoalStatusIndicator::Blocked)
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::UsageLimited,
None,
0,
)),
Some(GoalStatusIndicator::UsageLimited)
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::BudgetLimited,
Some(50_000),
51_000,
)),
Some(GoalStatusIndicator::BudgetLimited {
usage: Some("51K / 50K tokens".to_string()),
})
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::BudgetLimited,
None,
0,
)),
Some(GoalStatusIndicator::BudgetLimited { usage: None })
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
lemurclaw_core::app_server_protocol::ThreadGoalStatus::Complete,
Some(50_000),
40_000,
)),
Some(GoalStatusIndicator::Complete {
usage: Some("40K tokens".to_string()),
})
);
}
#[test]
fn goal_status_indicator_line_formats_goal_text() {
let cases = [
(
GoalStatusIndicator::Active {
usage: Some("4K / 5K".to_string()),
},
"Pursuing goal (4K / 5K)",
),
(
GoalStatusIndicator::BudgetLimited {
usage: Some("4K / 5K tokens".to_string()),
},
"Goal unmet (4K / 5K tokens)",
),
(GoalStatusIndicator::Paused, "Goal paused (/goal resume)"),
(GoalStatusIndicator::Blocked, "Goal blocked (/goal resume)"),
(
GoalStatusIndicator::UsageLimited,
"Goal hit usage limits (/goal resume)",
),
(
GoalStatusIndicator::BudgetLimited { usage: None },
"Goal abandoned",
),
(
GoalStatusIndicator::Complete {
usage: Some("10h 12m".to_string()),
},
"Goal achieved (10h 12m)",
),
(
GoalStatusIndicator::Complete { usage: None },
"Goal achieved",
),
];
for (indicator, expected) in cases {
let line =
goal_status_indicator_line(Some(&indicator)).expect("goal indicator should render");
let actual = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(expected, actual);
}
}
fn test_thread_goal(
status: lemurclaw_core::app_server_protocol::ThreadGoalStatus,
token_budget: Option<i64>,
tokens_used: i64,
) -> lemurclaw_core::app_server_protocol::ThreadGoal {
lemurclaw_core::app_server_protocol::ThreadGoal {
thread_id: "thread-1".to_string(),
objective: "Keep improving the benchmark".to_string(),
status,
token_budget,
tokens_used,
time_used_seconds: 30 * 60,
created_at: 0,
updated_at: 0,
}
}
#[tokio::test]
async fn runtime_metrics_websocket_timing_logs_and_final_separator_sums_totals() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.set_feature_enabled(Feature::RuntimeMetrics, true);
chat.on_task_started();
chat.apply_runtime_metrics_delta(RuntimeMetricsSummary {
responses_api_engine_iapi_ttft_ms: 120,
responses_api_engine_service_tbt_ms: 50.0,
..RuntimeMetricsSummary::default()
});
let first_log = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.find(|line| line.contains("WebSocket timing:"))
.expect("expected websocket timing log");
assert!(first_log.contains("TTFT: 120ms (iapi)"));
assert!(first_log.contains("TBT: 50ms (service)"));
chat.apply_runtime_metrics_delta(RuntimeMetricsSummary {
responses_api_engine_iapi_ttft_ms: 80,
..RuntimeMetricsSummary::default()
});
let second_log = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.find(|line| line.contains("WebSocket timing:"))
.expect("expected websocket timing log");
assert!(second_log.contains("TTFT: 80ms (iapi)"));
chat.on_task_complete(
None, None, false,
);
let mut final_separator = None;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
final_separator = Some(lines_to_single_string(&cell.display_lines( 300)));
}
}
let final_separator = final_separator.expect("expected final separator with runtime metrics");
assert!(final_separator.contains("TTFT: 80ms (iapi)"));
assert!(final_separator.contains("TBT: 50ms (service)"));
}
#[tokio::test]
async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_turn_started(&mut chat, "turn-1");
complete_assistant_message(&mut chat, "msg-first", "First message", None);
complete_assistant_message(
&mut chat,
"msg-second",
"Second message",
None,
);
handle_turn_completed(&mut chat, "turn-1", None);
let cells = drain_insert_history(&mut rx);
let combined: String = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect();
assert!(
combined.contains("First message"),
"missing first message: {combined}"
);
assert!(
combined.contains("Second message"),
"missing second message: {combined}"
);
let first_idx = combined.find("First message").unwrap();
let second_idx = combined.find("Second message").unwrap();
assert!(first_idx < second_idx, "messages out of order: {combined}");
}
#[tokio::test]
async fn final_reasoning_then_message_without_deltas_are_rendered() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_agent_reasoning_final(&mut chat);
complete_assistant_message(
&mut chat,
"msg-result",
"Here is the result.",
None,
);
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"final_reasoning_then_message_without_deltas_are_rendered",
combined
);
}
#[tokio::test]
async fn deltas_then_same_final_message_are_rendered_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_agent_reasoning_delta(&mut chat, "I will ");
handle_agent_reasoning_delta(&mut chat, "first analyze the ");
handle_agent_reasoning_delta(&mut chat, "request.");
handle_agent_reasoning_final(&mut chat);
handle_agent_message_delta(&mut chat, "Here is the ");
handle_agent_message_delta(&mut chat, "result.");
complete_assistant_message(
&mut chat,
"msg-result",
"Here is the result.",
None,
);
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"deltas_then_same_final_message_are_rendered_snapshot",
combined
);
}
#[tokio::test]
async fn unterminated_agent_delta_does_not_redraw_unchanged_stream_tail() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.handle_streaming_delta("| Step | Owner |\n".to_string());
assert!(chat.active_cell_is_stream_tail());
let revision = chat.transcript.active_cell_revision;
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
chat.frame_requester = frame_requester;
chat.handle_streaming_delta("| partial".to_string());
assert_eq!(chat.transcript.active_cell_revision, revision);
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn newline_agent_delta_redraws_stream_tail_after_noop_catch_up() {
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth(
None,
false,
false,
frame_requester,
)
.await;
chat.on_task_started();
chat.handle_streaming_delta("Earlier line\n".to_string());
chat.on_commit_tick();
assert!(!chat.bottom_pane.status_indicator_visible());
while draw_rx.try_recv().is_ok() {}
chat.handle_streaming_delta("Intro line\n| Step | Owner |\n".to_string());
assert!(chat.active_cell_is_stream_tail());
assert!(
draw_rx.try_recv().is_ok(),
"expected the changed assistant stream tail to schedule a redraw",
);
}
#[tokio::test]
async fn newline_plan_delta_redraws_stream_tail_after_noop_catch_up() {
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth(
Some("gpt-5"),
false,
false,
frame_requester,
)
.await;
chat.set_feature_enabled(Feature::CollaborationModes, true);
let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
.expect("expected plan collaboration mask");
chat.set_collaboration_mask(plan_mask);
chat.on_task_started();
chat.on_plan_delta("Earlier line\n".to_string());
chat.on_commit_tick();
assert!(!chat.bottom_pane.status_indicator_visible());
while draw_rx.try_recv().is_ok() {}
chat.on_plan_delta("Intro line\n| Step | Owner |\n".to_string());
assert!(chat.active_cell_is_stream_tail());
assert!(
draw_rx.try_recv().is_ok(),
"expected the changed plan stream tail to schedule a redraw",
);
}
#[tokio::test]
async fn regular_commit_tick_clears_orphaned_plan_stream_tail() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_feature_enabled(Feature::CollaborationModes, true);
let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
.expect("expected plan collaboration mask");
chat.set_collaboration_mask(plan_mask);
chat.on_task_started();
chat.on_plan_delta("| Step | Owner |\n".to_string());
assert!(chat.active_cell_is_stream_tail());
chat.on_task_started();
assert!(chat.active_cell_is_stream_tail());
chat.on_commit_tick();
assert!(!chat.active_cell_is_stream_tail());
}
#[tokio::test]
async fn reasoning_delta_redraws_only_when_header_becomes_visible() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
chat.frame_requester = frame_requester;
chat.on_agent_reasoning_delta("still looking".to_string());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
assert_eq!(chat.reasoning_header, None);
chat.on_agent_reasoning_delta(" **Checking".to_string());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
chat.on_agent_reasoning_delta(" files**".to_string());
assert!(draw_rx.try_recv().is_ok());
assert_eq!(chat.reasoning_header.as_deref(), Some("Checking files"));
chat.on_agent_reasoning_delta(" and preparing a response".to_string());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn reasoning_delta_does_not_double_schedule_visible_status_redraw() {
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth(
None,
false,
false,
frame_requester,
)
.await;
chat.on_task_started();
assert!(chat.bottom_pane.status_indicator_visible());
while draw_rx.try_recv().is_ok() {}
chat.on_agent_reasoning_delta("**Checking files**".to_string());
assert_eq!(chat.reasoning_header.as_deref(), Some("Checking files"));
assert!(draw_rx.try_recv().is_ok());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn reasoning_delta_restores_recreated_status_indicator_header() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.on_task_started();
chat.on_agent_reasoning_delta("**Checking files**".to_string());
chat.on_agent_message_delta("Preamble line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert!(!chat.bottom_pane.status_indicator_visible());
begin_unified_exec_startup(&mut chat, "call-1", "proc-1", "sleep 2");
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should be recreated");
assert_eq!(status.header(), "Working");
chat.on_agent_reasoning_delta(" and preparing a response".to_string());
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should remain visible");
assert_eq!(status.header(), "Checking files");
let width: u16 = 80;
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("create terminal");
terminal.set_viewport_area(Rect::new( 0, 0, width, height));
terminal
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
.expect("draw restored reasoning status");
assert_chatwidget_snapshot!(
"reasoning_delta_restores_recreated_status_indicator",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.handle_server_notification(
ServerNotification::HookStarted(AppServerHookStartedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: Some("turn-1".to_string()),
run: AppServerHookRunSummary {
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
event_name: AppServerHookEventName::UserPromptSubmit,
handler_type: AppServerHookHandlerType::Command,
execution_mode: AppServerHookExecutionMode::Sync,
scope: AppServerHookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: lemurclaw_core::app_server_protocol::HookSource::User,
display_order: 0,
status: AppServerHookRunStatus::Running,
status_message: Some("checking go-workflow input policy".to_string()),
started_at: 1,
completed_at: None,
duration_ms: None,
entries: Vec::new(),
},
}),
None,
);
chat.handle_server_notification(
ServerNotification::HookCompleted(AppServerHookCompletedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: Some("turn-1".to_string()),
run: AppServerHookRunSummary {
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
event_name: AppServerHookEventName::UserPromptSubmit,
handler_type: AppServerHookHandlerType::Command,
execution_mode: AppServerHookExecutionMode::Sync,
scope: AppServerHookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: lemurclaw_core::app_server_protocol::HookSource::User,
display_order: 0,
status: AppServerHookRunStatus::Stopped,
status_message: Some("checking go-workflow input policy".to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
entries: vec![
AppServerHookOutputEntry {
kind: AppServerHookOutputEntryKind::Warning,
text: "go-workflow must start from PlanMode".to_string(),
},
AppServerHookOutputEntry {
kind: AppServerHookOutputEntryKind::Stop,
text: "prompt blocked".to_string(),
},
],
},
}),
None,
);
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"user_prompt_submit_app_server_hook_notifications_render_snapshot",
combined
);
assert!(!chat.bottom_pane.status_indicator_visible());
}
#[tokio::test]
async fn pre_tool_use_hook_events_render_snapshot() {
assert_hook_events_snapshot(
lemurclaw_core::app_server_protocol::HookEventName::PreToolUse,
"pre-tool-use:0:/tmp/hooks.json",
"warming the shell",
"pre_tool_use_hook_events_render_snapshot",
)
.await;
}
#[tokio::test]
async fn post_tool_use_hook_events_render_snapshot() {
assert_hook_events_snapshot(
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
"post-tool-use:0:/tmp/hooks.json",
"warming the shell",
"post_tool_use_hook_events_render_snapshot",
)
.await;
}
#[tokio::test]
async fn completed_hook_with_no_entries_stays_out_of_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
None,
),
);
assert!(drain_insert_history(&mut rx).is_empty());
reveal_running_hooks(&mut chat);
let running_snapshot = hook_live_and_history_snapshot(&chat, "running", "");
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
lemurclaw_core::app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
let completed_lingering_snapshot =
hook_live_and_history_snapshot(&chat, "completed lingering", "");
expire_quiet_hook_linger(&mut chat);
let completed_snapshot = hook_live_and_history_snapshot(&chat, "completed after linger", "");
assert_chatwidget_snapshot!(
"hook_live_running_then_quiet_completed_snapshot",
format!("{running_snapshot}\n\n{completed_lingering_snapshot}\n\n{completed_snapshot}")
);
}
#[tokio::test]
async fn quiet_hook_linger_starts_when_delayed_redraw_reveals_hook() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
reveal_running_hooks_after_delayed_redraw(&mut chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
lemurclaw_core::app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(
active_hook_blob(&chat).contains("Running PostToolUse hook"),
"quiet hook should linger after the row becomes visible"
);
expire_quiet_hook_linger(&mut chat);
assert_eq!(active_hook_blob(&chat), "<empty>\n");
}
#[tokio::test]
async fn blocked_and_failed_hooks_render_feedback_and_errors() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_hook_completed(
&mut chat,
hook_completed_run(
"pre-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PreToolUse,
lemurclaw_core::app_server_protocol::HookRunStatus::Blocked,
vec![lemurclaw_core::app_server_protocol::HookOutputEntry {
kind: lemurclaw_core::app_server_protocol::HookOutputEntryKind::Feedback,
text: "run tests before touching the fixture".to_string(),
}],
),
);
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:1:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
lemurclaw_core::app_server_protocol::HookRunStatus::Failed,
vec![lemurclaw_core::app_server_protocol::HookOutputEntry {
kind: lemurclaw_core::app_server_protocol::HookOutputEntryKind::Error,
text: "hook exited with code 7".to_string(),
}],
),
);
let rendered = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!("hook_blocked_failed_feedback_history_snapshot", rendered);
assert!(
rendered.contains(
"PreToolUse hook (blocked)\n feedback: run tests before touching the fixture"
),
"expected blocked hook feedback: {rendered:?}"
);
assert!(
rendered.contains("PostToolUse hook (failed)\n error: hook exited with code 7"),
"expected failed hook error: {rendered:?}"
);
}
#[tokio::test]
async fn completed_hook_with_output_flushes_immediately() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
lemurclaw_core::app_server_protocol::HookEventName::PreToolUse,
Some("checking command"),
),
);
reveal_running_hooks(&mut chat);
let running_snapshot = hook_live_and_history_snapshot(&chat, "running", "");
handle_hook_completed(
&mut chat,
hook_completed_run(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
lemurclaw_core::app_server_protocol::HookEventName::PreToolUse,
lemurclaw_core::app_server_protocol::HookRunStatus::Blocked,
vec![lemurclaw_core::app_server_protocol::HookOutputEntry {
kind: lemurclaw_core::app_server_protocol::HookOutputEntryKind::Feedback,
text: "command blocked by policy".to_string(),
}],
),
);
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
let completed_snapshot = hook_live_and_history_snapshot(&chat, "completed", &history);
assert_chatwidget_snapshot!(
"completed_hook_with_output_flushes_immediately_snapshot",
format!("{running_snapshot}\n\n{completed_snapshot}")
);
}
#[tokio::test]
async fn completed_hook_output_precedes_following_assistant_message() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
lemurclaw_core::app_server_protocol::HookEventName::PreToolUse,
Some("checking command"),
),
);
reveal_running_hooks(&mut chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
lemurclaw_core::app_server_protocol::HookEventName::PreToolUse,
lemurclaw_core::app_server_protocol::HookRunStatus::Blocked,
vec![lemurclaw_core::app_server_protocol::HookOutputEntry {
kind: lemurclaw_core::app_server_protocol::HookOutputEntryKind::Feedback,
text: "command blocked by policy".to_string(),
}],
),
);
complete_assistant_message(
&mut chat,
"msg-after-hook",
"The hook feedback was applied.",
None,
);
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"completed_hook_output_precedes_following_assistant_message_snapshot",
format!(
"active hooks:\n{}history:\n{history}",
active_hook_blob(&chat)
)
);
let hook_index = history
.find("PreToolUse hook (blocked)")
.expect("hook feedback should be in history");
let assistant_index = history
.find("The hook feedback was applied.")
.expect("assistant message should be in history");
assert!(
hook_index < assistant_index,
"hook output should precede later assistant text: {history:?}"
);
}
#[tokio::test]
async fn completed_same_id_hook_output_survives_restart() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let hook_id = "stop:0:/tmp/hooks.json";
handle_hook_started(
&mut chat,
hook_started_run(
hook_id,
lemurclaw_core::app_server_protocol::HookEventName::Stop,
Some("checking stop condition"),
),
);
reveal_running_hooks(&mut chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
hook_id,
lemurclaw_core::app_server_protocol::HookEventName::Stop,
lemurclaw_core::app_server_protocol::HookRunStatus::Stopped,
vec![lemurclaw_core::app_server_protocol::HookOutputEntry {
kind: lemurclaw_core::app_server_protocol::HookOutputEntryKind::Stop,
text: "continue with more context".to_string(),
}],
),
);
handle_hook_started(
&mut chat,
hook_started_run(
hook_id,
lemurclaw_core::app_server_protocol::HookEventName::Stop,
Some("checking stop condition"),
),
);
reveal_running_hooks(&mut chat);
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"completed_same_id_hook_output_survives_restart_snapshot",
format!(
"active hooks:\n{}history:\n{history}",
active_hook_blob(&chat)
)
);
assert!(
history.contains("Stop hook (stopped)\n stop: continue with more context"),
"first hook output should not be overwritten: {history:?}"
);
}
#[tokio::test]
async fn identical_parallel_running_hooks_collapse_to_count() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
for tool_call_id in ["tool-call-1", "tool-call-2", "tool-call-3"] {
handle_hook_started(
&mut chat,
hook_started_run(
&format!("pre-tool-use:0:/tmp/hooks.json:{tool_call_id}"),
lemurclaw_core::app_server_protocol::HookEventName::PreToolUse,
Some("checking command policy"),
),
);
}
reveal_running_hooks(&mut chat);
assert_chatwidget_snapshot!(
"identical_parallel_running_hooks_collapse_to_count_snapshot",
hook_live_and_history_snapshot(&chat, "running", "")
);
}
#[tokio::test]
async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.set_status_header("Thinking".to_string());
chat.bottom_pane.ensure_status_indicator();
handle_hook_started(
&mut chat,
hook_started_run(
"pre-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PreToolUse,
Some("checking command policy"),
),
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
reveal_running_hooks(&mut chat);
let first_running_snapshot = hook_live_and_history_snapshot(&chat, "pre running", "");
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:1:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
),
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
reveal_running_hooks(&mut chat);
let second_running_snapshot = hook_live_and_history_snapshot(&chat, "post running", "");
handle_hook_completed(
&mut chat,
hook_completed_run(
"pre-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PreToolUse,
lemurclaw_core::app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
let older_completed_snapshot =
hook_live_and_history_snapshot(&chat, "pre completed lingering", "");
expire_quiet_hook_linger(&mut chat);
let older_completed_expired_snapshot =
hook_live_and_history_snapshot(&chat, "pre completed after linger", "");
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:1:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
lemurclaw_core::app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
assert!(chat.bottom_pane.status_indicator_visible());
assert!(drain_insert_history(&mut rx).is_empty());
let all_completed_lingering_snapshot =
hook_live_and_history_snapshot(&chat, "all completed lingering", "");
expire_quiet_hook_linger(&mut chat);
let all_completed_snapshot = hook_live_and_history_snapshot(&chat, "all completed", "");
assert_chatwidget_snapshot!(
"overlapping_hook_live_cell_snapshot",
format!(
"{first_running_snapshot}\n\n{second_running_snapshot}\n\n{older_completed_snapshot}\n\n{older_completed_expired_snapshot}\n\n{all_completed_lingering_snapshot}\n\n{all_completed_snapshot}"
)
);
}
#[tokio::test]
async fn running_hook_does_not_displace_active_exec_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
let begin = begin_exec(&mut chat, "call-1", "echo done");
let exec_running = active_blob(&chat);
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
),
);
reveal_running_hooks(&mut chat);
let exec_and_hook_running = format!(
"active exec:\n{}active hooks:\n{}",
active_blob(&chat),
active_hook_blob(&chat)
);
end_exec(&mut chat, begin, "done", "", 0);
let history_after_exec = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
let hook_running_after_exec = active_hook_blob(&chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
lemurclaw_core::app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
let quiet_hook_completed_lingering = active_hook_blob(&chat);
expire_quiet_hook_linger(&mut chat);
let quiet_hook_completed = active_hook_blob(&chat);
assert_chatwidget_snapshot!(
"hook_runs_while_exec_active_snapshot",
format!(
"exec running:\n{exec_running}\nexec and hook running:\n{exec_and_hook_running}\nhistory after exec:\n{history_after_exec}\nhook running after exec:\n{hook_running_after_exec}\nquiet hook completed lingering:\n{quiet_hook_completed_lingering}\nquiet hook completed:\n{quiet_hook_completed}"
)
);
}
#[tokio::test]
async fn hidden_active_hook_does_not_add_transcript_separator() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
begin_exec(&mut chat, "call-1", "echo done");
let exec_only_line_count = chat
.active_cell_transcript_lines( 80)
.expect("active exec transcript lines")
.len();
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
),
);
let hidden_hook_transcript = chat
.active_cell_transcript_lines( 80)
.expect("active exec transcript lines");
assert_eq!(hidden_hook_transcript.len(), exec_only_line_count);
reveal_running_hooks(&mut chat);
let visible_hook_lines = chat
.active_hook_cell
.as_ref()
.expect("active hook cell")
.transcript_lines( 80);
let visible_hook_transcript = chat
.active_cell_transcript_lines( 80)
.expect("active exec and hook transcript lines");
assert_eq!(
visible_hook_transcript.len(),
exec_only_line_count + 1 + visible_hook_lines.len()
);
assert_eq!(
lines_to_single_string(
&visible_hook_transcript[exec_only_line_count..exec_only_line_count + 1],
),
"\n"
);
}
#[tokio::test]
async fn hook_completed_before_reveal_renders_completed_without_running_flash() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"session-start:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::SessionStart,
Some("warming the shell"),
),
);
let started_hidden_snapshot = active_hook_blob(&chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
"session-start:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::SessionStart,
lemurclaw_core::app_server_protocol::HookRunStatus::Completed,
vec![lemurclaw_core::app_server_protocol::HookOutputEntry {
kind: lemurclaw_core::app_server_protocol::HookOutputEntryKind::Context,
text: "session context\nsecond line".to_string(),
}],
),
);
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"hook_completed_before_reveal_renders_completed_without_running_flash_snapshot",
format!("started hidden:\n{started_hidden_snapshot}\nhistory:\n{history}")
);
}
#[tokio::test]
async fn long_hook_context_is_truncated_with_transcript_hint_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_hook_completed(
&mut chat,
hook_completed_run(
"session-start:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::SessionStart,
lemurclaw_core::app_server_protocol::HookRunStatus::Stopped,
vec![
lemurclaw_core::app_server_protocol::HookOutputEntry {
kind: lemurclaw_core::app_server_protocol::HookOutputEntryKind::Context,
text: "This hook context is intentionally long enough to wrap across several terminal rows while keeping the complete value available in the transcript overlay. The main conversation should stay compact even when a hook injects a large block of instructions for the model."
.to_string(),
},
lemurclaw_core::app_server_protocol::HookOutputEntry {
kind: lemurclaw_core::app_server_protocol::HookOutputEntryKind::Stop,
text: "The hook stopped this turn for an important reason.\nThis second line must remain visible in full."
.to_string(),
},
],
),
);
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"long_hook_context_is_truncated_with_transcript_hint",
history
);
}
#[tokio::test]
async fn session_start_hook_events_render_snapshot() {
assert_hook_events_snapshot(
lemurclaw_core::app_server_protocol::HookEventName::SessionStart,
"session-start:0:/tmp/hooks.json",
"warming the shell",
"session_start_hook_events_render_snapshot",
)
.await;
}
fn hook_started_run(
id: &str,
event_name: lemurclaw_core::app_server_protocol::HookEventName,
status_message: Option<&str>,
) -> lemurclaw_core::app_server_protocol::HookRunSummary {
hook_run_summary(
id,
event_name,
lemurclaw_core::app_server_protocol::HookRunStatus::Running,
status_message,
Vec::new(),
)
}
fn hook_completed_run(
id: &str,
event_name: lemurclaw_core::app_server_protocol::HookEventName,
status: lemurclaw_core::app_server_protocol::HookRunStatus,
entries: Vec<lemurclaw_core::app_server_protocol::HookOutputEntry>,
) -> lemurclaw_core::app_server_protocol::HookRunSummary {
hook_run_summary(
id, event_name, status, None, entries,
)
}
fn hook_run_summary(
id: &str,
event_name: lemurclaw_core::app_server_protocol::HookEventName,
status: lemurclaw_core::app_server_protocol::HookRunStatus,
status_message: Option<&str>,
entries: Vec<lemurclaw_core::app_server_protocol::HookOutputEntry>,
) -> lemurclaw_core::app_server_protocol::HookRunSummary {
lemurclaw_core::app_server_protocol::HookRunSummary {
id: id.to_string(),
event_name,
handler_type: lemurclaw_core::app_server_protocol::HookHandlerType::Command,
execution_mode: lemurclaw_core::app_server_protocol::HookExecutionMode::Sync,
scope: lemurclaw_core::app_server_protocol::HookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: lemurclaw_core::app_server_protocol::HookSource::User,
display_order: 0,
status,
status_message: status_message.map(str::to_string),
started_at: 1,
completed_at: (status != lemurclaw_core::app_server_protocol::HookRunStatus::Running).then_some(2),
duration_ms: (status != lemurclaw_core::app_server_protocol::HookRunStatus::Running).then_some(1),
entries,
}
}
fn hook_live_and_history_snapshot(chat: &ChatWidget, phase: &str, history: &str) -> String {
let history = if history.is_empty() {
"<empty>"
} else {
history
};
format!(
"{phase}\nlive hooks:\n{}history:\n{history}",
active_hook_blob(chat),
)
}
#[tokio::test]
async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
complete_assistant_message(
&mut chat,
"msg-search",
"I’m going to search the repo for where “Change Approved” is rendered to update that view.",
None,
);
let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()];
let parsed_cmd = [
ParsedCommand::Search {
query: Some("Change Approved".into()),
path: None,
cmd: "rg \"Change Approved\"".into(),
},
ParsedCommand::Read {
name: "diff_render.rs".into(),
cmd: "cat diff_render.rs".into(),
path: "diff_render.rs".into(),
},
];
let command_actions = parsed_cmd
.iter()
.cloned()
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd))
.collect::<Vec<_>>();
let cwd = chat.config.cwd.clone();
handle_exec_begin(
&mut chat,
AppServerThreadItem::CommandExecution {
id: "c1".into(),
command: lemurclaw_core::shell_command::parse_command::shlex_join(&command),
cwd: cwd.clone().into(),
process_id: None,
source: ExecCommandSource::Agent,
status: AppServerCommandExecutionStatus::InProgress,
command_actions: command_actions.clone(),
aggregated_output: None,
exit_code: None,
duration_ms: None,
},
);
handle_exec_end(
&mut chat,
AppServerThreadItem::CommandExecution {
id: "c1".into(),
command: lemurclaw_core::shell_command::parse_command::shlex_join(&command),
cwd: cwd.into(),
process_id: None,
source: ExecCommandSource::Agent,
status: AppServerCommandExecutionStatus::Completed,
command_actions,
aggregated_output: None,
exit_code: Some(0),
duration_ms: Some(16000),
},
);
handle_turn_started(&mut chat, "turn-1");
handle_agent_reasoning_delta(&mut chat, "**Investigating rendering code**");
chat.bottom_pane.set_composer_text(
"Summarize recent commits".to_string(),
Vec::new(),
Vec::new(),
);
let width: u16 = 80;
let ui_height: u16 = chat.desired_height(width);
let vt_height: u16 = 40;
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
let backend = VT100Backend::new(width, vt_height);
let mut term = crate::tui_internal::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(viewport);
for lines in drain_insert_history(&mut rx) {
crate::tui_internal::insert_history::insert_history_lines(&mut term, lines)
.expect("Failed to insert history lines in test");
}
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_chatwidget_snapshot!(
"chatwidget_exec_and_status_layout_vt100_snapshot",
normalize_snapshot_paths(term.backend().vt100().screen().contents())
);
}
#[tokio::test]
async fn chatwidget_markdown_code_blocks_vt100_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
handle_turn_started(&mut chat, "turn-1");
let width: u16 = 80;
let height: u16 = 50;
let backend = VT100Backend::new(width, height);
let mut term = crate::tui_internal::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new(0, height - 1, width, 1));
let source: &str = r#"
-- Indented code block (4 spaces)
SELECT *
FROM "users"
WHERE "email" LIKE '%@example.com';
````markdown
```sh
printf 'fenced within fenced\n'
```
````
```jsonc
{
// comment allowed in jsonc
"path": "C:\\Program Files\\App",
"regex": "^foo.*(bar)?$"
}
```
"#;
let mut it = source.chars();
loop {
let mut delta = String::new();
match it.next() {
Some(c) => delta.push(c),
None => break,
}
if let Some(c2) = it.next() {
delta.push(c2);
}
handle_agent_message_delta(&mut chat, delta);
loop {
chat.on_commit_tick();
let mut inserted_any = false;
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = app_ev {
let lines = cell.display_lines(width);
crate::tui_internal::insert_history::insert_history_lines(&mut term, lines)
.expect("Failed to insert history lines in test");
inserted_any = true;
}
}
if !inserted_any {
break;
}
}
}
handle_turn_completed(&mut chat, "turn-1", None);
for lines in drain_insert_history(&mut rx) {
crate::tui_internal::insert_history::insert_history_lines(&mut term, lines)
.expect("Failed to insert history lines in test");
}
assert_chatwidget_snapshot!(
"chatwidget_markdown_code_blocks_vt100_snapshot",
normalize_snapshot_paths(term.backend().vt100().screen().contents())
);
}
#[tokio::test]
async fn chatwidget_tall() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual( None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
for i in 0..30 {
chat.queue_user_message(format!("Hello, world! {i}").into());
}
let width: u16 = 80;
let height: u16 = 24;
let backend = VT100Backend::new(width, height);
let mut term = crate::tui_internal::custom_terminal::Terminal::with_options(backend).expect("terminal");
let desired_height = chat.desired_height(width).min(height);
term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height));
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_chatwidget_snapshot!(
"chatwidget_tall",
normalize_snapshot_paths(term.backend().vt100().screen().contents())
);
}