use std::sync::Arc; use parking_lot::FairMutex; use session_sharing_protocol::common::{ OrderedTerminalEvent, OrderedTerminalEventType, Scrollback, ScrollbackBlock, WindowSize, }; use warpui::platform::WindowStyle; use warpui::units::Lines; use warpui::{App, SingletonEntity, ViewHandle}; use crate::ai::blocklist::agent_view::AgentViewState; use crate::ai::blocklist::{BlocklistAIHistoryModel, QueuedQueryModel}; use crate::terminal::event_listener::ChannelEventListener; use crate::terminal::model::block::{BlockId, SerializedBlock}; use crate::terminal::shared_session::shared_handlers::RemoteUpdateGuard; use crate::terminal::shared_session::tests::terminal_model_for_viewer; use crate::terminal::shared_session::viewer::event_loop::{ EventLoop, SharedSessionInitialLoadMode, }; use crate::terminal::shared_session::SharedSessionStatus; use crate::terminal::TerminalView; use crate::test_util::add_window_with_terminal; use crate::test_util::terminal::initialize_app_for_terminal_view; fn ordered_terminal_event_from_bytes( bytes: impl Into>, event_no: usize, ) -> OrderedTerminalEvent { let compressed = lz4_flex::block::compress_prepend_size(&bytes.into()); OrderedTerminalEvent { event_no, event_type: OrderedTerminalEventType::PtyBytesRead { bytes: compressed }, } } fn terminal_view(app: &mut App) -> ViewHandle { initialize_app_for_terminal_view(app); add_window_with_terminal(app, None) } /// Cloud-mode terminal view counterpart to [`terminal_view`]. Sets up the /// singletons and constructs a `TerminalView` with `is_cloud_mode = true` so /// `ambient_agent_view_model()` is `Some(..)`. fn cloud_mode_terminal_view(app: &mut App) -> ViewHandle { initialize_app_for_terminal_view(app); let tips_model = app.add_model(|_| Default::default()); let (_, terminal) = app.add_window(WindowStyle::NotStealFocus, |ctx| { TerminalView::new_for_test_with_cloud_mode(tips_model, None, true, ctx) }); terminal } fn completed_block(command: &str, output: &str) -> SerializedBlock { let mut block = SerializedBlock::new_for_test(command.as_bytes().into(), output.as_bytes().into()); block.id = BlockId::new(); block } fn active_block() -> SerializedBlock { let mut block = SerializedBlock::new_active_block_for_test(); block.id = BlockId::new(); block } fn scrollback_block(block: &SerializedBlock) -> ScrollbackBlock { ScrollbackBlock { raw: serde_json::to_vec(block).unwrap(), } } fn empty_scrollback() -> Scrollback { Scrollback { blocks: vec![], is_alt_screen_active: false, } } #[test] fn test_terminal_model_is_correct() { App::test((), |mut app| async move { let channel_event_proxy = ChannelEventListener::new_for_test(); let model = Arc::new(FairMutex::new(terminal_model_for_viewer( channel_event_proxy.clone(), ))); let terminal_view = terminal_view(&mut app); let event_loop = app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, Scrollback { blocks: vec![], is_alt_screen_active: false, }, None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); // Before we receive any events, the block list only contains hidden blocks. assert!(model .lock() .block_list() .blocks() .iter() .all(|block| block.height(&AgentViewState::Inactive) == Lines::zero())); // Load shared session scrollback. let scrollback = &[ SerializedBlock::new_for_test("block1".into(), "block1".into()), SerializedBlock::new_active_block_for_test(), ]; { let mut model = model.lock(); model.load_shared_session_scrollback(scrollback); // A hidden block, a completed scrollback block, then the active block. assert_eq!(model.block_list().blocks().len(), 3); assert_eq!( model.block_list().blocks()[0].height(&AgentViewState::Inactive), Lines::zero() ); assert_ne!( model.block_list().blocks()[1].height(&AgentViewState::Inactive), Lines::zero() ); assert_eq!( model.block_list().blocks()[2].height(&AgentViewState::Inactive), Lines::zero() ); } // Write some PTY events after starting active block. model.lock().start_command_execution(); event_loop.update(&mut app, |event_loop, ctx| { event_loop .process_ordered_terminal_event(ordered_terminal_event_from_bytes("a", 0), ctx); }); let model = model.lock(); // After writing bytes, active block should no longer have height 0. assert_eq!(model.block_list().blocks().len(), 3); assert_eq!( model.block_list().blocks()[0].height(&AgentViewState::Inactive), Lines::zero() ); assert_ne!( model.block_list().blocks()[1].height(&AgentViewState::Inactive), Lines::zero() ); assert_ne!( model.block_list().blocks()[2].height(&AgentViewState::Inactive), Lines::zero() ); }) } #[test] fn test_append_followup_scrollback_skips_duplicates() { App::test((), |mut app| async move { let channel_event_proxy = ChannelEventListener::new_for_test(); let model = Arc::new(FairMutex::new(terminal_model_for_viewer( channel_event_proxy.clone(), ))); let terminal_view = terminal_view(&mut app); let initial_completed = completed_block("initial-command", "initial-output"); let initial_active = active_block(); app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, Scrollback { blocks: vec![ scrollback_block(&initial_completed), scrollback_block(&initial_active), ], is_alt_screen_active: false, }, None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); assert_eq!(model.lock().block_list().blocks().len(), 3); let followup_completed = completed_block("followup-command", "followup-output"); let followup_active = active_block(); app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, Scrollback { blocks: vec![ scrollback_block(&initial_completed), scrollback_block(&followup_completed), scrollback_block(&followup_active), ], is_alt_screen_active: false, }, None, SharedSessionInitialLoadMode::AppendFollowupScrollback, RemoteUpdateGuard::new(), ctx, ) }); { let model = model.lock(); let commands = model .block_list() .blocks() .iter() .map(|block| block.command_to_string()) .collect::>(); assert_eq!(model.block_list().blocks().len(), 5); assert_eq!( commands .iter() .filter(|command| command.as_str() == "initial-command") .count(), 1 ); assert_eq!( commands .iter() .filter(|command| command.as_str() == "followup-command") .count(), 1 ); } let second_followup_completed = completed_block("second-followup-command", "second-followup-output"); let second_followup_active = active_block(); app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, Scrollback { blocks: vec![ scrollback_block(&initial_completed), scrollback_block(&followup_completed), scrollback_block(&second_followup_completed), scrollback_block(&second_followup_active), ], is_alt_screen_active: false, }, None, SharedSessionInitialLoadMode::AppendFollowupScrollback, RemoteUpdateGuard::new(), ctx, ) }); let model = model.lock(); let commands = model .block_list() .blocks() .iter() .map(|block| block.command_to_string()) .collect::>(); assert_eq!(model.block_list().blocks().len(), 7); for command in [ "initial-command", "followup-command", "second-followup-command", ] { assert_eq!( commands .iter() .filter(|existing_command| existing_command.as_str() == command) .count(), 1 ); } }) } #[test] fn test_append_followup_scrollback_with_completed_last_block_creates_active_block() { App::test((), |mut app| async move { let channel_event_proxy = ChannelEventListener::new_for_test(); let model = Arc::new(FairMutex::new(terminal_model_for_viewer( channel_event_proxy.clone(), ))); let terminal_view = terminal_view(&mut app); let initial_completed = completed_block("initial-command", "initial-output"); let initial_active = active_block(); app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, Scrollback { blocks: vec![ scrollback_block(&initial_completed), scrollback_block(&initial_active), ], is_alt_screen_active: false, }, None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); let followup_completed = completed_block("followup-command", "followup-output"); app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, Scrollback { blocks: vec![ scrollback_block(&initial_completed), scrollback_block(&followup_completed), ], is_alt_screen_active: false, }, None, SharedSessionInitialLoadMode::AppendFollowupScrollback, RemoteUpdateGuard::new(), ctx, ) }); let model = model.lock(); let commands = model .block_list() .blocks() .iter() .map(|block| block.command_to_string()) .collect::>(); assert_eq!(model.block_list().blocks().len(), 5); assert_eq!( commands .iter() .filter(|command| command.as_str() == "initial-command") .count(), 1 ); assert_eq!( commands .iter() .filter(|command| command.as_str() == "followup-command") .count(), 1 ); assert_eq!(model.block_list().active_block_index(), 4.into()); assert_eq!( model .block_list() .active_block() .height(&AgentViewState::Inactive), Lines::zero() ); assert!(!model.block_list().active_block().started()); }) } #[test] fn test_append_followup_replay_marks_existing_conversations_suppressible() { App::test((), |mut app| async move { let channel_event_proxy = ChannelEventListener::new_for_test(); let model = Arc::new(FairMutex::new(terminal_model_for_viewer( channel_event_proxy.clone(), ))); let terminal_view = terminal_view(&mut app); let event_loop = app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, empty_scrollback(), None, SharedSessionInitialLoadMode::AppendFollowupScrollback, RemoteUpdateGuard::new(), ctx, ) }); event_loop.update(&mut app, |event_loop, ctx| { event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no: 0, event_type: OrderedTerminalEventType::AgentConversationReplayStarted, }, ctx, ); }); { let model = model.lock(); assert!(model.is_receiving_agent_conversation_replay()); } event_loop.update(&mut app, |event_loop, ctx| { event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no: 1, event_type: OrderedTerminalEventType::AgentConversationReplayEnded, }, ctx, ); }); let model = model.lock(); assert!(!model.is_receiving_agent_conversation_replay()); }) } #[test] fn test_fresh_session_replay_does_not_suppress_existing_conversations() { App::test((), |mut app| async move { let channel_event_proxy = ChannelEventListener::new_for_test(); let model = Arc::new(FairMutex::new(terminal_model_for_viewer( channel_event_proxy.clone(), ))); let terminal_view = terminal_view(&mut app); let event_loop = app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, empty_scrollback(), None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); event_loop.update(&mut app, |event_loop, ctx| { event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no: 0, event_type: OrderedTerminalEventType::AgentConversationReplayStarted, }, ctx, ); }); let model = model.lock(); assert!(model.is_receiving_agent_conversation_replay()); }) } #[test] fn test_out_of_order_buffering() { App::test((), |mut app| async move { let channel_event_proxy = ChannelEventListener::new_for_test(); let model = Arc::new(FairMutex::new(terminal_model_for_viewer( channel_event_proxy.clone(), ))); let terminal_view = terminal_view(&mut app); let active_block: SerializedBlock = model.lock().block_list().active_block().into(); let event_loop = app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, Scrollback { blocks: vec![ScrollbackBlock { raw: serde_json::to_vec(&active_block).unwrap(), }], is_alt_screen_active: false, }, None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); // Simulate the real event flow: CommandExecutionStarted (event_no 0) arrives first, // then PTY bytes (event_no 1-3) potentially in out-of-order sequence. event_loop.update(&mut app, |event_loop, ctx| { // First: sharer sends CommandExecutionStarted when user executes a command event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no: 0, event_type: OrderedTerminalEventType::CommandExecutionStarted { participant_id: Default::default(), ai_metadata: None, }, }, ctx, ); // Then: PTY bytes arrive (potentially out of order) event_loop .process_ordered_terminal_event(ordered_terminal_event_from_bytes("c", 3), ctx); event_loop .process_ordered_terminal_event(ordered_terminal_event_from_bytes("b", 2), ctx); event_loop .process_ordered_terminal_event(ordered_terminal_event_from_bytes("a", 1), ctx); }); // Ensure the events were applied in the right order. let command_grid = model .lock() .block_list() .active_block() .command_to_string() .trim() .to_string(); assert_eq!(command_grid, "abc"); }) } #[test] fn command_execution_finished_defers_queued_command_advance_until_block_completion() { // `CommandExecutionFinished` can arrive before synced block completion reaches input cleanup. // Keep the in-flight flag armed until block completion advances the queue. App::test((), |mut app| async move { let channel_event_proxy = ChannelEventListener::new_for_test(); let model = Arc::new(FairMutex::new(terminal_model_for_viewer( channel_event_proxy.clone(), ))); let terminal_view = terminal_view(&mut app); let terminal_view_id = terminal_view.read(&app, |view, _| view.id()); // Start a conversation and arm an in-flight queued command for it. let command_conversation_id = BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| { let id = history.start_new_conversation(terminal_view_id, false, false, false, ctx); history.set_active_conversation_id(id, terminal_view_id, ctx); id }); QueuedQueryModel::handle(&app).update(&mut app, |model, _| { model.arm_command_in_flight(command_conversation_id); }); // Switch the pane to another conversation before the command finishes. Block completion // must still clear the queue state for the conversation that dispatched the command. let active_conversation_id = BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| { let id = history.start_new_conversation(terminal_view_id, false, false, false, ctx); history.set_active_conversation_id(id, terminal_view_id, ctx); id }); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(model.has_command_in_flight(command_conversation_id)); assert!(!model.has_command_in_flight(active_conversation_id)); }); let event_loop = app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, empty_scrollback(), None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); for event_no in 0..2 { event_loop.update(&mut app, |event_loop, ctx| { event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no, event_type: OrderedTerminalEventType::CommandExecutionFinished { next_block_id: Default::default(), }, }, ctx, ); }); } QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(model.has_command_in_flight(command_conversation_id)); assert!(!model.has_command_in_flight(active_conversation_id)); }); terminal_view.update(&mut app, |view, ctx| { view.on_queued_command_finished(ctx); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(!model.has_command_in_flight(command_conversation_id)); assert!(!model.has_command_in_flight(active_conversation_id)); }); }) } #[test] fn command_execution_started_preserves_draft_for_queued_command() { App::test((), |mut app| async move { let channel_event_proxy = ChannelEventListener::new_for_test(); let model = Arc::new(FairMutex::new(terminal_model_for_viewer( channel_event_proxy.clone(), ))); let terminal_view = terminal_view(&mut app); let terminal_view_id = terminal_view.read(&app, |view, _| view.id()); let conversation_id = BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| { let id = history.start_new_conversation(terminal_view_id, false, false, false, ctx); history.set_active_conversation_id(id, terminal_view_id, ctx); id }); QueuedQueryModel::handle(&app).update(&mut app, |model, _| { model.arm_command_in_flight(conversation_id); }); terminal_view.update(&mut app, |view, ctx| { view.input().update(ctx, |input, ctx| { input.replace_buffer_content("draft in progress", ctx); }); }); let event_loop = app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, empty_scrollback(), None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); event_loop.update(&mut app, |event_loop, ctx| { event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no: 0, event_type: OrderedTerminalEventType::CommandExecutionStarted { participant_id: Default::default(), ai_metadata: None, }, }, ctx, ); }); terminal_view.read(&app, |view, ctx| { assert_eq!( view.input().as_ref(ctx).buffer_text(ctx), "draft in progress" ); }); }) } #[test] fn test_pty_bytes_buffered_before_command_execution_started() { App::test((), |mut app| async move { let channel_event_proxy = ChannelEventListener::new_for_test(); let model = Arc::new(FairMutex::new(terminal_model_for_viewer( channel_event_proxy.clone(), ))); let terminal_view = terminal_view(&mut app); let active_block: SerializedBlock = model.lock().block_list().active_block().into(); let event_loop = app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, Scrollback { blocks: vec![ScrollbackBlock { raw: serde_json::to_vec(&active_block).unwrap(), }], is_alt_screen_active: false, }, None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); // Edge case: PTY bytes arrive BEFORE CommandExecutionStarted. // The event loop should buffer the PTY bytes until CommandExecutionStarted arrives, // then process them in order. event_loop.update(&mut app, |event_loop, ctx| { // PTY bytes arrive first (event_no 0-2, out of order) event_loop .process_ordered_terminal_event(ordered_terminal_event_from_bytes("c", 2), ctx); event_loop .process_ordered_terminal_event(ordered_terminal_event_from_bytes("a", 0), ctx); // CommandExecutionStarted arrives later (event_no 3) event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no: 3, event_type: OrderedTerminalEventType::CommandExecutionStarted { participant_id: Default::default(), ai_metadata: None, }, }, ctx, ); // More PTY bytes arrive after CommandExecutionStarted (event_no 4) event_loop .process_ordered_terminal_event(ordered_terminal_event_from_bytes("b", 1), ctx); }); // Ensure the buffering worked correctly and bytes were applied in the right order. // Note: The first two bytes (0, 2) arrive before CommandExecutionStarted, // but since the block isn't started until event 3, they should be buffered. // After CommandExecutionStarted, the block is started and we process in order: 0, 1, 2. let command_grid = model .lock() .block_list() .active_block() .command_to_string() .trim() .to_string(); assert_eq!(command_grid, "abc"); }) } #[test] fn test_cloud_mode_setup_phase_ended_clears_setup_state() { App::test((), |mut app| async move { let terminal_view = cloud_mode_terminal_view(&mut app); // Share the view's own `TerminalModel` with the event loop so the // event loop's mutations are observable through both the model and // the view's `ambient_agent_view_model()`. let model = terminal_view.read(&app, |view, _| view.model.clone()); // Mark as a viewer so the event loop's scrollback load invariant holds. model .lock() .set_shared_session_status(SharedSessionStatus::ViewPending); let channel_event_proxy = ChannelEventListener::new_for_test(); let event_loop = app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, empty_scrollback(), None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); // Seed setup state the way the production viewer would: the // BlockList flag is true while setup commands are running, and the // default `SetupCommandState` already has the initial group marked // as running and expanded. model .lock() .block_list_mut() .set_is_executing_oz_environment_startup_commands(true); let initial_group_id = terminal_view.read(&app, |view, ctx| { view.ambient_agent_view_model() .expect("cloud mode view has ambient agent view model") .as_ref(ctx) .setup_command_state() .current_group_id() }); // Sanity-check the seeded state. assert!(model .lock() .block_list() .is_executing_oz_environment_startup_commands()); terminal_view.read(&app, |view, ctx| { let setup_state = view .ambient_agent_view_model() .expect("cloud mode view has ambient agent view model") .as_ref(ctx) .setup_command_state(); assert!(setup_state.is_running(initial_group_id)); assert!(setup_state.should_expand(initial_group_id)); }); // Dispatch the new marker; expect the BlockList flag to clear, the // setup group to finish, and the group's expansion to collapse. event_loop.update(&mut app, |event_loop, ctx| { event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no: 0, event_type: OrderedTerminalEventType::CloudModeSetupPhaseEnded, }, ctx, ); }); assert!(!model .lock() .block_list() .is_executing_oz_environment_startup_commands()); terminal_view.read(&app, |view, ctx| { let setup_state = view .ambient_agent_view_model() .expect("cloud mode view has ambient agent view model") .as_ref(ctx) .setup_command_state(); assert!(!setup_state.is_running(initial_group_id)); assert!(!setup_state.should_expand(initial_group_id)); }); }) } #[test] fn test_cloud_mode_setup_phase_ended_when_flag_already_false() { App::test((), |mut app| async move { let terminal_view = cloud_mode_terminal_view(&mut app); let model = terminal_view.read(&app, |view, _| view.model.clone()); // Mark as a viewer so the event loop's scrollback load invariant holds. model .lock() .set_shared_session_status(SharedSessionStatus::ViewPending); let channel_event_proxy = ChannelEventListener::new_for_test(); let event_loop = app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, empty_scrollback(), None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); // BlockList flag starts at the default `false`; we intentionally do not // call set_is_executing_oz_environment_startup_commands(true) so the // marker arrives against a tree that never observed setup phase. assert!(!model .lock() .block_list() .is_executing_oz_environment_startup_commands()); let initial_group_id = terminal_view.read(&app, |view, ctx| { view.ambient_agent_view_model() .expect("cloud mode view has ambient agent view model") .as_ref(ctx) .setup_command_state() .current_group_id() }); event_loop.update(&mut app, |event_loop, ctx| { event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no: 0, event_type: OrderedTerminalEventType::CloudModeSetupPhaseEnded, }, ctx, ); }); // Flag stays cleared, and the unconditional teardown leaves the // initial setup group finished and collapsed. assert!(!model .lock() .block_list() .is_executing_oz_environment_startup_commands()); terminal_view.read(&app, |view, ctx| { let setup_state = view .ambient_agent_view_model() .expect("cloud mode view has ambient agent view model") .as_ref(ctx) .setup_command_state(); assert!(!setup_state.is_running(initial_group_id)); assert!(!setup_state.should_expand(initial_group_id)); }); }) } #[test] fn test_cloud_mode_setup_phase_ended_is_idempotent() { App::test((), |mut app| async move { let terminal_view = cloud_mode_terminal_view(&mut app); let model = terminal_view.read(&app, |view, _| view.model.clone()); // Mark as a viewer so the event loop's scrollback load invariant holds. model .lock() .set_shared_session_status(SharedSessionStatus::ViewPending); let channel_event_proxy = ChannelEventListener::new_for_test(); let event_loop = app.add_model(|ctx| { EventLoop::new( model.clone(), terminal_view.downgrade(), channel_event_proxy.clone(), WindowSize { num_rows: 0, num_cols: 0, }, empty_scrollback(), None, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, RemoteUpdateGuard::new(), ctx, ) }); model .lock() .block_list_mut() .set_is_executing_oz_environment_startup_commands(true); let initial_group_id = terminal_view.read(&app, |view, ctx| { view.ambient_agent_view_model() .expect("cloud mode view has ambient agent view model") .as_ref(ctx) .setup_command_state() .current_group_id() }); // First dispatch tears down the setup state. event_loop.update(&mut app, |event_loop, ctx| { event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no: 0, event_type: OrderedTerminalEventType::CloudModeSetupPhaseEnded, }, ctx, ); }); // Second dispatch must be a no-op: the BlockList flag stays cleared // and the setup group remains finished + collapsed. event_loop.update(&mut app, |event_loop, ctx| { event_loop.process_ordered_terminal_event( OrderedTerminalEvent { event_no: 1, event_type: OrderedTerminalEventType::CloudModeSetupPhaseEnded, }, ctx, ); }); assert!(!model .lock() .block_list() .is_executing_oz_environment_startup_commands()); terminal_view.read(&app, |view, ctx| { let setup_state = view .ambient_agent_view_model() .expect("cloud mode view has ambient agent view model") .as_ref(ctx) .setup_command_state(); assert!(!setup_state.is_running(initial_group_id)); assert!(!setup_state.should_expand(initial_group_id)); }); }) }