aboutsummaryrefslogtreecommitdiff
path: root/src/server/headless/tests/mod.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-21 20:47:14 +0300
committerGitHub <noreply@github.com>2026-09-21 20:47:14 +0300
commit28360107c1bfab57fb0de99d74bfa48e74423ca8 (patch)
tree30f9d953e48fb81b32f2a663500c6cff6f447f8f /src/server/headless/tests/mod.rs
parent309749ad65f3aa596f077ec23a1bf3ee428b7a04 (diff)
fix: distinguish agent completion from startup and session changes (#4457)
refs #3338 refs #3990 refs #3916
Diffstat (limited to 'src/server/headless/tests/mod.rs')
-rw-r--r--src/server/headless/tests/mod.rs376
1 files changed, 323 insertions, 53 deletions
diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs
index cf6d389b..6accdb09 100644
--- a/src/server/headless/tests/mod.rs
+++ b/src/server/headless/tests/mod.rs
@@ -9,12 +9,36 @@ mod surface_delta_tests;
#[path = "surface_interest.rs"]
mod surface_interest_tests;
-fn client_shell_snapshot(message: ServerMessage) -> Box<crate::protocol::ClientShellSnapshot> {
- let ServerMessage::EndpointControl { kind, data } = message else {
- panic!("expected client shell snapshot");
+fn client_shell_projection(
+ receiver: &std::sync::mpsc::Receiver<Vec<u8>>,
+) -> (
+ Box<protocol::ClientShellSnapshot>,
+ protocol::endpoint::EndpointAgentCompletions,
+) {
+ let read_control = |expected| {
+ let ServerMessage::EndpointControl { kind, data } = read_server_message(
+ receiver
+ .recv_timeout(Duration::from_secs(1))
+ .expect("endpoint projection"),
+ ) else {
+ panic!("expected endpoint control {expected}");
+ };
+ assert_eq!(kind, expected);
+ data
};
- assert_eq!(kind, crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND);
- Box::new(serde_json::from_str(&data).expect("decode client shell snapshot"))
+ let completions: protocol::endpoint::EndpointAgentCompletions =
+ serde_json::from_str(&read_control(protocol::endpoint::AGENT_COMPLETIONS_KIND)).unwrap();
+ let snapshot: Box<protocol::ClientShellSnapshot> =
+ serde_json::from_str(&read_control(protocol::endpoint::ENDPOINT_SNAPSHOT_KIND)).unwrap();
+ assert_eq!(completions.boot_id, snapshot.boot_id);
+ assert_eq!(completions.revision, snapshot.revision);
+ (snapshot, completions)
+}
+
+fn client_shell_snapshot(
+ receiver: &std::sync::mpsc::Receiver<Vec<u8>>,
+) -> Box<protocol::ClientShellSnapshot> {
+ client_shell_projection(receiver).0
}
fn client_agent_view_projection(
@@ -715,6 +739,64 @@ async fn client_shell_attach_seeds_workspace() {
}
#[tokio::test]
+async fn completion_guard_endpoint_pairs_runtime_completions_with_snapshots() {
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("endpoint");
+ let pane_id = workspace.tabs[0].root_pane;
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(0);
+ let (writer, control_rx, _render_rx) = test_client_writer();
+ server.handle_server_event(ServerEvent::ClientShellConnected {
+ client_id: 78,
+ surface_cols: 80,
+ surface_rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: false,
+ surface_active: false,
+ surface_reuse: false,
+ surface_delta: false,
+ writer,
+ });
+ let (_, initial) = client_shell_projection(&control_rx);
+ assert!(initial.completions.is_empty());
+ for first_state in [
+ crate::detect::AgentState::Working,
+ crate::detect::AgentState::Unknown,
+ ] {
+ for state in [first_state, crate::detect::AgentState::Idle] {
+ server.app.state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(crate::detect::Agent::Pi),
+ state,
+ visible_blocker: false,
+ visible_working: state == crate::detect::AgentState::Working,
+ process_exited: false,
+ observed_at: Instant::now(),
+ });
+ }
+ server.render_and_stream();
+ let (snapshot, completions) = client_shell_projection(&control_rx);
+ assert_eq!(snapshot.agents.len(), 1);
+ let agent = &snapshot.agents[0];
+ let expected =
+ (first_state == crate::detect::AgentState::Working).then_some(agent.state_change_seq);
+ assert_eq!(
+ completions.completions.get(&agent.pane_id).copied(),
+ expected
+ );
+ assert_eq!(
+ server.app.session_snapshot().agents[0].completion_seq,
+ expected
+ );
+ }
+}
+
+#[tokio::test]
async fn client_shell_endpoint_request_uses_the_selected_connection() {
let mut server = test_headless_server();
server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("endpoint")];
@@ -739,7 +821,7 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() {
writer,
})
);
- let _initial_snapshot = control_rx.recv().expect("initial shell snapshot");
+ let _initial_snapshot = client_shell_snapshot(&control_rx);
let boot_id = server.client_shell_boot_id.clone();
assert!(
@@ -857,9 +939,7 @@ async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots(
writer,
})
);
- let initial = client_shell_snapshot(read_server_message(
- control_rx.recv().expect("initial snapshot"),
- ));
+ let initial = client_shell_snapshot(&control_rx);
let mut view = AgentViewSetParams {
source: "example.views".into(),
@@ -875,9 +955,7 @@ async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots(
let set = client_agent_view_projection(read_server_message(
control_rx.recv().expect("set projection"),
));
- let set_snapshot = client_shell_snapshot(read_server_message(
- control_rx.recv().expect("set snapshot"),
- ));
+ let set_snapshot = client_shell_snapshot(&control_rx);
assert!(set.revision > initial.revision);
assert_eq!(set.revision, set_snapshot.revision);
assert_eq!(
@@ -894,9 +972,7 @@ async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots(
let replacement = client_agent_view_projection(read_server_message(
control_rx.recv().expect("replacement projection"),
));
- let replacement_snapshot = client_shell_snapshot(read_server_message(
- control_rx.recv().expect("replacement snapshot"),
- ));
+ let replacement_snapshot = client_shell_snapshot(&control_rx);
assert!(replacement.revision > set.revision);
assert_eq!(replacement.revision, replacement_snapshot.revision);
assert_eq!(
@@ -913,9 +989,7 @@ async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots(
let cleared = client_agent_view_projection(read_server_message(
control_rx.recv().expect("clear projection"),
));
- let cleared_snapshot = client_shell_snapshot(read_server_message(
- control_rx.recv().expect("clear snapshot"),
- ));
+ let cleared_snapshot = client_shell_snapshot(&control_rx);
assert!(cleared.revision > replacement.revision);
assert_eq!(cleared.revision, cleared_snapshot.revision);
assert!(cleared.view.is_none());
@@ -967,9 +1041,7 @@ async fn client_shell_receives_metadata_then_shell_free_pane_surface() {
writer,
})
);
- let snapshot = client_shell_snapshot(read_server_message(
- control_rx.recv().expect("shell snapshot"),
- ));
+ let snapshot = client_shell_snapshot(&control_rx);
assert_eq!(snapshot.workspaces.len(), 1);
assert_eq!(snapshot.workspaces[0].label, "shell-only-label");
assert_eq!(
@@ -1599,9 +1671,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() {
writer: local_writer,
})
);
- let local_snapshot = client_shell_snapshot(read_server_message(
- local_control.recv().expect("local shell snapshot"),
- ));
+ let local_snapshot = client_shell_snapshot(&local_control);
assert_eq!(
local_snapshot.config_diagnostic.as_deref(),
Some("theme warning")
@@ -1625,9 +1695,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() {
writer: endpoint_writer,
})
);
- let endpoint_snapshot = client_shell_snapshot(read_server_message(
- endpoint_control.recv().expect("endpoint shell snapshot"),
- ));
+ let endpoint_snapshot = client_shell_snapshot(&endpoint_control);
assert_eq!(
endpoint_snapshot.config_diagnostic.as_deref(),
Some("server keybinding warning\ntheme warning")
@@ -1658,12 +1726,8 @@ async fn client_shell_tab_focus_changes_only_the_source_connection() {
let (first_control, _first_render) = connect_test_shell(&mut server, 7, 100, 30);
let (second_control, _second_render) = connect_test_shell(&mut server, 8, 80, 24);
- let first_initial = client_shell_snapshot(read_server_message(
- first_control.recv().expect("first snapshot"),
- ));
- let second_initial = client_shell_snapshot(read_server_message(
- second_control.recv().expect("second snapshot"),
- ));
+ let first_initial = client_shell_snapshot(&first_control);
+ let second_initial = client_shell_snapshot(&second_control);
assert_eq!(
first_initial.focused_tab_id.as_deref(),
Some(first_tab_id.as_str())
@@ -1699,9 +1763,7 @@ async fn client_shell_tab_focus_changes_only_the_source_connection() {
first_control.try_recv().is_err(),
"another shell must not receive a navigation replacement"
);
- let second_replacement = client_shell_snapshot(read_server_message(
- second_control.recv().expect("second replacement snapshot"),
- ));
+ let second_replacement = client_shell_snapshot(&second_control);
assert_eq!(
second_replacement.focused_tab_id.as_deref(),
Some(second_tab_id.as_str())
@@ -2433,13 +2495,11 @@ async fn public_agent_focus_replaces_a_diverged_client_shell_projection() {
let second_tab_id = server.app.public_tab_id(1, 0).unwrap();
let (control_rx, render_rx) = connect_test_shell(&mut server, 9, 80, 23);
- let _ = control_rx.recv().expect("initial snapshot");
+ let _ = client_shell_snapshot(&control_rx);
assert!(server.focus_shell_client_on_tab(9, &second_tab_id));
assert!(server.claim_shell_tab_geometry(9, false));
server.render_and_stream();
- let diverged = client_shell_snapshot(read_server_message(
- control_rx.recv().expect("diverged snapshot"),
- ));
+ let diverged = client_shell_snapshot(&control_rx);
assert_eq!(
diverged.focused_workspace_id.as_deref(),
Some(server.app.public_workspace_id(1).as_str())
@@ -2484,9 +2544,7 @@ async fn public_agent_focus_replaces_a_diverged_client_shell_projection() {
assert_eq!(location.focused_tab_id(), Some(first_tab_id.as_str()));
server.render_and_stream();
- let replacement = client_shell_snapshot(read_server_message(
- control_rx.recv().expect("agent focus replacement snapshot"),
- ));
+ let replacement = client_shell_snapshot(&control_rx);
assert_eq!(
replacement.focused_workspace_id.as_deref(),
Some(first_workspace_id.as_str())
@@ -2529,10 +2587,7 @@ async fn public_api_focus_replaces_every_client_shell_projection() {
writer,
})
);
- let initial_revision = client_shell_snapshot(read_server_message(
- control_rx.recv().expect("initial snapshot"),
- ))
- .revision;
+ let initial_revision = client_shell_snapshot(&control_rx).revision;
let (respond_to, _response_rx) = std::sync::mpsc::channel();
server.handle_api_request_with_shutdown_check(crate::api::ApiRequestMessage {
@@ -2551,9 +2606,7 @@ async fn public_api_focus_replaces_every_client_shell_projection() {
assert_eq!(server.app.state.active, Some(1));
server.render_and_stream();
- let replacement = client_shell_snapshot(read_server_message(
- control_rx.recv().expect("replacement snapshot"),
- ));
+ let replacement = client_shell_snapshot(&control_rx);
assert!(replacement.revision > initial_revision);
assert_eq!(
replacement.focused_workspace_id.as_deref(),
@@ -2781,9 +2834,7 @@ async fn client_shell_streams_and_targets_popup_terminal_content() {
writer,
})
);
- let _snapshot = client_shell_snapshot(read_server_message(
- control_rx.recv().expect("shell snapshot"),
- ));
+ let _snapshot = client_shell_snapshot(&control_rx);
server.render_and_stream();
let ServerMessage::PaneSurface(surface) =
@@ -6726,6 +6777,225 @@ fn notification_show_api_includes_sound_in_semantic_event() {
}
}
+fn completion_guard_server(writer: ClientWriter) -> (HeadlessServer, crate::layout::PaneId) {
+ let mut server = test_headless_server();
+ server.app.state.workspaces = ["background", "active"]
+ .map(crate::workspace::Workspace::test_new)
+ .into();
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(1);
+ server.app.state.toast_config.delay_seconds = 0;
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ Default::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(writer),
+ ),
+ );
+ let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
+ (server, pane_id)
+}
+
+fn completion_guard_api_report(server: &mut HeadlessServer, method: api::schema::Method) {
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "completion-probe".into(),
+ method,
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ });
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ serde_json::from_str::<api::schema::SuccessResponse>(&response).expect("successful report");
+}
+
+fn completion_guard_notifications(
+ server: &mut HeadlessServer,
+ receiver: &std::sync::mpsc::Receiver<Vec<u8>>,
+) -> Vec<protocol::SemanticNotificationKind> {
+ server.send_to_client(
+ 1,
+ ServerMessage::EndpointControl {
+ kind: "test.completion-barrier".into(),
+ data: String::new(),
+ },
+ );
+ let mut messages = Vec::new();
+ loop {
+ let message = read_server_message(
+ receiver
+ .recv_timeout(Duration::from_secs(1))
+ .expect("notification barrier"),
+ );
+ if matches!(&message, ServerMessage::EndpointControl { kind, .. } if kind == "test.completion-barrier")
+ {
+ return messages;
+ }
+ if let ServerMessage::SemanticNotification(notification) = message {
+ messages.push(notification.kind);
+ }
+ }
+}
+
+#[test]
+fn completion_guard_api_startup_blocker_respects_suppression() {
+ use protocol::SemanticNotificationKind::{Finished, NeedsAttention};
+
+ let (writer, control_rx, _render_rx) = test_client_writer();
+ let (mut server, pane_id) = completion_guard_server(writer);
+ server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected {
+ pane_id,
+ agent: crate::detect::Agent::Pi,
+ observed_at: Instant::now(),
+ });
+ let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap();
+ for (seq, state) in [
+ api::schema::PaneAgentState::Blocked,
+ api::schema::PaneAgentState::Idle,
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ completion_guard_api_report(
+ &mut server,
+ api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams {
+ pane_id: public_pane_id.clone(),
+ source: "custom:pi".into(),
+ agent: "pi".into(),
+ state,
+ message: None,
+ seq: Some(seq as u64 + 1),
+ agent_session_id: None,
+ agent_session_path: None,
+ }),
+ );
+ }
+ let notifications = completion_guard_notifications(&mut server, &control_rx);
+ assert!(notifications.contains(&NeedsAttention));
+ assert!(
+ !notifications.contains(&Finished),
+ "startup: {notifications:?}"
+ );
+}
+
+#[test]
+fn completion_guard_api_session_replacement_does_not_notify_finished() {
+ use crate::agent_resume::{AgentSessionRef, PersistedAgentSession};
+ use crate::detect::{Agent, AgentState};
+ use api::schema::{
+ Method, PaneAgentState, PaneReportAgentParams, PaneReportAgentSessionParams,
+ };
+ use protocol::SemanticNotificationKind::Finished;
+
+ for fallback_state in [
+ AgentState::Unknown,
+ AgentState::Working,
+ AgentState::Blocked,
+ ] {
+ for reason in ["new", "resume", "fork"] {
+ let (writer, control_rx, _render_rx) = test_client_writer();
+ let (mut server, pane_id) = completion_guard_server(writer);
+ let terminal_id = server.app.state.workspaces[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = server.app.state.terminals.get_mut(&terminal_id).unwrap();
+ terminal.set_detected_state(Some(Agent::Pi), fallback_state);
+ let cwd = std::env::current_dir().unwrap();
+ let old_session =
+ AgentSessionRef::path(cwd.join("completion-old.jsonl").display().to_string())
+ .unwrap();
+ let new_session = cwd.join("completion-new.jsonl").display().to_string();
+ terminal.set_persisted_agent_session(PersistedAgentSession {
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ session_ref: old_session.clone(),
+ });
+ terminal
+ .set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Idle,
+ None,
+ Some(old_session),
+ Some(10),
+ )
+ .expect("initial authority");
+ let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap();
+ completion_guard_api_report(
+ &mut server,
+ Method::PaneReportAgentSession(PaneReportAgentSessionParams {
+ pane_id: public_pane_id.clone(),
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ seq: Some(11),
+ agent_session_id: None,
+ agent_session_path: Some(new_session.clone()),
+ session_start_source: Some(reason.into()),
+ }),
+ );
+ let mut report = PaneReportAgentParams {
+ pane_id: public_pane_id,
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ state: PaneAgentState::Idle,
+ message: None,
+ seq: Some(12),
+ agent_session_id: None,
+ agent_session_path: Some(new_session.clone()),
+ };
+ completion_guard_api_report(&mut server, Method::PaneReportAgent(report.clone()));
+ let terminal = &server.app.state.terminals[&terminal_id];
+ assert_eq!(terminal.state, AgentState::Idle);
+ assert_eq!(
+ terminal
+ .hook_authority
+ .as_ref()
+ .unwrap()
+ .session_ref
+ .as_ref()
+ .unwrap()
+ .value,
+ new_session
+ );
+ let notifications = completion_guard_notifications(&mut server, &control_rx);
+ assert!(
+ !notifications.contains(&Finished),
+ "{reason}: {notifications:?}"
+ );
+ assert!(server.app.state.workspaces[0].panes[&pane_id].seen);
+ for (seq, state, expected) in [
+ (13, PaneAgentState::Working, AgentState::Working),
+ (14, PaneAgentState::Idle, AgentState::Idle),
+ ] {
+ report.seq = Some(seq);
+ report.state = state;
+ completion_guard_api_report(&mut server, Method::PaneReportAgent(report.clone()));
+ let terminal = &server.app.state.terminals[&terminal_id];
+ assert_eq!(terminal.state, expected);
+ assert_eq!(
+ terminal.last_agent_completion_seq.is_some(),
+ expected == AgentState::Idle
+ );
+ }
+ let finished = completion_guard_notifications(&mut server, &control_rx)
+ .into_iter()
+ .filter(|kind| *kind == Finished)
+ .count();
+ assert_eq!(
+ finished, 1,
+ "the first real turn after {reason} must finish"
+ );
+ }
+ }
+}
+
#[test]
fn startup_idle_does_not_forward_completion() {
let mut server = test_headless_server();