Files
galaxy/app/src/terminal/shared_session/viewer/event_loop_tests.rs
T

1025 lines
37 KiB
Rust

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<Vec<u8>>,
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<TerminalView> {
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<TerminalView> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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));
});
})
}