1025 lines
37 KiB
Rust
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));
|
|
});
|
|
})
|
|
}
|