use super::*;
use crate::{input::PromptInput, thndrs_core};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::path::Path;
fn with_isolated_setup_env<T>(home: &Path, f: impl FnOnce() -> T) -> T {
let _guard = crate::test_env::lock();
let old_home = std::env::var_os("HOME");
let old_umans = std::env::var_os(thndrs_core::auth::UMANS_API_KEY_ENV);
let old_opencode = std::env::var_os(thndrs_core::auth::OPENCODE_GO_KEY_ENV);
let old_opencode_zen = std::env::var_os(thndrs_core::auth::OPENCODE_ZEN_KEY_ENV);
let old_chatgpt = std::env::var_os(thndrs_core::auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV);
unsafe {
std::env::set_var("HOME", home);
std::env::remove_var(thndrs_core::auth::UMANS_API_KEY_ENV);
std::env::remove_var(thndrs_core::auth::OPENCODE_GO_KEY_ENV);
std::env::remove_var(thndrs_core::auth::OPENCODE_ZEN_KEY_ENV);
std::env::remove_var(thndrs_core::auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV);
}
let result = f();
unsafe {
if let Some(value) = old_home {
std::env::set_var("HOME", value);
} else {
std::env::remove_var("HOME");
}
if let Some(value) = old_umans {
std::env::set_var(thndrs_core::auth::UMANS_API_KEY_ENV, value);
} else {
std::env::remove_var(thndrs_core::auth::UMANS_API_KEY_ENV);
}
if let Some(value) = old_opencode {
std::env::set_var(thndrs_core::auth::OPENCODE_GO_KEY_ENV, value);
} else {
std::env::remove_var(thndrs_core::auth::OPENCODE_GO_KEY_ENV);
}
if let Some(value) = old_opencode_zen {
std::env::set_var(thndrs_core::auth::OPENCODE_ZEN_KEY_ENV, value);
} else {
std::env::remove_var(thndrs_core::auth::OPENCODE_ZEN_KEY_ENV);
}
if let Some(value) = old_chatgpt {
std::env::set_var(thndrs_core::auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV, value);
} else {
std::env::remove_var(thndrs_core::auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV);
}
}
result
}
#[test]
fn slash_clear_while_working_is_rejected() {
let mut app = working_app_with_streaming();
app.transcript.push(Entry::User { text: "keep me".to_string() });
app.input = PromptInput::from("/clear");
let result = update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(result, None, "/clear should not execute while working");
assert!(
app.transcript
.iter()
.any(|e| matches!(e, Entry::User { text } if text == "keep me")),
"transcript should not be cleared while an agent can still emit events"
);
assert!(
app.transcript
.iter()
.any(|e| matches!(e, Entry::Status { text } if text.contains("not available"))),
"/clear should be rejected with a status message"
);
assert!(app.input.is_empty(), "input should be cleared after /clear");
}
#[test]
fn slash_help_while_working_executes_immediately() {
let mut app = working_app_with_streaming();
app.input = PromptInput::from("/help");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
app.prompt_accessory,
PromptAccessory::Help,
"/help should open help while working"
);
assert!(app.input.is_empty(), "input should be cleared after /help");
}
#[test]
fn slash_model_while_working_is_rejected() {
let mut app = working_app_with_streaming();
app.input = PromptInput::from("/model");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
app.prompt_accessory,
PromptAccessory::None,
"/model should not open picker while working"
);
assert!(
app.transcript
.iter()
.any(|e| matches!(e, Entry::Status { text } if text.contains("not available"))),
"/model should be rejected with a status message"
);
}
#[test]
fn slash_skills_while_working_is_rejected() {
let mut app = working_app_with_streaming();
app.input = PromptInput::from("/skills");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
app.prompt_accessory,
PromptAccessory::None,
"/skills should not open picker while working"
);
assert!(
app.transcript
.iter()
.any(|e| matches!(e, Entry::Status { text } if text.contains("not available"))),
"/skills should be rejected with a status message"
);
}
#[test]
fn slash_unknown_while_working_is_rejected() {
let mut app = working_app_with_streaming();
app.input = PromptInput::from("/unknown");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(
app.queued_followups.is_empty(),
"unknown slash command should not be queued as text"
);
assert!(
app.transcript
.iter()
.any(|e| matches!(e, Entry::Status { text } if text.contains("not available"))),
"unknown slash command should be rejected with a status message"
);
}
#[test]
fn double_slash_while_working_queues_literal_slash_followup() {
let mut app = working_app_with_streaming();
app.input = PromptInput::from("//clear after this run");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
app.queued_followups,
vec!["/clear after this run".to_string()],
"double slash should escape a literal slash-prefixed follow-up"
);
assert!(app.input.is_empty(), "input should be cleared after queueing");
}
#[test]
fn slash_clear_clears_transcript_and_input() {
let mut app = fresh_app();
app.transcript.push(Entry::User { text: String::from("old") });
app.input = PromptInput::from("/clear");
update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
assert!(app.transcript.is_empty());
assert_eq!(app.input.as_str(), "");
assert!(!app.quit);
}
#[test]
fn slash_auth_config_and_doctor_append_redacted_output() {
let mut app = fresh_app();
app.input = PromptInput::from("/auth status");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(app.transcript.last(), Some(Entry::Status { text }) if text.contains("umans")));
app.input = PromptInput::from("/config path");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(app.transcript.last(), Some(Entry::Status { text }) if text.contains("global:") && text.contains("project:"))
);
app.input = PromptInput::from("/config show");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(app.transcript.last(), Some(Entry::Status { text }) if text.contains("effective_config:")));
app.input = PromptInput::from("/doctor");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let transcript = format!("{:?}", app.transcript);
assert!(transcript.contains("thndrs doctor"));
assert!(!transcript.contains("test-umans-key"));
}
#[test]
fn context_surface_is_bounded_and_does_not_render_source_content() {
let mut app = fresh_app();
let source = app.cwd.join("AGENTS.md");
std::fs::write(&source, "api_key=source-secret-that-must-not-be-rendered\n").expect("write instructions");
app.context_sources = vec![crate::context::load_agents_md(&app.cwd).expect("load instructions")];
app.input = PromptInput::from("/context");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.prompt_accessory, PromptAccessory::Context);
let table = app.render_context_table();
let text = table
.rows
.iter()
.flat_map(|row| row.iter())
.map(|cell| cell.text.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(text.contains("budget"));
assert!(!text.contains("source-secret-that-must-not-be-rendered"));
assert!(table.rows.len() <= 67, "context table must stay bounded");
}
#[test]
fn tokens_command_exposes_estimate_provider_components_and_error() {
let mut app = fresh_app();
let mut accounting = thndrs_agent::ProviderRequestAccounting::from_serialized_request(
"turn_1",
"turn_1:request:1",
1,
"fixture",
"fixture-model",
b"request",
Vec::new(),
);
accounting.provider_usage = Some(
thndrs_agent::ProviderUsageComponents {
input_tokens: Some(100),
output_tokens: Some(7),
cache_read_input_tokens: Some(4),
cache_creation_input_tokens: Some(2),
reasoning_tokens: None,
}
.normalize("fixture", thndrs_agent::ProviderUsageRule::AnthropicMessages),
);
app.last_request_accounting = Some(accounting);
app.input = PromptInput::from("/tokens");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let output = app
.transcript
.iter()
.find_map(|entry| match entry {
Entry::Status { text } if text.starts_with("tokens\n") => Some(text.as_str()),
_ => None,
})
.expect("token inspection output");
assert!(output.contains("estimated/"));
assert!(output.contains("100 input / 7 output"));
assert!(output.contains("cache: 4 read / 2 create"));
assert!(output.contains("normalized input: 106"));
assert!(output.contains("estimate error:"));
}
#[test]
fn context_export_command_writes_versioned_json_and_markdown() {
let mut app = fresh_app();
let json_path = app.cwd.join("context-export.json");
app.input = PromptInput::from("/context export context-export.json");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let json = std::fs::read_to_string(&json_path).expect("json export");
assert!(json.contains("context-export-v1"));
assert!(json.contains("model_projection"));
let markdown_path = app.cwd.join("context-export.md");
app.input = PromptInput::from("/context export context-export.md markdown");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let markdown = std::fs::read_to_string(&markdown_path).expect("markdown export");
assert!(markdown.starts_with("# Context export"));
assert!(markdown.contains("## Context items"));
}
#[test]
fn context_pin_drop_recover_and_failed_pin_preserve_prompt_input() {
let mut app = fresh_app();
let file = app.cwd.join("notes.md");
std::fs::write(&file, "private notes").expect("write file");
app.input = PromptInput::from("/context pin notes.md");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.input.is_empty(), "successful context action clears its command");
let pinned_id = app
.context_ledger
.as_ref()
.expect("ledger")
.items
.iter()
.find(|item| item.kind == thndrs_agent::context::ContextItemKind::PinnedFile)
.expect("pinned item")
.id
.clone();
app.input = PromptInput::from(format!("/context drop {pinned_id}"));
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(
app.context_ledger
.as_ref()
.expect("ledger")
.items
.iter()
.any(|item| item.id == pinned_id && item.visibility == thndrs_agent::context::ContextVisibility::Dropped)
);
app.input = PromptInput::from(format!("/context recover {pinned_id}"));
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(
app.context_ledger
.as_ref()
.expect("ledger")
.items
.iter()
.any(|item| item.id == pinned_id && item.visibility == thndrs_agent::context::ContextVisibility::Pinned)
);
app.input = PromptInput::from("/context pin missing.md");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.input.as_str(), "/context pin missing.md");
assert!(format!("{:?}", app.transcript).contains("cannot pin"));
}
#[test]
fn slash_config_edit_reports_cli_only() {
let mut app = fresh_app();
app.input = PromptInput::from("/config edit");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
app.transcript.last(),
Some(Entry::Status { text }) if text.contains("config edit is CLI-only")
));
}
#[test]
fn slash_command_rejects_api_key_like_extra_argument() {
let mut app = fresh_app();
app.input = PromptInput::from("/login umans sk-secret-should-not-appear");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.first_run_recovery.is_none());
let transcript = format!("{:?}", app.transcript);
assert!(transcript.contains("do not accept API keys"));
assert!(!transcript.contains("sk-secret-should-not-appear"));
let mut app = fresh_app();
app.input = PromptInput::from("/login chatgpt-codex access_token=secret-should-not-appear");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.first_run_recovery.is_none());
let transcript = format!("{:?}", app.transcript);
assert!(transcript.contains("do not accept API keys"));
assert!(!transcript.contains("secret-should-not-appear"));
}
#[test]
fn slash_logout_requires_confirmation_surface() {
let mut app = fresh_app();
app.input = PromptInput::from("/logout umans");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let recovery = app.first_run_recovery.as_ref().expect("logout recovery");
assert_eq!(recovery.stage, RecoveryStage::LogoutConfirm);
assert_eq!(recovery.provider, Some(SetupProviderArg::Umans));
}
#[test]
fn slash_chatgpt_codex_logout_stays_cli_only() {
let mut app = fresh_app();
app.input = PromptInput::from("/logout chatgpt-codex");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.first_run_recovery.is_none());
assert!(matches!(
app.transcript.last(),
Some(Entry::Status { text }) if text.contains("ChatGPT Codex logout is CLI-only")
));
}
#[test]
fn slash_setup_and_login_open_recovery_surfaces() {
let mut app = fresh_app();
app.input = PromptInput::from("/setup");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
app.first_run_recovery.as_ref().map(|recovery| recovery.stage),
Some(RecoveryStage::ChooseProvider)
));
app.first_run_recovery = None;
app.input = PromptInput::from("/login opencode-go");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let recovery = app.first_run_recovery.as_ref().expect("login recovery");
assert_eq!(recovery.stage, RecoveryStage::EnterKey);
assert_eq!(recovery.provider, Some(SetupProviderArg::OpencodeGo));
}
#[test]
fn slash_setup_uses_chatgpt_provider_aware_recovery_for_chatgpt_model() {
let mut app = fresh_app();
app.model = "chatgpt-codex/gpt-5.5".to_string();
app.cli.model = app.model.clone();
app.input = PromptInput::from("/setup");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let recovery = app.first_run_recovery.as_ref().expect("setup recovery");
assert_eq!(recovery.stage, RecoveryStage::ChooseProvider);
assert_eq!(recovery.provider, Some(SetupProviderArg::ChatgptCodex));
}
#[test]
fn slash_setup_writes_project_model_and_prompts_for_api_key() {
let dir = tempfile::tempdir().expect("create temp dir");
let home = dir.path().join("home");
std::fs::create_dir_all(&home).expect("create home");
with_isolated_setup_env(&home, || {
let cli = Cli { cwd: dir.path().to_path_buf(), ..Cli::default() };
let mut app = App::from_cli(&cli);
app.session_writer = None;
app.first_run_recovery = None;
app.input = PromptInput::from("/setup");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let recovery = app.first_run_recovery.as_ref().expect("credential entry");
assert_eq!(recovery.stage, RecoveryStage::MissingCredential);
assert_eq!(recovery.provider, Some(SetupProviderArg::Umans));
assert!(app.model.is_empty(), "authentication precedes model selection");
});
}
#[test]
fn slash_setup_can_choose_chatgpt_and_write_project_model() {
let dir = tempfile::tempdir().expect("create temp dir");
let home = dir.path().join("home");
let workspace = dir.path().join("workspace");
std::fs::create_dir_all(&home).expect("create home");
std::fs::create_dir_all(&workspace).expect("create workspace");
with_isolated_setup_env(&home, || {
let cli = Cli { cwd: workspace.clone(), ..Cli::default() };
let mut app = App::from_cli(&cli);
app.session_writer = None;
app.first_run_recovery = None;
app.model = "chatgpt-codex/gpt-5.5".to_string();
app.cli.model = app.model.clone();
app.input = PromptInput::from("/setup");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let recovery = app.first_run_recovery.as_ref().expect("chatgpt recovery");
assert_eq!(recovery.stage, RecoveryStage::MissingCredential);
assert_eq!(recovery.provider, Some(SetupProviderArg::ChatgptCodex));
});
}
#[test]
fn slash_chatgpt_codex_login_opens_oauth_recovery_surface() {
let mut app = fresh_app();
app.input = PromptInput::from("/login chatgpt-codex");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let recovery = app.first_run_recovery.as_ref().expect("login recovery");
assert_eq!(recovery.stage, RecoveryStage::MissingCredential);
assert_eq!(recovery.provider, Some(SetupProviderArg::ChatgptCodex));
}
#[test]
fn slash_chatgpt_codex_login_surface_starts_tui_oauth() {
let mut app = fresh_app();
app.chatgpt_oauth_driver = ChatGptOAuthDriver {
request_device_code: oauth_request_ok,
poll_device_code_once: oauth_poll_pending,
write_credentials: oauth_write_ok,
..Default::default()
};
app.input = PromptInput::from("/login chatgpt-codex");
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
update(&mut app, &key(KeyCode::Down, KeyModifiers::NONE));
update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));
let recovery = app.first_run_recovery.as_ref().expect("oauth recovery");
assert_eq!(recovery.stage, RecoveryStage::ChatGptOAuthPolling);
assert!(recovery.chatgpt_oauth.is_some());
}
#[test]
fn slash_quit_sets_quit_flag() {
let mut app = fresh_app();
app.input = PromptInput::from("/quit");
let follow = update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
assert!(app.quit);
assert_eq!(follow, Some(Msg::Quit));
assert_eq!(app.input.as_str(), "");
}
#[test]
fn slash_exit_also_quits() {
let mut app = fresh_app();
app.input = PromptInput::from("/exit");
update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
assert!(app.quit);
}
#[test]
fn unknown_slash_command_is_ignored() {
let mut app = fresh_app();
app.input = PromptInput::from("/bogus");
update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
assert!(!app.quit);
assert!(app.transcript.is_empty());
assert_eq!(app.input.as_str(), "/bogus");
}
#[test]
fn slash_mcp_lists_empty_config() {
let temp = tempfile::tempdir().expect("tempdir");
let mut app = fresh_app();
app.cwd = temp.path().to_path_buf();
app.input = PromptInput::from("/mcp");
update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
assert!(
matches!(app.transcript.last(), Some(Entry::Status { text }) if text.contains("no MCP servers configured"))
);
}
#[test]
fn slash_mcp_tools_requires_name() {
let mut app = fresh_app();
app.input = PromptInput::from("/mcp tools ");
update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
assert!(matches!(app.transcript.last(), Some(Entry::Error { text }) if text.contains("usage: /mcp tools <name>")));
}