//! Cross-surface equivalence tests for the agent-icon helpers. //! //! The invariant under test: for every canonical logical run state, every surface produces //! the same [`IconWithStatusVariant`]. Surfaces today are: //! - Terminal view (vertical tabs + pane header) via //! [`super::agent_icon_variant_from_terminal_inputs`] //! - Run cards (conversation list, agent management view) via //! [`super::agent_icon_variant_for_run`] //! - Notification mailbox — exercised in `notifications/item_tests.rs` //! //! Adding a new canonical state is a one-enum-variant + one `expected` arm + one `*_inputs` //! arm change; the table test below enforces every surface agrees. use chrono::Utc; use warp_cli::agent::Harness; use super::{ agent_conversation_entry_icon_variant, agent_icon_variant_for_run, agent_icon_variant_from_terminal_inputs, CLISessionInputs, TerminalIconInputs, }; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::agent_conversations_model::entry::{ AgentConversationBackingData, AgentConversationCapabilities, AgentConversationDisplayData, AgentConversationIdentity, AgentConversationPrincipal, }; use crate::ai::agent_conversations_model::{ AgentConversationEntry, AgentConversationEntryId, AgentConversationProvenance, AgentRunDisplayStatus, }; use crate::terminal::CLIAgent; use crate::ui_components::icon_with_status::IconWithStatusVariant; /// Projection of the fields we care about for cross-surface equivalence. /// [`IconWithStatusVariant`] itself can't derive `PartialEq` because `NeutralElement` /// carries a `Box`, so we extract the agent-variant fields here. #[derive(Debug, Clone, PartialEq, Eq)] struct AgentIconFields { is_cli: bool, cli_agent: Option, status: Option, is_ambient: bool, } impl AgentIconFields { fn from_variant(variant: &IconWithStatusVariant) -> Option { match variant { IconWithStatusVariant::OzAgent { status, is_ambient } => Some(Self { is_cli: false, cli_agent: None, status: status.clone(), is_ambient: *is_ambient, }), IconWithStatusVariant::CLIAgent { agent, status, is_ambient, } => Some(Self { is_cli: true, cli_agent: Some(*agent), status: status.clone(), is_ambient: *is_ambient, }), IconWithStatusVariant::Neutral { .. } | IconWithStatusVariant::NeutralElement { .. } | IconWithStatusVariant::CustomAvatar { .. } => None, } } } /// Canonical logical run states. Each represents a conceptually distinct run whose icon must /// be rendered identically across every surface that can display it. #[derive(Debug, Clone, Copy)] enum CanonicalRunState { /// Plain terminal, no conversation, no agent activity. PlainTerminal, /// Local Warp-native (Oz) conversation, in-progress. LocalOzInProgress, /// Cloud-mode Oz run, in-progress. CloudOzInProgress, /// Cloud Claude harness selected, pre-dispatch (no session, no status yet). /// This is the state the pre-setup icon bug regressed on — the tab must already render /// the Claude brand circle even though no CLI session exists yet. CloudClaudePreDispatch, /// Cloud Claude harness selected, dispatch in flight (status = InProgress, no session). CloudClaudeInProgress, /// Viewing a finished cloud Codex transcript whose VM has shut down. No live ambient /// model exists, so the harness comes from the conversation's server metadata; the icon /// must still render as cloud Codex. ViewingCloudCodexTranscript, /// Local Claude CLI session with a plugin listener (rich status), in-progress. LocalClaudePluginInProgress, /// Local Claude CLI session with a plugin listener (rich status), blocked. LocalClaudePluginBlocked, /// Local Claude CLI session detected via command matching only (no listener, no rich status). LocalClaudeCommandDetected, } impl CanonicalRunState { fn all() -> &'static [Self] { use CanonicalRunState::*; &[ PlainTerminal, LocalOzInProgress, CloudOzInProgress, CloudClaudePreDispatch, CloudClaudeInProgress, ViewingCloudCodexTranscript, LocalClaudePluginInProgress, LocalClaudePluginBlocked, LocalClaudeCommandDetected, ] } /// The canonical [`AgentIconFields`] for this state. `None` means no agent icon renders. /// Editing an arm here is the deliberate way to evolve the cross-surface contract. fn expected(&self) -> Option { use CanonicalRunState::*; match self { PlainTerminal => None, LocalOzInProgress => Some(AgentIconFields { is_cli: false, cli_agent: None, status: Some(ConversationStatus::InProgress), is_ambient: false, }), CloudOzInProgress => Some(AgentIconFields { is_cli: false, cli_agent: None, status: Some(ConversationStatus::InProgress), is_ambient: true, }), CloudClaudePreDispatch => Some(AgentIconFields { is_cli: true, cli_agent: Some(CLIAgent::Claude), status: None, is_ambient: true, }), CloudClaudeInProgress => Some(AgentIconFields { is_cli: true, cli_agent: Some(CLIAgent::Claude), status: Some(ConversationStatus::InProgress), is_ambient: true, }), ViewingCloudCodexTranscript => Some(AgentIconFields { is_cli: true, cli_agent: Some(CLIAgent::Codex), status: Some(ConversationStatus::Success), is_ambient: true, }), LocalClaudePluginInProgress => Some(AgentIconFields { is_cli: true, cli_agent: Some(CLIAgent::Claude), status: Some(ConversationStatus::InProgress), is_ambient: false, }), LocalClaudePluginBlocked => Some(AgentIconFields { is_cli: true, cli_agent: Some(CLIAgent::Claude), status: Some(ConversationStatus::Blocked { blocked_action: String::new(), }), is_ambient: false, }), LocalClaudeCommandDetected => Some(AgentIconFields { is_cli: true, cli_agent: Some(CLIAgent::Claude), status: None, is_ambient: false, }), } } /// Terminal-view inputs for this state. Every state has a terminal representation. fn terminal_inputs(&self) -> TerminalIconInputs { use CanonicalRunState::*; match self { PlainTerminal => TerminalIconInputs { is_ambient: false, cli_session: None, selected_third_party_cli_agent: None, selected_conversation_status: None, has_selected_conversation: false, }, LocalOzInProgress => TerminalIconInputs { is_ambient: false, cli_session: None, selected_third_party_cli_agent: None, selected_conversation_status: Some(ConversationStatus::InProgress), has_selected_conversation: true, }, CloudOzInProgress => TerminalIconInputs { is_ambient: true, cli_session: None, selected_third_party_cli_agent: None, selected_conversation_status: Some(ConversationStatus::InProgress), has_selected_conversation: false, }, CloudClaudePreDispatch => TerminalIconInputs { is_ambient: true, cli_session: None, selected_third_party_cli_agent: Some(CLIAgent::Claude), selected_conversation_status: None, has_selected_conversation: false, }, CloudClaudeInProgress => TerminalIconInputs { is_ambient: true, cli_session: None, selected_third_party_cli_agent: Some(CLIAgent::Claude), selected_conversation_status: Some(ConversationStatus::InProgress), has_selected_conversation: false, }, ViewingCloudCodexTranscript => TerminalIconInputs { // VM has shut down: the caller resolves these fields from the conversation's // server metadata, so the waterfall sees the same shape as a live run. is_ambient: true, cli_session: None, selected_third_party_cli_agent: Some(CLIAgent::Codex), selected_conversation_status: Some(ConversationStatus::Success), has_selected_conversation: true, }, LocalClaudePluginInProgress => TerminalIconInputs { is_ambient: false, cli_session: Some(CLISessionInputs { agent: CLIAgent::Claude, has_listener: true, status: ConversationStatus::InProgress, supports_rich_status: true, }), selected_third_party_cli_agent: None, selected_conversation_status: None, has_selected_conversation: false, }, LocalClaudePluginBlocked => TerminalIconInputs { is_ambient: false, cli_session: Some(CLISessionInputs { agent: CLIAgent::Claude, has_listener: true, status: ConversationStatus::Blocked { blocked_action: String::new(), }, supports_rich_status: true, }), selected_third_party_cli_agent: None, selected_conversation_status: None, has_selected_conversation: false, }, LocalClaudeCommandDetected => TerminalIconInputs { is_ambient: false, cli_session: Some(CLISessionInputs { agent: CLIAgent::Claude, has_listener: false, status: ConversationStatus::InProgress, supports_rich_status: false, }), selected_third_party_cli_agent: None, selected_conversation_status: None, has_selected_conversation: false, }, } } /// Run-card inputs for this state, if it can surface as a run card. /// Cards only exist for cloud/ambient runs; local states return `None`. fn run_inputs(&self) -> Option<(Harness, ConversationStatus, bool)> { use CanonicalRunState::*; match self { CloudOzInProgress => Some((Harness::Oz, ConversationStatus::InProgress, true)), CloudClaudePreDispatch | CloudClaudeInProgress => { Some((Harness::Claude, ConversationStatus::InProgress, true)) } ViewingCloudCodexTranscript => { Some((Harness::Codex, ConversationStatus::Success, true)) } PlainTerminal | LocalOzInProgress | LocalClaudePluginInProgress | LocalClaudePluginBlocked | LocalClaudeCommandDetected => None, } } } /// The consistency enforcer: for every canonical state, the terminal-side and task-side /// helpers must produce the same [`AgentIconFields`] projection. #[test] fn every_canonical_state_produces_consistent_icon_across_surfaces() { for state in CanonicalRunState::all() { let expected = state.expected(); let terminal_actual = agent_icon_variant_from_terminal_inputs(&state.terminal_inputs()) .as_ref() .and_then(AgentIconFields::from_variant); assert_eq!( terminal_actual, expected, "terminal surface disagreed for {state:?}" ); if let Some((harness, status, is_ambient)) = state.run_inputs() { let run_variant = agent_icon_variant_for_run(harness, status.clone(), is_ambient); let run_actual = AgentIconFields::from_variant(&run_variant); // Run cards always populate status (they derive it from `ConversationOrTask::status`). let expected_for_run = expected.clone().map(|mut fields| { fields.status = Some(status); fields }); assert_eq!( run_actual, expected_for_run, "run-card surface disagreed for {state:?}" ); } } } /// Structural invariant: the `is_ambient` flag on the rendered variant must match the /// `is_ambient` flag on the terminal inputs. Catches accidental drift in the waterfall. #[test] fn terminal_is_ambient_matches_inputs_for_every_state() { for state in CanonicalRunState::all() { let inputs = state.terminal_inputs(); let Some(variant) = agent_icon_variant_from_terminal_inputs(&inputs) else { continue; }; let fields = AgentIconFields::from_variant(&variant) .expect("terminal helper must only return agent variants"); assert_eq!( fields.is_ambient, inputs.is_ambient, "is_ambient drifted for {state:?}" ); } } #[test] fn cli_agent_from_harness_maps_known_harnesses() { assert_eq!(CLIAgent::from_harness(Harness::Oz), None); assert_eq!( CLIAgent::from_harness(Harness::Claude), Some(CLIAgent::Claude) ); assert_eq!( CLIAgent::from_harness(Harness::Gemini), Some(CLIAgent::Gemini) ); assert_eq!( CLIAgent::from_harness(Harness::OpenCode), Some(CLIAgent::OpenCode) ); } #[test] fn run_card_with_oz_or_unknown_harness_renders_as_oz() { // Oz harness explicitly: local Oz is the spec-defined fallback. let variant = agent_icon_variant_for_run(Harness::Oz, ConversationStatus::Success, true); let fields = AgentIconFields::from_variant(&variant).unwrap(); assert!(!fields.is_cli); assert!(fields.is_ambient); // Unknown harness (e.g. server surfaced a future variant): also falls back to Oz so we // don't render an unbranded gray circle. let variant = agent_icon_variant_for_run(Harness::Unknown, ConversationStatus::Success, true); let fields = AgentIconFields::from_variant(&variant).unwrap(); assert!(!fields.is_cli); assert!(fields.is_ambient); } /// A local Claude session and an ambient Claude run must render with the same CLI agent /// brand but differ only by `is_ambient`. This answers the product-spec ambiguity about /// whether those should look different — they should. #[test] fn local_claude_vs_cloud_claude_differ_only_by_is_ambient() { let local = agent_icon_variant_from_terminal_inputs( &CanonicalRunState::LocalClaudePluginInProgress.terminal_inputs(), ) .and_then(|v| AgentIconFields::from_variant(&v)) .unwrap(); let cloud = agent_icon_variant_from_terminal_inputs( &CanonicalRunState::CloudClaudeInProgress.terminal_inputs(), ) .and_then(|v| AgentIconFields::from_variant(&v)) .unwrap(); assert_eq!(local.cli_agent, cloud.cli_agent); assert_eq!(local.cli_agent, Some(CLIAgent::Claude)); assert!(!local.is_ambient); assert!(cloud.is_ambient); } #[test] fn non_ambient_entry_uses_display_harness() { let conversation_id = AIConversationId::new(); let entry = AgentConversationEntry { id: AgentConversationEntryId::Conversation(conversation_id), identity: AgentConversationIdentity { local_conversation_id: Some(conversation_id), ambient_agent_task_id: None, server_conversation_token: None, session_id: None, }, provenance: AgentConversationProvenance::CloudSyncedConversation, display: AgentConversationDisplayData { title: "Codex conversation".to_string(), initial_query: None, created_at: Utc::now(), last_updated: Utc::now(), status: AgentRunDisplayStatus::ConversationSucceeded, creator: AgentConversationPrincipal::default(), executor: None, request_usage: None, run_time: None, session_status: None, source: None, working_directory: None, environment_id: None, harness: Some(Harness::Codex), artifacts: Vec::new(), }, backing: AgentConversationBackingData { has_loaded_conversation: true, has_local_persisted_data: true, has_cloud_data: true, has_ambient_run: false, }, capabilities: AgentConversationCapabilities { can_open: true, can_copy_link: false, can_share: false, can_delete: false, can_fork_locally: false, can_cancel: false, }, }; let variant = agent_conversation_entry_icon_variant(&entry); assert_eq!( AgentIconFields::from_variant(&variant).unwrap(), AgentIconFields { is_cli: true, cli_agent: Some(CLIAgent::Codex), status: Some(ConversationStatus::Success), is_ambient: false, } ); }