mod compact;
pub(crate) mod runtime;
use self::compact::handle_compact_builtin;
use crate::{
checkpoints::{
ChangeClassification, ChangesReadModel, CheckpointStore, ParsedRewindTarget, RestorePlan,
RestoreStatus, parse_rewind_target, redacted_path_for_display,
},
commands::{BuiltInCommand, FastModeCommand, parse_fast_mode_command},
config::{AuthState, EffectiveConfig},
herdr::{HerdrReporter, HerdrSessionStartSource},
sessions::{
Session, SessionExportReport, SessionManager, export_session, parse_prune_sessions_days,
},
};
use std::{collections::HashMap, path::PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ShellFastObservation {
pub(crate) observation: crate::fast::FastObservation,
pub(crate) prompt_order: u64,
}
#[derive(Debug, Clone)]
pub struct ShellState {
pub session_manager: SessionManager,
pub current_session: Option<Session>,
pub cwd: PathBuf,
pub model: String,
pub auth_state: AuthState,
pub config: Option<EffectiveConfig>,
pub(crate) herdr_reporter: Option<HerdrReporter>,
fast_observations: HashMap<(String, String), ShellFastObservation>,
next_prompt_order: u64,
}
impl ShellState {
pub fn new(
session_manager: SessionManager,
current_session: Option<Session>,
cwd: PathBuf,
model: String,
auth_state: AuthState,
) -> Self {
Self {
session_manager,
current_session,
cwd,
model,
auth_state,
config: None,
herdr_reporter: None,
fast_observations: HashMap::new(),
next_prompt_order: 0,
}
}
pub(crate) fn with_herdr_reporter(mut self, herdr_reporter: Option<HerdrReporter>) -> Self {
self.herdr_reporter = herdr_reporter;
self
}
pub fn with_config(mut self, config: EffectiveConfig) -> Self {
self.auth_state = config.auth_state();
self.config = Some(config);
self
}
pub fn active_session_id(&self) -> Option<&str> {
self.current_session.as_ref().map(|session| session.id())
}
pub(crate) fn next_prompt_order(&mut self) -> u64 {
self.next_prompt_order = self.next_prompt_order.saturating_add(1);
self.next_prompt_order
}
pub(crate) fn fast_observation(
&self,
provider_id: &str,
model: &str,
) -> Option<&ShellFastObservation> {
let key = crate::fast::canonical_fast_observation_key(provider_id, model);
self.fast_observations.get(&key)
}
pub(crate) fn accept_fast_observation(
&mut self,
provider_id: &str,
model: &str,
requested_service_tier: &str,
outcome: &crate::fast::FastOutcome,
prompt_order: u64,
request_sequence: u64,
) -> bool {
let key = crate::fast::canonical_fast_observation_key(provider_id, model);
if self
.fast_observations
.get(&key)
.is_some_and(|current| prompt_order <= current.prompt_order)
{
return false;
}
self.fast_observations.insert(
key,
ShellFastObservation {
observation: crate::fast::FastObservation {
requested_service_tier: requested_service_tier.to_string(),
outcome: outcome.clone(),
request_sequence,
run_order: None,
},
prompt_order,
},
);
true
}
pub(crate) fn clear_fast_observations(&mut self) {
self.fast_observations.clear();
}
}
pub fn handle_builtin(
command: BuiltInCommand,
arg: Option<&str>,
state: &mut ShellState,
) -> anyhow::Result<String> {
match command {
BuiltInCommand::Update => {
anyhow::bail!(
"/update requires idle Mission Control; use magi-code --update outside the service"
)
}
BuiltInCommand::Fast => handle_fast_mode_builtin(arg, state),
BuiltInCommand::Compact => handle_compact_builtin(arg, state),
BuiltInCommand::Changes => handle_changes_builtin(state),
BuiltInCommand::Export => handle_export_builtin(arg, state),
BuiltInCommand::Rewind => handle_rewind_builtin_noninteractive(arg, state),
BuiltInCommand::New => {
let session = state.session_manager.create()?.admit_standalone_writer()?;
let session_id = session.id().to_string();
state.current_session = Some(session);
state.clear_fast_observations();
if let Some(reporter) = state.herdr_reporter.as_ref() {
reporter
.report_agent_session_with_source(&session_id, HerdrSessionStartSource::New);
reporter.report_title(None);
}
Ok(format!(
"new session: {}",
state.active_session_id().unwrap_or("<disabled>")
))
}
BuiltInCommand::Login => {
anyhow::bail!("/login requires the Mission Control login dialog")
}
BuiltInCommand::Logout => {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/logout requires loaded runtime config"))?;
let Some(provider_id) = arg else {
return crate::login::logout_provider_list_text(&config.paths);
};
let removal =
crate::commands::runtime::remove_logout_target(&config.paths, provider_id)?;
crate::commands::runtime::reconcile_runtime_after_logout(state, None, &removal);
Ok(removal.message())
}
BuiltInCommand::Model => {
let Some(model_id) = arg else {
return Ok(crate::model_catalog::model_usage().to_string());
};
let result = crate::commands::runtime::switch_shell_model(state, model_id)?;
let suffix = result
.notice
.map(|notice| format!(" ({notice})"))
.unwrap_or_default();
Ok(format!("model switched to {model_id}{suffix}"))
}
BuiltInCommand::Models => {
anyhow::bail!("/models is only available in Mission Control TUI")
}
BuiltInCommand::Usage => {
anyhow::bail!("/usage is only available in Mission Control TUI")
}
BuiltInCommand::PruneSessions => handle_prune_sessions_builtin(arg, state),
BuiltInCommand::Help => {
anyhow::bail!("/help is only available in Mission Control TUI")
}
BuiltInCommand::SystemPrompt => {
anyhow::bail!("/system-prompt is only available in Mission Control TUI")
}
BuiltInCommand::Skills => {
anyhow::bail!("/skills is only available in Mission Control TUI")
}
BuiltInCommand::Tools => {
anyhow::bail!("/tools is only available in Mission Control TUI")
}
BuiltInCommand::Subagents => {
anyhow::bail!("/subagents is only available in Mission Control TUI")
}
BuiltInCommand::Mcp => {
anyhow::bail!("/mcp is only available in Mission Control TUI")
}
BuiltInCommand::Sessions => {
if arg.is_some() {
anyhow::bail!("usage: /sessions (Mission Control TUI only)")
}
anyhow::bail!("/sessions is only available in Mission Control TUI")
}
BuiltInCommand::SummarizeStart | BuiltInCommand::SummarizeStop => {
anyhow::bail!("summary controls are only available in Mission Control TUI")
}
BuiltInCommand::Quit => Ok("bye".to_string()),
BuiltInCommand::Theme => {
anyhow::bail!("/theme is only available in Mission Control TUI")
}
}
}
fn handle_fast_mode_builtin(arg: Option<&str>, state: &mut ShellState) -> anyhow::Result<String> {
let command = parse_fast_mode_command(arg).map_err(anyhow::Error::msg)?;
let enabled = {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/fast requires loaded runtime config"))?;
match command {
FastModeCommand::Toggle => crate::config::toggle_fast_mode(&config.paths)?,
FastModeCommand::On => crate::config::set_fast_mode(&config.paths, true)?,
FastModeCommand::Off => crate::config::set_fast_mode(&config.paths, false)?,
FastModeCommand::Status => crate::config::fast_mode_enabled(&config.paths)?,
}
};
if !matches!(command, FastModeCommand::Status) && !enabled {
state.clear_fast_observations();
}
let settings = {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/fast requires loaded runtime config"))?;
crate::config::read_settings(&config.paths)?
};
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/fast requires loaded runtime config"))?;
let provider = config.provider_id();
let model = &state.model;
let capability = crate::fast::resolve_fast_capability(
&settings,
provider,
model,
&config.paths,
config.custom_providers.get(provider),
crate::fast::FastWorkload::Primary,
);
let observation = state
.fast_observation(provider, model)
.map(|stored| stored.observation.clone());
Ok(crate::fast::fast_status_message(
enabled,
provider,
model,
&capability,
observation.as_ref(),
))
}
fn handle_export_builtin(arg: Option<&str>, state: &mut ShellState) -> anyhow::Result<String> {
if arg.is_some() {
anyhow::bail!("usage: /export")
}
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/export requires loaded runtime config"))?;
let session = state
.current_session
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/export requires an active persisted session"))?;
match std::fs::symlink_metadata(session.path()) {
Ok(metadata) if metadata.file_type().is_file() => {}
Ok(_) => anyhow::bail!("/export requires an active persisted session"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
anyhow::bail!("/export requires an active persisted session")
}
Err(error) => return Err(error.into()),
}
let destination = crate::sessions::export_destination(&config.paths.root, session.id())?;
let report = export_session(&state.session_manager, session, &destination)?;
Ok(format_export_status(&report))
}
fn format_export_status(report: &SessionExportReport) -> String {
const MAX_PATH_CHARS: usize = 240;
const MAX_DETAILS_CHARS: usize = 600;
let path = report.destination.display().to_string();
let path = path.chars().take(MAX_PATH_CHARS).collect::<String>();
let completeness = if report.warnings.is_empty() {
"complete"
} else {
"partial"
};
let warnings = report
.warnings
.iter()
.take(8)
.cloned()
.collect::<Vec<_>>()
.join("; ");
let details = format!(
"session export {completeness}: {path} ({} members, {} source bytes); WARNING: ZIP is unencrypted and contains raw potentially sensitive session/project/tool data; private permissions do not make it safe to share{}",
report.member_count,
report.source_bytes,
if warnings.is_empty() {
String::new()
} else {
format!("; warnings: {warnings}")
}
);
let details = crate::output::sanitize_display_text(&details);
details.chars().take(MAX_DETAILS_CHARS).collect()
}
fn handle_changes_builtin(state: &ShellState) -> anyhow::Result<String> {
let (store, session_id) = checkpoint_store_and_session(state)?;
Ok(format_changes_for_tui(&store.changes(&session_id)))
}
pub(crate) fn format_rewind_prompt_preview(
session: &Session,
plan: &RestorePlan,
) -> anyhow::Result<String> {
let prompt = session
.rewind_prompts()?
.into_iter()
.find(|prompt| prompt.turn == plan.target_turn)
.ok_or_else(|| anyhow::anyhow!("prompt target is not in retained session history"))?;
Ok(format_rewind_prompt_text(plan, &prompt.text))
}
pub(crate) fn format_rewind_prompt_text(plan: &RestorePlan, prompt: &str) -> String {
format!(
"Prompt {}: {}\n\nFile preview (files/both only):\n{}",
plan.target_turn,
crate::output::sanitize_display_text(prompt)
.chars()
.take(500)
.collect::<String>(),
format_rewind_plan_for_tui(plan)
)
}
fn handle_rewind_builtin_noninteractive(
arg: Option<&str>,
state: &mut ShellState,
) -> anyhow::Result<String> {
match parse_rewind_target(arg).map_err(|usage| anyhow::anyhow!(rewind_usage(&usage)))? {
ParsedRewindTarget::NeedsSelection => {
anyhow::bail!("usage: /rewind --to <turn> [--dry-run] [--mode conversation|files|both]")
}
ParsedRewindTarget::Target {
target_turn,
dry_run,
mode,
} => {
let (store, session_id) = checkpoint_store_and_session(state)?;
let plan = store.plan_rewind(&session_id, &state.cwd, target_turn);
let session = state
.current_session
.as_ref()
.ok_or_else(|| anyhow::anyhow!("rewind requires a persisted session"))?;
let preview = format!(
"Mode: {}\n{}",
mode.label(),
format_rewind_prompt_preview(session, &plan)?
);
if dry_run {
Ok(preview)
} else {
anyhow::bail!("/rewind requires interactive confirmation; use --dry-run to preview")
}
}
}
}
fn checkpoint_store_and_session(state: &ShellState) -> anyhow::Result<(CheckpointStore, String)> {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("checkpoint commands require loaded runtime config"))?;
let session_id = state
.active_session_id()
.ok_or_else(|| anyhow::anyhow!("checkpoint commands require an active persisted session"))?
.to_string();
Ok((CheckpointStore::from_paths(&config.paths), session_id))
}
fn rewind_usage(reason: &str) -> String {
format!("usage: /rewind [--dry-run] --to <turn> [--mode conversation|files|both]; {reason}")
}
pub(crate) fn format_changes_for_tui(changes: &ChangesReadModel) -> String {
if changes.turns.is_empty() {
return "no rewindable file changes".to_string();
}
let mut lines = vec!["rewindable file changes:".to_string()];
for turn in &changes.turns {
lines.push(format!("turn {}", turn.user_turn));
for change in &turn.changes {
let status = if change.rewindable {
format!("{:?}", change.classification).to_lowercase()
} else {
format!(
"non-rewindable ({})",
change
.unavailable_reason
.as_deref()
.unwrap_or("unavailable")
)
};
let kind = match change.classification {
ChangeClassification::Created => "created",
ChangeClassification::Modified => "modified",
ChangeClassification::Deleted => "deleted",
ChangeClassification::NonRewindable => "non-rewindable",
};
lines.push(format!(
" - {kind}: {} [{status}]",
redacted_path_for_display(&change.relative_path)
));
}
}
lines.extend(
changes
.diagnostics
.iter()
.map(|diagnostic| format!("warning: {diagnostic}")),
);
lines.join("\n")
}
pub(crate) fn format_rewind_plan_for_tui(plan: &RestorePlan) -> String {
if plan.operations.is_empty() {
return format!("no rewind operations for turn {}", plan.target_turn);
}
let latest = plan.latest_turn.unwrap_or(plan.target_turn);
let mut lines = vec![format!(
"rewind preview: turns {}..{} ({} operations)",
plan.target_turn,
latest,
plan.operations.len()
)];
for operation in &plan.operations {
lines.push(format!(
" - {}: {}",
redacted_path_for_display(&operation.relative_path),
operation.kind.label()
));
}
lines.extend(
plan.diagnostics
.iter()
.map(|diagnostic| format!("warning: {diagnostic}")),
);
lines.join("\n")
}
pub(crate) fn format_rewind_execution_for_tui(
execution: &crate::checkpoints::RewindExecution,
) -> String {
let mut lines = vec!["rewind result:".to_string()];
for result in &execution.results {
let status = match result.status {
RestoreStatus::Restored => "restored",
RestoreStatus::DeletedCreated => "deleted-created",
RestoreStatus::ResurrectedDeleted => "resurrected-deleted",
RestoreStatus::SkipConflict => "skip-conflict",
RestoreStatus::SkipUnavailable => "skip-unavailable",
};
let reason = result
.reason
.as_deref()
.map(|reason| format!(" ({})", crate::output::redact_sensitive_text(reason)))
.unwrap_or_default();
lines.push(format!(
" - {}: {status}{reason}",
redacted_path_for_display(&result.relative_path)
));
}
lines.join("\n")
}
fn handle_prune_sessions_builtin(
arg: Option<&str>,
state: &mut ShellState,
) -> anyhow::Result<String> {
let retention_days = parse_prune_sessions_days(arg).map_err(|usage| anyhow::anyhow!(usage))?;
let report = state
.session_manager
.prune_sessions(retention_days, state.active_session_id())?;
Ok(report.summary())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sessions::{SessionEvent, SessionManager};
use tempfile::TempDir;
fn test_state(temp: &TempDir) -> ShellState {
ShellState::new(
SessionManager::new(temp.path().join("sessions")),
None,
temp.path().to_path_buf(),
"gpt-test".to_string(),
AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
},
)
}
fn start_sse_server(summary: &'static str) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2)));
let mut buffer = [0_u8; 4096];
let _ = std::io::Read::read(&mut stream, &mut buffer);
let payload = format!("data: {{\"delta\":\"{summary}\"}}\n\ndata: [DONE]\n\n");
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
payload.len(),
payload
);
let _ = std::io::Write::write_all(&mut stream, response.as_bytes());
});
format!("http://{addr}/v1")
}
fn write_compaction_override_settings(
paths: &crate::config::McPaths,
provider: &str,
model: &str,
base_url: &str,
) {
let settings = crate::config::Settings {
compaction: crate::config::CompactionSettings {
provider: Some(provider.to_string()),
model: Some(model.to_string()),
..crate::config::CompactionSettings::default()
},
custom_providers: std::collections::BTreeMap::from([(
provider.to_string(),
crate::config::make_custom_provider_config("Local", base_url, "").unwrap(),
)]),
..crate::config::Settings::default()
};
crate::config::write_settings(paths, &settings).unwrap();
}
fn missing_codex_config(paths: crate::config::McPaths) -> crate::config::EffectiveConfig {
crate::config::EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-test".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
auth: None,
paths,
}
}
fn ready_anthropic_state(temp: &TempDir, paths: &crate::config::McPaths) -> ShellState {
let config = crate::config::EffectiveConfig {
provider: Some(crate::providers::ANTHROPIC_PROVIDER.to_string()),
model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
auth: Some(crate::config::ProviderCredential::ApiKey {
key: "stored-test-key".to_string(),
}),
paths: paths.clone(),
};
test_state(temp).with_config(config)
}
fn assert_no_secret_path(text: &str) {
assert!(!text.contains("auth.json"), "leaked auth.json: {text}");
assert!(!text.contains(".env"), "leaked .env: {text}");
assert!(!text.contains("id_rsa"), "leaked id_rsa: {text}");
}
#[test]
fn rewind_formatters_redact_secret_paths() {
use crate::checkpoints::{
ChangeEntry, ChangeTurn, RestoreOperation, RestoreOperationKind,
RestoreOperationResult, RewindExecution,
};
let changes = ChangesReadModel {
turns: vec![ChangeTurn {
user_turn: 1,
changes: vec![ChangeEntry {
tool: crate::checkpoints::SnapshotTool::Edit,
relative_path: "auth.json".into(),
classification: ChangeClassification::NonRewindable,
rewindable: false,
unavailable_reason: Some("excluded".to_string()),
}],
}],
diagnostics: Vec::new(),
};
let plan = RestorePlan {
session_id: "session".to_string(),
target_turn: 1,
latest_turn: Some(1),
diagnostics: Vec::new(),
operations: vec![RestoreOperation {
relative_path: ".env".into(),
kind: RestoreOperationKind::SkipUnavailable {
reason: "denied".to_string(),
},
}],
};
let execution = RewindExecution {
results: vec![RestoreOperationResult {
relative_path: "id_rsa".into(),
status: RestoreStatus::SkipUnavailable,
reason: None,
}],
};
assert_no_secret_path(&format_changes_for_tui(&changes));
assert_no_secret_path(&format_rewind_plan_for_tui(&plan));
assert_no_secret_path(&format_rewind_execution_for_tui(&execution));
}
#[test]
fn rewind_execution_formatter_shows_sanitized_reason() {
let execution = crate::checkpoints::RewindExecution {
results: vec![crate::checkpoints::RestoreOperationResult {
relative_path: "safe.txt".into(),
status: RestoreStatus::SkipUnavailable,
reason: Some("write failed: sk-testsecret123456789".to_string()),
}],
};
let output = format_rewind_execution_for_tui(&execution);
assert!(output.contains("write failed"));
assert!(!output.contains("sk-testsecret123456789"));
}
#[test]
fn new_session_reports_new_transition_and_clears_title_after_commit() {
let temp = TempDir::new().unwrap();
let (owner, lines) = crate::herdr::HerdrOwner::new_for_test();
let mut state = test_state(&temp).with_herdr_reporter(Some(owner.reporter()));
let status = handle_builtin(BuiltInCommand::New, None, &mut state).unwrap();
assert!(status.starts_with("new session: "));
let session_id = state.active_session_id().unwrap().to_string();
let lines = lines.lock().unwrap();
assert_eq!(lines.len(), 2);
let session_report: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(session_report["method"], "pane.report_agent_session");
assert_eq!(session_report["params"]["agent_session_id"], session_id);
assert_eq!(session_report["params"]["session_start_source"], "new");
let title_report: serde_json::Value = serde_json::from_str(&lines[1]).unwrap();
assert_eq!(title_report["method"], "pane.report_metadata");
assert_eq!(title_report["params"]["clear_title"], true);
assert!(title_report["params"].get("tokens").is_none());
}
#[test]
fn model_builtin_no_arg_returns_usage() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let output = handle_builtin(BuiltInCommand::Model, None, &mut state).unwrap();
assert!(output.contains("usage: /setmodel <provider>/<model-name>"));
}
#[test]
fn model_builtin_switches_from_fresh_catalog_cache_and_persists() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
let config = crate::config::EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-test".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
auth: None,
paths: paths.clone(),
};
let mut state = test_state(&temp).with_config(config);
let output = handle_builtin(
BuiltInCommand::Model,
Some("openai-codex/gpt-next"),
&mut state,
)
.unwrap();
assert_eq!(state.model, "gpt-next");
assert_eq!(
crate::config::read_settings(&paths)
.unwrap()
.selected_model
.model
.as_deref(),
Some("gpt-next")
);
assert!(output.contains("model switched to openai-codex/gpt-next"));
}
#[test]
fn model_builtin_rejects_disabled_model_without_changing_selection() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
crate::config::set_model_disabled_for_scope(
&paths,
crate::config::SettingsScope::Global,
"openai-codex/gpt-next",
true,
)
.unwrap();
let config = crate::config::EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-test".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
auth: None,
paths: paths.clone(),
};
let mut state = test_state(&temp).with_config(config);
let error = handle_builtin(
BuiltInCommand::Model,
Some("openai-codex/gpt-next"),
&mut state,
)
.unwrap_err()
.to_string();
assert_eq!(error, "cannot select disabled model: openai-codex/gpt-next");
assert_eq!(state.model, "gpt-test");
assert_ne!(
crate::config::read_settings(&paths)
.unwrap()
.selected_model
.model
.as_deref(),
Some("gpt-next")
);
}
#[test]
fn model_builtin_switches_to_codex_from_openai_and_refreshes_runtime_state() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
crate::config::write_settings(
&paths,
&crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
..Default::default()
},
..Default::default()
},
)
.unwrap();
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
let config = crate::config::EffectiveConfig {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
auth: None,
paths: paths.clone(),
};
let mut state = test_state(&temp).with_config(config);
let output = handle_builtin(
BuiltInCommand::Model,
Some("openai-codex/gpt-next"),
&mut state,
)
.unwrap();
assert!(output.contains("model switched to openai-codex/gpt-next"));
assert_eq!(state.model, "gpt-next");
assert_eq!(
state.auth_state.provider(),
crate::providers::OPENAI_CODEX_PROVIDER
);
let saved = crate::config::read_settings(&paths).unwrap();
assert_eq!(
saved.selected_model.provider.as_deref(),
Some(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert_eq!(saved.selected_model.model.as_deref(), Some("gpt-next"));
}
#[test]
fn new_builtin_creates_and_activates_session() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let new_output = handle_builtin(BuiltInCommand::New, None, &mut state).unwrap();
assert!(new_output.starts_with("new session: "));
assert!(state.active_session_id().is_some());
assert!(!state.current_session.as_ref().unwrap().path().exists());
}
#[test]
fn export_builtin_requires_a_persisted_session_and_rejects_arguments() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let mut state = test_state(&temp).with_config(missing_codex_config(paths));
let error = handle_builtin(BuiltInCommand::Export, Some("extra"), &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "usage: /export");
let error = handle_builtin(BuiltInCommand::Export, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/export requires an active persisted session");
state.current_session = Some(state.session_manager.create().unwrap());
let error = handle_builtin(BuiltInCommand::Export, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/export requires an active persisted session");
}
#[test]
fn export_builtin_writes_private_archive_without_provider_work() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let mut state = test_state(&temp).with_config(missing_codex_config(paths.clone()));
let session = state.session_manager.create().unwrap();
session
.append(&crate::sessions::SessionEvent::new(
"user_input",
session.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text": "hello"}),
))
.unwrap();
state.current_session = Some(session.clone());
let output = handle_builtin(BuiltInCommand::Export, None, &mut state).unwrap();
assert!(output.starts_with("session export complete: "), "{output}");
let exports = std::fs::read_dir(paths.root.join("exports"))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(exports.len(), 1);
assert!(exports[0].path().is_file());
let output = handle_builtin(BuiltInCommand::Export, None, &mut state).unwrap();
assert!(output.starts_with("session export complete: "), "{output}");
assert_eq!(
std::fs::read_dir(paths.root.join("exports"))
.unwrap()
.count(),
2
);
}
#[test]
fn noninteractive_logout_anthropic_keeps_environment_auth_ready() {
let env = crate::test_support::env::env_lock();
let _saved_key = env.save("ANTHROPIC_API_KEY");
env.set_var("ANTHROPIC_API_KEY", "environment-test-key");
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let mut auth = crate::config::Auth::default();
auth.providers.insert(
crate::providers::ANTHROPIC_PROVIDER.to_string(),
crate::config::AuthProviderRecord::ApiKey {
key: "stored-test-key".to_string(),
},
);
crate::config::write_auth(&paths, &auth).unwrap();
let mut state = ready_anthropic_state(&temp, &paths);
let message = handle_builtin(
BuiltInCommand::Logout,
Some(crate::providers::ANTHROPIC_PROVIDER),
&mut state,
)
.unwrap();
assert!(message.contains("removed local auth"), "{message}");
assert!(!message.contains("stored-test-key"), "{message}");
assert!(state.auth_state.is_ready());
assert!(matches!(
state.auth_state,
AuthState::Ready {
provider,
credential: crate::config::ProviderCredential::ApiKey { key }
} if provider == crate::providers::ANTHROPIC_PROVIDER && key == "environment-test-key"
));
let config = state.config.as_ref().unwrap();
assert!(matches!(
config.auth,
Some(crate::config::ProviderCredential::ApiKey { ref key })
if key == "environment-test-key"
));
}
#[test]
fn logout_unknown_provider_is_actionable_and_secret_free() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let config = crate::config::EffectiveConfig {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-test".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
auth: None,
paths,
};
let mut state = test_state(&temp).with_config(config);
let error = handle_builtin(BuiltInCommand::Logout, Some("bad-provider"), &mut state)
.unwrap_err()
.to_string();
assert!(error.contains("unsupported logout provider 'bad-provider'"));
assert!(!error.contains("access"));
assert!(!error.contains("refresh"));
}
#[test]
fn shell_sessions_command_reports_unsupported_or_lists_plain_text() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Sessions, None, &mut state)
.unwrap_err()
.to_string();
assert!(error.contains("/sessions is only available in Mission Control TUI"));
}
#[test]
fn shell_sessions_command_rejects_extra_arguments() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Sessions, Some("extra"), &mut state)
.unwrap_err()
.to_string();
assert!(error.contains("usage: /sessions"));
}
#[test]
fn shell_models_command_reports_tui_only() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Models, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/models is only available in Mission Control TUI");
}
#[test]
fn shell_usage_command_reports_tui_only() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Usage, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/usage is only available in Mission Control TUI");
}
#[test]
fn shell_mcp_command_reports_tui_only() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Mcp, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/mcp is only available in Mission Control TUI");
}
#[test]
fn shell_tools_command_reports_tui_only() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Tools, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/tools is only available in Mission Control TUI");
}
#[test]
fn shell_subagents_command_reports_tui_only() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Subagents, None, &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, "/subagents is only available in Mission Control TUI");
}
#[test]
fn prune_sessions_builtin_deletes_old_sessions_and_skips_active() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let old = state.session_manager.open("old-shell").unwrap();
let active = state.session_manager.open("active-shell").unwrap();
let mut old_event = SessionEvent::new(
"diagnostic",
old.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({}),
);
old_event.timestamp =
chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2020, 1, 1, 0, 0, 0).unwrap();
old.append(&old_event).unwrap();
old.release_active_lease_for_test();
let mut active_event = SessionEvent::new(
"diagnostic",
active.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({}),
);
active_event.timestamp = old_event.timestamp;
active.append(&active_event).unwrap();
state.current_session = Some(active.clone());
let output = handle_builtin(BuiltInCommand::PruneSessions, Some("0"), &mut state)
.unwrap_err()
.to_string();
assert!(output.contains("usage: /prune-sessions [days]"));
assert!(old.path().exists());
assert!(active.path().exists());
let output = handle_builtin(BuiltInCommand::PruneSessions, Some("1"), &mut state).unwrap();
assert!(output.contains("older than 1 days"));
assert!(output.contains("skipped active session"));
assert!(!old.path().exists());
assert!(active.path().exists());
}
#[test]
fn compact_builtin_requires_active_session() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
let error = handle_builtin(BuiltInCommand::Compact, None, &mut state)
.unwrap_err()
.to_string();
assert!(error.contains("/compact requires an active persisted session"));
}
#[test]
fn compact_builtin_uses_override_without_refreshing_missing_active_codex_auth() {
let _env = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let base_url = start_sse_server("shell override summary");
write_compaction_override_settings(&paths, "local-provider", "summary-model", &base_url);
let mut state = test_state(&temp).with_config(missing_codex_config(paths));
state.current_session = Some(state.session_manager.create().unwrap());
let output = handle_builtin(BuiltInCommand::Compact, None, &mut state).unwrap();
assert!(output.contains("compaction complete: session"), "{output}");
assert!(matches!(
state.auth_state,
AuthState::Missing { ref provider } if provider == crate::providers::OPENAI_CODEX_PROVIDER
));
let session = state.current_session.as_ref().unwrap();
let events = session.read_events().unwrap();
assert_eq!(
events.last().unwrap().payload["summary"],
"shell override summary"
);
assert_eq!(events.last().unwrap().payload["provider"], "local-provider");
assert_eq!(events.last().unwrap().payload["model"], "summary-model");
}
#[test]
fn compact_builtin_still_refreshes_auth_when_inheriting_active_codex_provider() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
crate::config::write_settings(&paths, &crate::config::Settings::default()).unwrap();
let mut state = test_state(&temp).with_config(missing_codex_config(paths));
state.current_session = Some(state.session_manager.create().unwrap());
let error = handle_builtin(BuiltInCommand::Compact, None, &mut state)
.unwrap_err()
.to_string();
assert!(
error.contains("missing OAuth auth for provider 'openai-codex'"),
"{error}"
);
assert!(
state
.current_session
.unwrap()
.read_events()
.unwrap()
.is_empty()
);
}
#[test]
fn shell_fast_observation_orders_by_prompt_and_rejects_stale_prompts() {
let temp = TempDir::new().unwrap();
let mut state = test_state(&temp);
assert_eq!(state.next_prompt_order(), 1);
let prompt_order = state.next_prompt_order();
assert!(state.accept_fast_observation(
"provider",
"model",
"priority",
&crate::fast::FastOutcome::Different("default".to_string()),
prompt_order,
2,
));
assert!(!state.accept_fast_observation(
"provider",
"model",
"stale",
&crate::fast::FastOutcome::Unconfirmed,
prompt_order - 1,
u64::MAX,
));
assert!(!state.accept_fast_observation(
"provider",
"model",
"same prompt",
&crate::fast::FastOutcome::Confirmed,
prompt_order,
u64::MAX,
));
let next_prompt_order = state.next_prompt_order();
assert!(state.accept_fast_observation(
"provider",
"model",
"priority",
&crate::fast::FastOutcome::Confirmed,
next_prompt_order,
1,
));
let observation = state.fast_observation("provider", "model").unwrap();
assert_eq!(observation.observation.requested_service_tier, "priority");
assert_eq!(
observation.observation.outcome,
crate::fast::FastOutcome::Confirmed
);
assert_eq!(observation.prompt_order, next_prompt_order);
assert_eq!(observation.observation.request_sequence, 1);
}
#[test]
fn fast_status_uses_selected_model_and_clears_observations_on_reset() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let mut first = crate::model_catalog::ModelCatalogEntry::new_codex("gpt-5.4");
first.service_tiers = Some(vec![crate::model_catalog::ModelCatalogServiceTier {
id: "priority".to_string(),
name: "fast".to_string(),
description: None,
}]);
let mut second = crate::model_catalog::ModelCatalogEntry::new_codex("gpt-other");
second.service_tiers = first.service_tiers.clone();
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[first, second],
)
.unwrap();
let mut config = missing_codex_config(paths.clone());
config.model = Some("gpt-5.4".to_string());
let mut state = test_state(&temp).with_config(config);
state.model = "gpt-5.4".to_string();
handle_builtin(BuiltInCommand::Fast, Some("on"), &mut state).unwrap();
assert!(state.accept_fast_observation(
crate::providers::OPENAI_CODEX_PROVIDER,
"gpt-5.4",
"priority",
&crate::fast::FastOutcome::Confirmed,
1,
0,
));
assert!(
handle_builtin(BuiltInCommand::Fast, Some("status"), &mut state)
.unwrap()
.contains("Fast was confirmed for openai-codex/gpt-5.4")
);
state.model = "gpt-other".to_string();
assert!(
handle_builtin(BuiltInCommand::Fast, Some("status"), &mut state)
.unwrap()
.contains("will request Fast processing")
);
assert!(state.accept_fast_observation(
crate::providers::OPENAI_CODEX_PROVIDER,
"gpt-other",
"priority",
&crate::fast::FastOutcome::Different("standard".to_string()),
2,
0,
));
assert!(
handle_builtin(BuiltInCommand::Fast, Some("status"), &mut state)
.unwrap()
.contains("reported tier \"standard\"")
);
assert!(
state
.fast_observation(crate::providers::OPENAI_CODEX_PROVIDER, "gpt-other")
.is_some()
);
handle_builtin(BuiltInCommand::Fast, Some("on"), &mut state).unwrap();
assert!(
state
.fast_observation(crate::providers::OPENAI_CODEX_PROVIDER, "gpt-other")
.is_some()
);
assert!(state.accept_fast_observation(
crate::providers::OPENAI_CODEX_PROVIDER,
"gpt-other",
"priority",
&crate::fast::FastOutcome::Confirmed,
3,
0,
));
handle_builtin(BuiltInCommand::Fast, Some("off"), &mut state).unwrap();
assert!(
state
.fast_observation(crate::providers::OPENAI_CODEX_PROVIDER, "gpt-other")
.is_none()
);
state.accept_fast_observation(
crate::providers::OPENAI_CODEX_PROVIDER,
"gpt-other",
"priority",
&crate::fast::FastOutcome::Confirmed,
4,
0,
);
handle_builtin(BuiltInCommand::New, None, &mut state).unwrap();
assert!(state.fast_observations.is_empty());
}
#[test]
fn failed_fast_persistence_preserves_observation() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
std::fs::create_dir_all(&paths.root).unwrap();
std::fs::create_dir(&paths.settings_file).unwrap();
let mut config = missing_codex_config(paths.clone());
config.model = Some("gpt-5.4".to_string());
let mut state = test_state(&temp).with_config(config);
state.model = "gpt-5.4".to_string();
assert!(state.accept_fast_observation(
crate::providers::OPENAI_CODEX_PROVIDER,
"gpt-5.4",
"priority",
&crate::fast::FastOutcome::Confirmed,
1,
0,
));
let error = handle_builtin(BuiltInCommand::Fast, Some("on"), &mut state).unwrap_err();
assert!(!error.to_string().is_empty());
assert!(
state
.fast_observation(crate::providers::OPENAI_CODEX_PROVIDER, "gpt-5.4")
.is_some()
);
let error = handle_builtin(BuiltInCommand::Fast, Some("off"), &mut state).unwrap_err();
assert!(!error.to_string().is_empty());
assert!(
state
.fast_observation(crate::providers::OPENAI_CODEX_PROVIDER, "gpt-5.4")
.is_some()
);
}
#[test]
fn fast_mode_shell_builtin_supports_toggle_on_off_status_and_invalid_usage() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let mut entry = crate::model_catalog::ModelCatalogEntry::new_codex("gpt-5.4");
entry.service_tiers = Some(vec![crate::model_catalog::ModelCatalogServiceTier {
id: "priority".to_string(),
name: "fast".to_string(),
description: None,
}]);
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[entry],
)
.unwrap();
let mut config = missing_codex_config(paths.clone());
config.model = Some("gpt-5.4".to_string());
let mut state = test_state(&temp).with_config(config);
state.model = "gpt-5.4".to_string();
assert_eq!(
handle_builtin(BuiltInCommand::Fast, Some("status"), &mut state).unwrap(),
"Fast mode off. Magi-code will not request Fast processing."
);
assert_eq!(
handle_builtin(BuiltInCommand::Fast, Some("on"), &mut state).unwrap(),
"Fast mode on. openai-codex/gpt-5.4 will request Fast processing.\nEligible requests may have higher provider pricing."
);
assert_eq!(
handle_builtin(BuiltInCommand::Fast, Some("status"), &mut state).unwrap(),
"Fast mode on. openai-codex/gpt-5.4 will request Fast processing.\nEligible requests may have higher provider pricing."
);
assert_eq!(
handle_builtin(BuiltInCommand::Fast, None, &mut state).unwrap(),
"Fast mode off. Magi-code will not request Fast processing."
);
assert_eq!(
handle_builtin(BuiltInCommand::Fast, Some("off"), &mut state).unwrap(),
"Fast mode off. Magi-code will not request Fast processing."
);
let error = handle_builtin(BuiltInCommand::Fast, Some("later"), &mut state)
.unwrap_err()
.to_string();
assert_eq!(error, crate::commands::FAST_MODE_USAGE);
assert!(!crate::config::fast_mode_enabled(&paths).unwrap());
}
#[test]
fn fast_mode_shell_status_uses_standard_for_non_codex_provider() {
let temp = TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-5.4",
)],
)
.unwrap();
let mut config = missing_codex_config(paths);
config.provider = Some(crate::providers::ANTHROPIC_PROVIDER.to_string());
config.model = Some("gpt-5.4".to_string());
let mut state = test_state(&temp).with_config(config);
state.model = "gpt-5.4".to_string();
assert_eq!(
handle_builtin(BuiltInCommand::Fast, Some("on"), &mut state).unwrap(),
"Fast remains on, but anthropic/gpt-5.4 has no configured Fast support."
);
}
}