diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-21 20:47:14 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-21 20:47:14 +0300 |
| commit | 28360107c1bfab57fb0de99d74bfa48e74423ca8 (patch) | |
| tree | 30f9d953e48fb81b32f2a663500c6cff6f447f8f | |
| parent | 309749ad65f3aa596f077ec23a1bf3ee428b7a04 (diff) | |
fix: distinguish agent completion from startup and session changes (#4457)
refs #3338
refs #3990
refs #3916
| -rw-r--r-- | docs/next/api/herdr-api.schema.json | 9 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/agent-automation.mdx | 2 | ||||
| -rw-r--r-- | src/api/schema/agents.rs | 3 | ||||
| -rw-r--r-- | src/app/actions.rs | 364 | ||||
| -rw-r--r-- | src/app/agents.rs | 1 | ||||
| -rw-r--r-- | src/client/endpoint/control.rs | 30 | ||||
| -rw-r--r-- | src/client/mod.rs | 10 | ||||
| -rw-r--r-- | src/client/shell/endpoint_agent_state.rs | 235 | ||||
| -rw-r--r-- | src/client/shell/endpoints.rs | 19 | ||||
| -rw-r--r-- | src/protocol/endpoint.rs | 20 | ||||
| -rw-r--r-- | src/server/client_shell.rs | 28 | ||||
| -rw-r--r-- | src/server/clients.rs | 2 | ||||
| -rw-r--r-- | src/server/headless.rs | 49 | ||||
| -rw-r--r-- | src/server/headless/notifications.rs | 80 | ||||
| -rw-r--r-- | src/server/headless/render.rs | 19 | ||||
| -rw-r--r-- | src/server/headless/tests/mod.rs | 376 | ||||
| -rw-r--r-- | src/server/headless/tests/pane_graphics.rs | 25 | ||||
| -rw-r--r-- | src/server/headless/tests/pane_move.rs | 4 | ||||
| -rw-r--r-- | src/server/headless/tests/surface_interest.rs | 23 | ||||
| -rw-r--r-- | src/server/notifications.rs | 6 | ||||
| -rw-r--r-- | src/server/render_scale_benchmark.rs | 29 | ||||
| -rw-r--r-- | src/terminal/state.rs | 44 |
22 files changed, 1061 insertions, 317 deletions
diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index bd758e9e..b4648b03 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -6693,6 +6693,15 @@ "agent_status": { "$ref": "#/schemas/success_response/$defs/AgentStatus" }, + "completion_seq": { + "description": "The current idle transition completed work, independently of who has viewed it.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, "cwd": { "type": [ "string", diff --git a/docs/next/website/src/content/docs/agent-automation.mdx b/docs/next/website/src/content/docs/agent-automation.mdx index e7c02ae3..def306fa 100644 --- a/docs/next/website/src/content/docs/agent-automation.mdx +++ b/docs/next/website/src/content/docs/agent-automation.mdx @@ -77,6 +77,8 @@ Pane input addresses the terminal regardless of its current occupant. Agent inpu `idle` and `done` both mean the agent is ready for input. The CLI/API uses the server's seen state: `done` is idle but not yet marked seen, explicit `pane focus` / `agent focus` commands mark the target seen, and reads do not. Each TUI client tracks viewed completions independently, so a client's Done badge can differ from the CLI or another client's badge. `blocked` means Herdr recognized an approval or question UI. `unknown` means an agent is present but Herdr cannot classify its lifecycle confidently; it does not prove successful completion. Use exact `--until` states when that distinction matters. +Startup readiness, restoring an idle conversation, and switching agent conversations do not count as completed work. An agent that starts working immediately can finish its first task without Herdr first seeing an idle prompt. Agent responses may include `completion_seq`, identifying the current idle transition as completed work independently of who has viewed it. It matches that transition's `state_change_seq`; startup and session changes do not set it. Older servers may omit this field. Clients connected to older servers use observed working states to guard Done badges, so they may miss a turn that starts and finishes between updates. + A timeout or `agent_prompt_stalled` does not prove that no input was sent. Read the agent before retrying to avoid submitting the same prompt twice. IDs and agent names are scoped to one server; selecting another machine in the TUI does not retarget CLI commands running in an existing pane. `pane wait-output` does not interpret agent lifecycle. It polls the selected terminal snapshot and searches it immediately, so text that was already present can match. The default source name is `recent`; matching treats that source as unwrapped recent output from the latest 80 rendered terminal rows. `--lines` changes that row limit, and `--regex` uses Rust regular-expression syntax and matches one line at a time. diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs index ee496d5d..48450c30 100644 --- a/src/api/schema/agents.rs +++ b/src/api/schema/agents.rs @@ -218,6 +218,9 @@ pub struct AgentInfo { pub interactive_ready: bool, #[serde(default)] pub state_change_seq: u64, + /// The current idle transition completed work, independently of who has viewed it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completion_seq: Option<u64>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/app/actions.rs b/src/app/actions.rs index 85314331..953bcd94 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -25,12 +25,7 @@ fn is_background_completion_transition(prev_state: AgentState, new_state: AgentS } fn is_completion_transition(change: &EffectiveStateChange) -> bool { - is_completion_transition_parts( - change.previous_state, - change.state, - change.previous_agent_label.as_deref(), - change.agent_label.as_deref(), - ) + is_background_completion_transition(change.previous_state, change.state) } fn public_tab_id_for_index(ws: &crate::workspace::Workspace, tab_idx: usize) -> Option<String> { @@ -40,19 +35,6 @@ fn public_tab_id_for_index(ws: &crate::workspace::Workspace, tab_idx: usize) -> )) } -pub fn is_completion_transition_parts( - previous_state: AgentState, - state: AgentState, - previous_agent_label: Option<&str>, - agent_label: Option<&str>, -) -> bool { - is_background_completion_transition(previous_state, state) - || (previous_state == AgentState::Unknown - && state == AgentState::Idle - && previous_agent_label.is_some() - && previous_agent_label == agent_label) -} - pub fn active_tab_suppresses_notifications( is_active_tab: bool, outer_terminal_focus: Option<bool>, @@ -60,7 +42,6 @@ pub fn active_tab_suppresses_notifications( is_active_tab && outer_terminal_focus != Some(false) } -#[cfg(test)] pub fn notification_sound_for_state_change( suppress_active_tab_notifications: bool, prev_state: AgentState, @@ -82,58 +63,21 @@ pub fn notification_sound_for_state_change( } } -pub fn notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications: bool, - prev_state: AgentState, - new_state: AgentState, - previous_agent_label: Option<&str>, - agent_label: Option<&str>, -) -> Option<crate::sound::Sound> { - if new_state == prev_state { - return None; - } - - match new_state { - AgentState::Blocked => Some(crate::sound::Sound::Request), - AgentState::Idle - if is_completion_transition_parts( - prev_state, - new_state, - previous_agent_label, - agent_label, - ) && !suppress_active_tab_notifications => - { - Some(crate::sound::Sound::Done) - } - _ => None, - } -} - fn notification_sound_for_effective_state_change( suppress_active_tab_notifications: bool, change: &EffectiveStateChange, ) -> Option<crate::sound::Sound> { - if change.state == change.previous_state { - return None; - } - - match change.state { - AgentState::Blocked => Some(crate::sound::Sound::Request), - AgentState::Idle - if is_completion_transition(change) && !suppress_active_tab_notifications => - { - Some(crate::sound::Sound::Done) - } - _ => None, - } + notification_sound_for_state_change( + suppress_active_tab_notifications, + change.previous_state, + change.state, + ) } -pub fn notification_toast_for_state_change_with_agent_labels( +pub fn notification_toast_for_state_change( suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, - previous_agent_label: Option<&str>, - agent_label: Option<&str>, ) -> Option<ToastKind> { if suppress_active_tab_notifications || new_state == prev_state { return None; @@ -141,14 +85,7 @@ pub fn notification_toast_for_state_change_with_agent_labels( match new_state { AgentState::Blocked => Some(ToastKind::NeedsAttention), - AgentState::Idle - if is_completion_transition_parts( - prev_state, - new_state, - previous_agent_label, - agent_label, - ) => - { + AgentState::Idle if is_background_completion_transition(prev_state, new_state) => { Some(ToastKind::Finished) } _ => None, @@ -159,15 +96,11 @@ fn notification_toast_for_effective_state_change( suppress_active_tab_notifications: bool, change: &EffectiveStateChange, ) -> Option<ToastKind> { - if suppress_active_tab_notifications || change.state == change.previous_state { - return None; - } - - match change.state { - AgentState::Blocked => Some(ToastKind::NeedsAttention), - AgentState::Idle if is_completion_transition(change) => Some(ToastKind::Finished), - _ => None, - } + notification_toast_for_state_change( + suppress_active_tab_notifications, + change.previous_state, + change.state, + ) } pub fn notification_toast_for_pane_state_update( @@ -181,12 +114,10 @@ pub fn notification_toast_for_pane_state_update( return None; } - notification_toast_for_state_change_with_agent_labels( + notification_toast_for_state_change( suppress_active_tab_notifications, update.previous_state, update.state, - update.previous_agent_label.as_deref(), - update.agent_label.as_deref(), ) } @@ -1709,39 +1640,55 @@ impl AppState { managed_changed, agent_name_changed, unchanged_change, - managed_launch_pending, suppress_acquisition_completion, + completion_reset, ) = { let terminal = self.terminals.get_mut(&terminal_id)?; let previous_agent_name = terminal.agent_name.clone(); - let managed_launch_pending = terminal.managed_agent_launch_pending(); + let had_completion = terminal.last_agent_completion_seq.is_some() || !previous_seen; let mutation = update(terminal)?; + let completion_reset = mutation.session_ref_changed + || mutation + .effective_state_change + .as_ref() + .is_some_and(|change| change.previous_agent_label != change.agent_label); + if completion_reset { + terminal.last_agent_completion_seq = None; + } let managed_changed = terminal.reconcile_managed_agent_at(now, false); let suppress_acquisition_completion = terminal.finish_agent_process_acquisition(); let agent_name_changed = terminal.agent_name != previous_agent_name; - let unchanged_change = (mutation.agent_released || agent_name_changed) + let unchanged_change = (mutation.agent_released + || agent_name_changed + || (completion_reset && had_completion)) .then(|| terminal.unchanged_effective_state_change_at(now)); ( mutation, managed_changed, agent_name_changed, unchanged_change, - managed_launch_pending, suppress_acquisition_completion, + completion_reset, ) }; + if completion_reset { + self.pending_agent_notifications.remove(&pane_id); + self.workspaces[ws_idx].pane_state_mut(pane_id)?.seen = true; + } if mutation.session_ref_changed || managed_changed || agent_name_changed { self.mark_session_dirty(); } let agent_released = mutation.agent_released; let change = mutation.effective_state_change.or(unchanged_change)?; let suppress_completion = force_suppress_completion - || (change.state == AgentState::Idle - && (managed_launch_pending || suppress_acquisition_completion)); + || (change.state == AgentState::Idle && suppress_acquisition_completion); if change.previous_state != change.state { self.next_agent_state_change_seq += 1; if let Some(terminal) = self.terminals.get_mut(&terminal_id) { terminal.last_agent_state_change_seq = Some(self.next_agent_state_change_seq); + terminal.last_agent_completion_seq = (!suppress_completion + && is_completion_transition(&change)) + .then_some(self.next_agent_state_change_seq); } } let seen = self.apply_pane_state_change(ws_idx, pane_id, &change, suppress_completion)?; @@ -3054,34 +3001,217 @@ mod tests { assert!(pane.seen); } + fn assert_completion_guard_sequence( + acquired: bool, + states: &[AgentState], + expect_completion: bool, + ) { + let mut app = app_with_workspaces(&["active", "background"]); + app.active = Some(0); + app.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.toast_config.delay_seconds = 0; + let pane_id = app.workspaces[1].tabs[0].root_pane; + if acquired { + app.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: Instant::now(), + }); + } + for &state in states { + app.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state, + visible_blocker: state == AgentState::Blocked, + visible_working: state == AgentState::Working, + process_exited: false, + observed_at: Instant::now(), + }); + } + let finished = app + .toast + .as_ref() + .is_some_and(|toast| toast.kind == ToastKind::Finished); + assert_eq!( + finished, expect_completion, + "completion notification: {states:?}" + ); + assert_eq!( + !app.workspaces[1].panes[&pane_id].seen, expect_completion, + "unseen completion: {states:?}" + ); + let terminal = &app.terminals[&app.workspaces[1].panes[&pane_id].attached_terminal_id]; + assert_eq!( + terminal.last_agent_completion_seq.is_some(), + expect_completion + ); + if expect_completion { + assert_eq!( + terminal.last_agent_completion_seq, + terminal.last_agent_state_change_seq + ); + } + app.assert_invariants_for_test(); + } + #[test] - fn idle_after_known_unknown_agent_in_background_marks_done() { - let mut state = app_with_workspaces(&["active", "background"]); - state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - state.active = Some(0); - let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + fn completion_guard_unknown_to_idle_is_not_completed_work() { + assert_completion_guard_sequence(false, &[AgentState::Unknown, AgentState::Idle], false); + } - state.handle_app_event(AppEvent::StateChanged { - pane_id: bg_pane_id, - agent: Some(Agent::Pi), - state: AgentState::Unknown, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: std::time::Instant::now(), - }); - state.handle_app_event(AppEvent::StateChanged { - pane_id: bg_pane_id, - agent: Some(Agent::Pi), - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: std::time::Instant::now(), - }); + #[test] + fn completion_guard_first_work_finishes_without_prior_idle() { + assert_completion_guard_sequence(true, &[AgentState::Working, AgentState::Idle], true); + } - let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); - assert!(!pane.seen); + #[test] + fn completion_guard_first_work_can_pause_for_permission() { + assert_completion_guard_sequence( + true, + &[AgentState::Working, AgentState::Blocked, AgentState::Idle], + true, + ); + } + + #[test] + fn completion_guard_startup_trust_is_not_completed_work() { + assert_completion_guard_sequence(true, &[AgentState::Blocked, AgentState::Idle], false); + } + + #[test] + fn completion_guard_managed_launch_readiness_does_not_swallow_work() { + for first_state in [AgentState::Blocked, AgentState::Working] { + let mut app = app_with_workspaces(&["active", "background"]); + app.active = Some(0); + let pane_id = app.workspaces[1].tabs[0].root_pane; + let terminal_id = app.workspaces[1].panes[&pane_id] + .attached_terminal_id + .clone(); + app.terminals + .get_mut(&terminal_id) + .unwrap() + .begin_managed_agent( + "worker".into(), + Agent::Pi, + Instant::now(), + std::time::Duration::ZERO, + std::time::Duration::from_secs(60), + ); + for state in [first_state, AgentState::Idle] { + app.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state, + visible_blocker: state == AgentState::Blocked, + visible_working: state == AgentState::Working, + process_exited: false, + observed_at: Instant::now(), + }); + } + let terminal = &app.terminals[&terminal_id]; + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!( + terminal.last_agent_completion_seq.is_some(), + first_state == AgentState::Working + ); + assert_eq!( + !app.workspaces[1].panes[&pane_id].seen, + first_state == AgentState::Working + ); + } + } + + #[test] + fn completion_guard_same_state_agent_replacement_clears_old_work() { + let mut app = app_with_workspaces(&["active", "background"]); + app.active = Some(0); + app.toast_config.delay_seconds = 5; + let pane_id = app.workspaces[1].tabs[0].root_pane; + for (seq, label, state) in [ + (1, "old", AgentState::Working), + (2, "old", AgentState::Idle), + (3, "new", AgentState::Idle), + ] { + app.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "custom:worker".into(), + agent_label: label.into(), + state, + message: None, + seq: Some(seq), + session_ref: None, + }); + if seq == 2 { + assert!(!app.workspaces[1].panes[&pane_id].seen); + assert!(app.pending_agent_notifications.contains_key(&pane_id)); + } + } + let terminal = &app.terminals[&app.workspaces[1].panes[&pane_id].attached_terminal_id]; + assert_eq!(terminal.effective_agent_label(), Some("new")); + assert!(terminal.last_agent_completion_seq.is_none()); + assert!(app.workspaces[1].panes[&pane_id].seen); + assert!(!app.pending_agent_notifications.contains_key(&pane_id)); + } + + #[test] + fn completion_guard_idle_session_replacement_clears_seen_and_pending_delivery() { + let mut app = app_with_workspaces(&["active", "background"]); + app.active = Some(0); + app.toast_config.delay_seconds = 5; + let pane_id = app.workspaces[1].tabs[0].root_pane; + for (seq, session, reason) in [(1, "old-session", "startup"), (2, "new-session", "clear")] { + let updates = app.handle_app_event(AppEvent::AgentSessionReported { + pane_id, + source: "herdr:claude".into(), + agent_label: "claude".into(), + seq: Some(seq), + session_ref: crate::agent_resume::AgentSessionRef::id(session), + session_start_source: Some(reason.into()), + }); + if seq == 1 { + for state in [AgentState::Working, AgentState::Idle] { + app.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Claude), + state, + visible_blocker: false, + visible_working: state == AgentState::Working, + process_exited: false, + observed_at: Instant::now(), + }); + } + assert!(!app.workspaces[1].panes[&pane_id].seen); + assert!(app.pending_agent_notifications.contains_key(&pane_id)); + } else { + assert!( + !updates.is_empty(), + "session replacement must publish attention reset" + ); + } + } + let terminal = &app.terminals[&app.workspaces[1].panes[&pane_id].attached_terminal_id]; + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .unwrap() + .session_ref + .value, + "new-session" + ); + assert!(terminal.last_agent_completion_seq.is_none()); + assert!(app.workspaces[1].panes[&pane_id].seen); + assert!(!app.pending_agent_notifications.contains_key(&pane_id)); + } + + #[test] + fn completion_guard_normal_turn_still_finishes() { + assert_completion_guard_sequence( + true, + &[AgentState::Idle, AgentState::Working, AgentState::Idle], + true, + ); } #[test] @@ -3139,9 +3269,9 @@ mod tests { .pop() .expect("idle state update"); - assert!(update.suppress_completion); - assert!(state.workspaces[1].panes[&pane_id].seen); - assert!(!matches!( + assert!(!update.suppress_completion); + assert!(!state.workspaces[1].panes[&pane_id].seen); + assert!(matches!( state.toast.as_ref().map(|toast| toast.kind), Some(ToastKind::Finished) )); diff --git a/src/app/agents.rs b/src/app/agents.rs index 5033505d..fd2a58ed 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -395,6 +395,7 @@ impl App { launch_pending: terminal.managed_agent_launch_pending(), interactive_ready: terminal.managed_agent_interactive_ready(), state_change_seq: terminal.last_agent_state_change_seq.unwrap_or(0), + completion_seq: terminal.last_agent_completion_seq, cwd: pane.cwd, foreground_cwd: pane.foreground_cwd, revision: pane.revision, diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs index 6eef8dc8..f2fe1b76 100644 --- a/src/client/endpoint/control.rs +++ b/src/client/endpoint/control.rs @@ -9,6 +9,7 @@ pub(crate) struct DecodedAgentViewProjection { pub(crate) enum EndpointControlMessage { HealthPong, AgentViewProjection(DecodedAgentViewProjection), + AgentCompletions(crate::protocol::endpoint::EndpointAgentCompletions), Snapshot(Box<crate::protocol::ClientShellSnapshot>), Ignored, } @@ -20,6 +21,11 @@ pub(crate) fn decode_endpoint_control( if kind == crate::protocol::endpoint::HEALTH_PONG_KIND { return Ok(EndpointControlMessage::HealthPong); } + if kind == crate::protocol::endpoint::AGENT_COMPLETIONS_KIND { + return Ok(serde_json::from_str(data) + .map(EndpointControlMessage::AgentCompletions) + .unwrap_or(EndpointControlMessage::Ignored)); + } if kind == crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND { let Ok(projection): Result<crate::protocol::endpoint::EndpointAgentViewProjection, _> = serde_json::from_str(data) @@ -78,6 +84,30 @@ mod tests { } #[test] + fn completion_guard_optional_control_round_trips_without_changing_snapshot_codec() { + let projection = crate::protocol::endpoint::EndpointAgentCompletions { + boot_id: "boot".into(), + revision: 3, + completions: [("pane".into(), 7)].into_iter().collect(), + }; + let crate::protocol::ServerMessage::EndpointControl { kind, data } = + crate::protocol::endpoint::agent_completions_message(&projection).unwrap() + else { + panic!("expected optional control"); + }; + let EndpointControlMessage::AgentCompletions(decoded) = + decode_endpoint_control(&kind, &data).unwrap() + else { + panic!("expected completion projection"); + }; + assert_eq!(decoded, projection); + assert!(matches!( + decode_endpoint_control(&kind, "invalid").unwrap(), + EndpointControlMessage::Ignored + )); + } + + #[test] fn agent_view_projection_decodes_and_validates_the_view() { let view = crate::api::schema::AgentViewSetParams { source: "example.views".into(), diff --git a/src/client/mod.rs b/src/client/mod.rs index b9335e06..68e569f9 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1913,6 +1913,16 @@ async fn run_client_loop( } continue; } + Ok(endpoint::EndpointControlMessage::AgentCompletions(projection)) => { + if let Some(shell) = state.shell.as_mut() { + shell.set_endpoint_agent_completions( + &endpoint_id, + generation, + projection, + ); + } + continue; + } Ok(endpoint::EndpointControlMessage::Ignored) => { debug!(%kind, "ignoring unknown endpoint control message"); continue; diff --git a/src/client/shell/endpoint_agent_state.rs b/src/client/shell/endpoint_agent_state.rs index f8ba7a9b..1ebcd35d 100644 --- a/src/client/shell/endpoint_agent_state.rs +++ b/src/client/shell/endpoint_agent_state.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::api::schema::AgentStatus; use crate::protocol::{ClientShellAgent, ClientShellSnapshot, PaneSurfaceFrame}; @@ -7,13 +7,49 @@ use crate::protocol::{ClientShellAgent, ClientShellSnapshot, PaneSurfaceFrame}; pub(super) struct EndpointAgentPresentation { boot_id: Option<String>, acknowledged: HashMap<String, u64>, + completed: HashMap<String, u64>, + working: HashSet<String>, + pending_completions: Option<( + Option<u64>, + crate::protocol::endpoint::EndpointAgentCompletions, + )>, } impl EndpointAgentPresentation { + pub(super) fn receive_completions( + &mut self, + generation: Option<u64>, + completions: crate::protocol::endpoint::EndpointAgentCompletions, + ) { + if self + .pending_completions + .as_ref() + .is_some_and(|(current_generation, current)| { + *current_generation == generation + && current.boot_id == completions.boot_id + && current.revision >= completions.revision + }) + { + return; + } + self.pending_completions = Some((generation, completions)); + } + + #[cfg(test)] pub(super) fn project_snapshot(&mut self, snapshot: &mut ClientShellSnapshot) { + self.project_snapshot_for_generation(snapshot, None); + } + + pub(super) fn project_snapshot_for_generation( + &mut self, + snapshot: &mut ClientShellSnapshot, + generation: Option<u64>, + ) { if self.boot_id.as_deref() != Some(snapshot.boot_id.as_str()) { self.boot_id = Some(snapshot.boot_id.clone()); self.acknowledged.clear(); + self.completed.clear(); + self.working.clear(); self.acknowledged.extend( snapshot .agents @@ -21,13 +57,59 @@ impl EndpointAgentPresentation { .map(|agent| (agent.pane_id.clone(), agent.state_change_seq)), ); } - self.acknowledged.retain(|pane_id, _| { - snapshot - .agents - .iter() - .any(|agent| &agent.pane_id == pane_id) - }); + let pane_ids: HashSet<&str> = snapshot + .agents + .iter() + .map(|agent| agent.pane_id.as_str()) + .collect(); + self.acknowledged + .retain(|pane_id, _| pane_ids.contains(pane_id.as_str())); + self.completed + .retain(|pane_id, _| pane_ids.contains(pane_id.as_str())); + self.working + .retain(|pane_id| pane_ids.contains(pane_id.as_str())); + let completions = self + .pending_completions + .take() + .filter(|(received_generation, projection)| { + *received_generation == generation + && projection.boot_id == snapshot.boot_id + && projection.revision == snapshot.revision + }) + .map(|(_, projection)| projection.completions); for agent in &mut snapshot.agents { + match agent.agent_status { + AgentStatus::Working => { + self.working.insert(agent.pane_id.clone()); + self.completed.remove(&agent.pane_id); + } + AgentStatus::Blocked => { + self.completed.remove(&agent.pane_id); + } + AgentStatus::Idle | AgentStatus::Done => { + let observed_work = self.working.remove(&agent.pane_id); + let completed = completions.as_ref().map_or_else( + || { + observed_work + || self.completed.get(&agent.pane_id) + == Some(&agent.state_change_seq) + }, + |completions| { + completions.get(&agent.pane_id) == Some(&agent.state_change_seq) + }, + ); + if completed { + self.completed + .insert(agent.pane_id.clone(), agent.state_change_seq); + } else { + self.completed.remove(&agent.pane_id); + } + } + _ => { + self.working.remove(&agent.pane_id); + self.completed.remove(&agent.pane_id); + } + } agent.agent_status = self.projected_status(agent); } project_aggregate_status(snapshot); @@ -72,9 +154,11 @@ impl EndpointAgentPresentation { } pub(super) fn seen(&self, agent: &ClientShellAgent) -> bool { - self.acknowledged - .get(&agent.pane_id) - .is_some_and(|sequence| *sequence >= agent.state_change_seq) + self.completed.get(&agent.pane_id).is_none_or(|completion| { + self.acknowledged + .get(&agent.pane_id) + .is_some_and(|sequence| sequence >= completion) + }) } fn projected_status(&self, agent: &ClientShellAgent) -> AgentStatus { @@ -119,7 +203,9 @@ fn project_aggregate_status(snapshot: &mut ClientShellSnapshot) { #[cfg(test)] mod tests { use super::*; - use crate::protocol::{FrameData, PaneSurfacePane, SurfaceRect}; + use crate::protocol::{ + endpoint::EndpointAgentCompletions, FrameData, PaneSurfacePane, SurfaceRect, + }; fn agent(status: AgentStatus, sequence: u64) -> ClientShellAgent { ClientShellAgent { @@ -197,6 +283,133 @@ mod tests { assert_eq!(snapshot.agents[0].agent_status, AgentStatus::Idle); } + fn assert_idle_sequence(states: &[(AgentStatus, u64)]) { + let mut presentation = EndpointAgentPresentation::default(); + let mut projected = AgentStatus::Unknown; + for (revision, &(status, seq)) in states.iter().enumerate() { + let mut snapshot = snapshot(status, seq, revision as u64 + 1); + presentation.project_snapshot(&mut snapshot); + projected = snapshot.agents[0].agent_status; + } + assert_eq!(projected, AgentStatus::Idle, "{states:?}"); + } + + fn completions(boot: &str, revision: u64, seq: Option<u64>) -> EndpointAgentCompletions { + EndpointAgentCompletions { + boot_id: boot.into(), + revision, + completions: seq + .map(|seq| ("agent-pane".into(), seq)) + .into_iter() + .collect(), + } + } + + #[test] + fn completion_guard_initial_unknown_to_idle_does_not_project_done() { + assert_idle_sequence(&[(AgentStatus::Unknown, 0), (AgentStatus::Idle, 1)]); + } + + #[test] + fn completion_guard_new_idle_agent_is_not_completed_work() { + let mut presentation = EndpointAgentPresentation::default(); + let mut baseline = snapshot(AgentStatus::Idle, 0, 1); + baseline.agents.clear(); + presentation.project_snapshot(&mut baseline); + let mut ready = snapshot(AgentStatus::Idle, 1, 2); + + presentation.project_snapshot(&mut ready); + + assert_eq!(ready.agents[0].agent_status, AgentStatus::Idle); + } + + #[test] + fn completion_guard_session_rebind_does_not_project_done() { + assert_idle_sequence(&[ + (AgentStatus::Idle, 4), + (AgentStatus::Unknown, 5), + (AgentStatus::Idle, 6), + ]); + } + + #[test] + fn completion_guard_startup_blocker_does_not_project_done() { + assert_idle_sequence(&[ + (AgentStatus::Unknown, 0), + (AgentStatus::Blocked, 1), + (AgentStatus::Idle, 2), + ]); + } + + #[test] + fn completion_guard_distinguishes_coalesced_work_from_rebind() { + for completion in [None, Some(6)] { + let mut presentation = EndpointAgentPresentation::default(); + let mut baseline = snapshot(AgentStatus::Idle, 4, 1); + presentation.project_snapshot(&mut baseline); + presentation.receive_completions(None, completions(&baseline.boot_id, 2, completion)); + let mut settled = snapshot(AgentStatus::Idle, 6, 2); + + presentation.project_snapshot(&mut settled); + + let expected = if completion.is_some() { + AgentStatus::Done + } else { + AgentStatus::Idle + }; + assert_eq!(settled.agents[0].agent_status, expected); + presentation.project_snapshot(&mut settled); + assert_eq!(settled.agents[0].agent_status, expected); + } + } + + #[test] + fn completion_guard_server_suppression_overrides_client_observed_work() { + let mut presentation = EndpointAgentPresentation::default(); + let mut baseline = snapshot(AgentStatus::Working, 1, 1); + presentation.project_snapshot(&mut baseline); + presentation.receive_completions(None, completions(&baseline.boot_id, 2, None)); + let mut settled = snapshot(AgentStatus::Idle, 2, 2); + + presentation.project_snapshot(&mut settled); + + assert_eq!(settled.agents[0].agent_status, AgentStatus::Idle); + } + + #[test] + fn completion_guard_companion_is_scoped_to_boot_revision_and_connection() { + for (boot, revision, generation, seq) in [ + ("old-boot", 2, Some(1), 6), + ("endpoint-boot", 1, Some(1), 6), + ("endpoint-boot", 2, Some(0), 6), + ("endpoint-boot", 2, Some(1), 5), + ] { + let mut presentation = EndpointAgentPresentation::default(); + let mut baseline = snapshot(AgentStatus::Idle, 4, 1); + presentation.project_snapshot_for_generation(&mut baseline, Some(1)); + presentation.receive_completions(generation, completions(boot, revision, Some(seq))); + let mut settled = snapshot(AgentStatus::Idle, 6, 2); + + presentation.project_snapshot_for_generation(&mut settled, Some(1)); + + assert_eq!(settled.agents[0].agent_status, AgentStatus::Idle); + } + } + + #[test] + fn completion_guard_new_boot_baselines_existing_completions() { + let mut presentation = EndpointAgentPresentation::default(); + let mut old = snapshot(AgentStatus::Working, 1, 1); + old.boot_id = "old-boot".into(); + presentation.project_snapshot(&mut old); + presentation.receive_completions(None, completions("endpoint-boot", 1, Some(6))); + let mut restored = snapshot(AgentStatus::Idle, 6, 1); + + presentation.project_snapshot(&mut restored); + + assert_eq!(restored.agents[0].agent_status, AgentStatus::Idle); + } + #[test] fn unpresented_working_completion_projects_done() { let mut presentation = EndpointAgentPresentation::default(); diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index 513842ba..dd6fa3cb 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -309,6 +309,23 @@ impl ClientShellState { .map(|snapshot| (snapshot.boot_id.as_str(), snapshot.revision)) } + pub(crate) fn set_endpoint_agent_completions( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + projection: crate::protocol::endpoint::EndpointAgentCompletions, + ) { + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint + .agent_presentation + .receive_completions(Some(generation), projection); + } + } + pub(crate) fn set_endpoint_agent_view_projection_for_generation( &mut self, endpoint_id: &ClientEndpointId, @@ -546,7 +563,7 @@ impl ClientShellState { } self.endpoints[index] .agent_presentation - .project_snapshot(&mut snapshot); + .project_snapshot_for_generation(&mut snapshot, generation); let presented_surface = if acknowledge_surface && endpoint_id == &self.active_endpoint_id { self.pane_surface.as_ref() } else { diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs index df83b242..2704bf65 100644 --- a/src/protocol/endpoint.rs +++ b/src/protocol/endpoint.rs @@ -29,6 +29,15 @@ pub const HEALTH_PING_KIND: &str = "endpoint.health.ping.v1"; pub const HEALTH_PONG_KIND: &str = "endpoint.health.pong.v1"; pub const AGENT_VIEW_PROJECTION_CAPABILITY: &str = "agent_view_projection"; pub const AGENT_VIEW_PROJECTION_KIND: &str = "endpoint.agent-view.v1"; +pub const AGENT_COMPLETIONS_CAPABILITY: &str = "agent_completions"; +pub const AGENT_COMPLETIONS_KIND: &str = "endpoint.agent-completions.v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndpointAgentCompletions { + pub boot_id: String, + pub revision: u64, + pub completions: std::collections::BTreeMap<String, u64>, +} fn default_true() -> bool { true @@ -101,6 +110,15 @@ pub fn snapshot_message(snapshot: &ClientShellSnapshot) -> serde_json::Result<Se }) } +pub fn agent_completions_message( + projection: &EndpointAgentCompletions, +) -> serde_json::Result<ServerMessage> { + Ok(ServerMessage::EndpointControl { + kind: AGENT_COMPLETIONS_KIND.into(), + data: serde_json::to_string(projection)?, + }) +} + pub fn agent_view_projection_message( boot_id: &str, revision: u64, @@ -151,6 +169,7 @@ impl EndpointServerWelcome { PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), HEALTH_CHECK_CAPABILITY.into(), AGENT_VIEW_PROJECTION_CAPABILITY.into(), + AGENT_COMPLETIONS_CAPABILITY.into(), ], error: None, } @@ -357,6 +376,7 @@ mod tests { PRESENTATION_EFFECTS_FENCE_CAPABILITY.to_string(), HEALTH_CHECK_CAPABILITY.to_string(), AGENT_VIEW_PROJECTION_CAPABILITY.to_string(), + AGENT_COMPLETIONS_CAPABILITY.to_string(), ] ); } diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index 37470693..554e8167 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -3,6 +3,7 @@ use ratatui::layout::Rect; use crate::app; use crate::protocol::{self, FrameData}; +#[cfg(test)] pub(super) fn snapshot( app: &app::App, boot_id: &str, @@ -10,7 +11,29 @@ pub(super) fn snapshot( config_diagnostic: Option<&str>, location: Option<&crate::server::clients::ClientShellLocation>, ) -> protocol::ClientShellSnapshot { + snapshot_with_completions(app, boot_id, revision, config_diagnostic, location).0 +} + +pub(super) fn snapshot_with_completions( + app: &app::App, + boot_id: &str, + revision: u64, + config_diagnostic: Option<&str>, + location: Option<&crate::server::clients::ClientShellLocation>, +) -> ( + protocol::ClientShellSnapshot, + protocol::endpoint::EndpointAgentCompletions, +) { let snapshot = app.session_snapshot(); + let completions = protocol::endpoint::EndpointAgentCompletions { + boot_id: boot_id.to_owned(), + revision, + completions: snapshot + .agents + .iter() + .filter_map(|agent| agent.completion_seq.map(|seq| (agent.pane_id.clone(), seq))) + .collect(), + }; let focused_workspace_id = location .and_then(|location| location.focused_workspace_id.clone()) .or_else(|| snapshot.focused_workspace_id.clone()); @@ -216,7 +239,7 @@ pub(super) fn snapshot( preview: notes.preview, }); - protocol::ClientShellSnapshot { + let shell = protocol::ClientShellSnapshot { boot_id: boot_id.to_owned(), revision, config_diagnostic: config_diagnostic.map(str::to_owned), @@ -240,7 +263,8 @@ pub(super) fn snapshot( panes, agents, commands: app.client_shell_command_manifest(), - } + }; + (shell, completions) } pub(super) struct RenderedPaneSurface { diff --git a/src/server/clients.rs b/src/server/clients.rs index aeda3822..65e875b8 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -177,6 +177,7 @@ pub(crate) struct ClientConnection { pub(crate) shell_location: Option<ClientShellLocation>, /// Last coherent shell replacement sent to this client. pub(crate) shell_snapshot: Option<crate::protocol::ClientShellSnapshot>, + pub(crate) shell_agent_completions: Option<crate::protocol::endpoint::EndpointAgentCompletions>, /// View policy paired with the last coherent shell replacement. pub(crate) shell_agent_view: Option<crate::api::schema::AgentViewSetParams>, /// Monotonic shell replacement revision for this connection. @@ -246,6 +247,7 @@ impl ClientConnection { staged_clipboard_files: Vec::new(), shell_location: None, shell_snapshot: None, + shell_agent_completions: None, shell_agent_view: None, shell_projection_revision: 0, shell_endpoint_command_in_flight: false, diff --git a/src/server/headless.rs b/src/server/headless.rs index 638559ad..c5a644ce 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -51,7 +51,8 @@ use crate::server::client_accept::{ accept_pending_client_connections, reject_pending_client_connections, }; use crate::server::client_shell::{ - render_pane_surface as render_client_shell_pane_surface, snapshot as client_shell_snapshot, + render_pane_surface as render_client_shell_pane_surface, + snapshot_with_completions as client_shell_snapshot, }; use crate::server::client_transport::ServerEvent; use crate::server::clients::{ @@ -2041,7 +2042,7 @@ impl HeadlessServer { } else { self.server_config_diagnostic_without_keybindings.as_deref() }; - let seed_snapshot = client_shell_snapshot( + let (seed_snapshot, completion_projection) = client_shell_snapshot( &self.app, &self.client_shell_boot_id, connection.shell_projection_revision, @@ -2073,8 +2074,18 @@ impl HeadlessServer { return false; } }; + let completion_message = match crate::protocol::endpoint::agent_completions_message( + &completion_projection, + ) { + Ok(message) => message, + Err(err) => { + warn!(client_id, err = %err, "failed to encode agent completions"); + return false; + } + }; connection.shell_location = Some(location); connection.shell_snapshot = Some(seed_snapshot); + connection.shell_agent_completions = Some(completion_projection); connection.shell_agent_view = agent_view; self.clients.insert(client_id, connection); if self.app.state.popup_pane.is_some() && self.popup_owner_tab_id.is_none() { @@ -2083,6 +2094,7 @@ impl HeadlessServer { if let Some(message) = projection_message { self.send_to_client(client_id, message); } + self.send_to_client(client_id, completion_message); self.send_to_client(client_id, snapshot_message); if surface_active { self.foreground_client_id = Some(client_id); @@ -3241,7 +3253,10 @@ impl HeadlessServer { }; let new_state = terminal_after.state; - if new_state == *prev_state { + if new_state == *prev_state + || (new_state == crate::detect::AgentState::Idle + && terminal_after.last_agent_completion_seq.is_none()) + { continue; } @@ -3274,15 +3289,11 @@ impl HeadlessServer { && self.app.state.toast_config.delay_seconds == 0 && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { - if let Some(kind) = - crate::app::actions::notification_toast_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - *prev_state, - new_state, - prev_agent_label.as_deref(), - agent_label.as_deref(), - ) - { + if let Some(kind) = crate::app::actions::notification_toast_for_state_change( + suppress_active_tab_notifications, + *prev_state, + new_state, + ) { if let Some(agent_label) = self .app .state @@ -3319,15 +3330,11 @@ impl HeadlessServer { // Clients still decide locally whether they can execute the side effect. if self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - *prev_state, - new_state, - prev_agent_label.as_deref(), - agent_label.as_deref(), - ) - { + if let Some(sound) = crate::app::actions::notification_sound_for_state_change( + suppress_active_tab_notifications, + *prev_state, + new_state, + ) { debug!(sound = ?sound, "forwarding sound notification from API request"); self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index 722f95c5..a575cfe1 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -19,20 +19,6 @@ impl HeadlessServer { .unwrap_or(crate::detect::AgentState::Unknown) } - fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option<String> { - self.app.state.workspaces.iter().find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - let pane = tab.panes.get(&pane_id)?; - self.app - .state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label()) - .map(str::to_string) - }) - }) - } - fn forward_semantic_agent_notification( &mut self, update: &crate::app::actions::PaneStateUpdate, @@ -61,13 +47,9 @@ impl HeadlessServer { agent_label: Option<&str>, known_agent: Option<crate::detect::Agent>, ) -> bool { - let Some(kind) = crate::app::actions::notification_toast_for_state_change_with_agent_labels( - false, - previous_state, - state, - previous_agent_label, - agent_label, - ) else { + let Some(kind) = + crate::app::actions::notification_toast_for_state_change(false, previous_state, state) + else { return false; }; let Some(workspace) = self.app.state.workspaces.get(ws_idx) else { @@ -142,15 +124,11 @@ impl HeadlessServer { self.active_tab_suppresses_notifications(is_active_tab); if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - update.previous_state, - update.state, - update.previous_agent_label.as_deref(), - update.agent_label.as_deref(), - ) - { + if let Some(sound) = crate::app::actions::notification_sound_for_state_change( + suppress_active_tab_notifications, + update.previous_state, + update.state, + ) { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), @@ -360,11 +338,8 @@ impl HeadlessServer { let pane_id_val = *pane_id; let agent_val = *agent; - // Find the previous effective state of this pane before the event - // is processed. Notifications must follow effective state changes, - // not raw fallback reports that may be masked by hook authority. + // Notifications follow effective changes, not fallback reports masked by hooks. let prev_state = self.pane_effective_state(pane_id_val); - let prev_agent_label = self.pane_effective_agent_label(pane_id_val); // Handle the state change (updates pane state, sets toast on AppState). // Headless mode disables local sound playback separately from the @@ -396,21 +371,16 @@ impl HeadlessServer { self.active_tab_suppresses_notifications(is_active_tab); let next_state = self.pane_effective_state(pane_id_val); - let next_agent_label = self.pane_effective_agent_label(pane_id_val); if !suppress_completion && self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - next_agent_label.as_deref(), - ) - { + if let Some(sound) = crate::app::actions::notification_sound_for_state_change( + suppress_active_tab_notifications, + prev_state, + next_state, + ) { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), @@ -437,7 +407,6 @@ impl HeadlessServer { suppress_active_tab_notifications, prev_state, next_state, - prev_agent_label.as_deref(), ) } } else { @@ -465,11 +434,8 @@ impl HeadlessServer { let pane_id_val = *pane_id; let agent_val = crate::detect::parse_agent_label(agent_label); - // Capture the previous effective state for this pane. Hook reports - // are already folded into pane.state; raw hook transitions must not - // produce a second notification path. + // Hook reports are already folded into the effective state. let prev_state = self.pane_effective_state(pane_id_val); - let prev_agent_label = self.pane_effective_agent_label(pane_id_val); self.sync_foreground_client_state(); let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); @@ -499,21 +465,16 @@ impl HeadlessServer { self.active_tab_suppresses_notifications(is_active_tab); let next_state = self.pane_effective_state(pane_id_val); - let next_agent_label = self.pane_effective_agent_label(pane_id_val); if !suppress_completion && self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - next_agent_label.as_deref(), - ) - { + if let Some(sound) = crate::app::actions::notification_sound_for_state_change( + suppress_active_tab_notifications, + prev_state, + next_state, + ) { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), @@ -540,7 +501,6 @@ impl HeadlessServer { suppress_active_tab_notifications, prev_state, next_state, - prev_agent_label.as_deref(), ) } } else { diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index dd2cce38..82564ba3 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -483,7 +483,7 @@ impl HeadlessServer { let Some(client) = self.clients.get_mut(&client_id) else { continue; }; - let mut candidate = client_shell_snapshot( + let (mut candidate, mut completions) = client_shell_snapshot( &self.app, &self.client_shell_boot_id, client.shell_projection_revision, @@ -497,11 +497,26 @@ impl HeadlessServer { }; candidate.revision = client.shell_projection_revision; if client.shell_snapshot.as_ref() != Some(&candidate) + || client.shell_agent_completions.as_ref() != Some(&completions) || client.shell_agent_view != agent_view { client.shell_projection_revision = client.shell_projection_revision.saturating_add(1); candidate.revision = client.shell_projection_revision; + completions.revision = candidate.revision; + let completion_framed = + match crate::protocol::endpoint::agent_completions_message(&completions) + .map_err(std::io::Error::other) + .and_then(|message| { + Self::frame_server_message(&message).map_err(std::io::Error::other) + }) { + Ok(message) => message, + Err(err) => { + warn!(client_id, err = %err, "failed to frame agent completions"); + broken_clients.push(client_id); + continue; + } + }; let projection_message = if agent_view.is_some() || client.shell_agent_view.is_some() { @@ -554,12 +569,14 @@ impl HeadlessServer { continue; }; if projection_framed.is_some_and(|framed| writer.control.send(framed).is_err()) + || writer.control.send(completion_framed).is_err() || writer.control.send(snapshot_framed).is_err() { broken_clients.push(client_id); continue; } client.shell_snapshot = Some(candidate); + client.shell_agent_completions = Some(completions); client.shell_agent_view = agent_view; } shell_projection_revision = client.shell_projection_revision; 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(); diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 0b7a8095..3e36a8fc 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -56,7 +56,8 @@ async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once #[tokio::test] async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() { - let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"client shell graphics"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = @@ -92,7 +93,8 @@ async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() { #[tokio::test] async fn first_kitty_image_updates_retained_surface_without_full_redraw() { - let (mut server, client_rx, pane_id) = retained_test_server(b"text before image"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"text before image"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = @@ -183,7 +185,8 @@ async fn first_kitty_image_updates_retained_surface_without_full_redraw() { #[tokio::test] async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload() { - let (mut server, client_rx, pane_id) = retained_test_server(b"\x1b[?1049h"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"\x1b[?1049h"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.cell_size = crate::kitty_graphics::HostCellSize { @@ -260,7 +263,8 @@ async fn render_scale_profile_retained_graphics() { for retained in [false, true] { for with_image in [false, true] { for count in [1, 15] { - let (mut server, client_rx, root) = retained_test_server(b"populated terminal\r\n"); + let (mut server, _control_rx, client_rx, root) = + retained_test_server_with_control(b"populated terminal\r\n"); let mut pane_ids = vec![root]; for index in 1..count { let workspace = &mut server.app.state.workspaces[0]; @@ -336,8 +340,9 @@ async fn render_scale_profile_retained_graphics() { #[tokio::test] async fn client_shell_surface_projects_terminal_kitty_images_from_authoritative_runtime() { - let (mut server, client_rx, _pane_id) = - retained_test_server(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\"); + let (mut server, _control_rx, client_rx, _pane_id) = retained_test_server_with_control( + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = @@ -366,7 +371,7 @@ async fn client_shell_surface_projects_terminal_kitty_images_from_authoritative_ #[tokio::test] async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { - let (mut server, client_rx, _pane_id) = retained_test_server( + let (mut server, _control_rx, client_rx, _pane_id) = retained_test_server_with_control( b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\\x1b_Ga=T,f=32,t=d,i=8,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", ); let client = server.clients.get_mut(&1).unwrap(); @@ -406,7 +411,8 @@ async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { #[tokio::test] async fn full_client_shell_render_lane_does_not_commit_graphics_delivery() { - let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"client shell graphics"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = @@ -975,7 +981,8 @@ fn resident_direct_stream_survives_non_direct_client_becoming_foreground() { #[cfg(unix)] #[tokio::test] async fn client_shell_direct_graphics_uploads_without_server_authored_coordinates() { - let (mut server, client_rx, pane_id) = retained_test_server(b"client shell direct"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"client shell direct"); server.app.state.kitty_graphics_enabled = true; let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; diff --git a/src/server/headless/tests/pane_move.rs b/src/server/headless/tests/pane_move.rs index c18d0f1f..789b144f 100644 --- a/src/server/headless/tests/pane_move.rs +++ b/src/server/headless/tests/pane_move.rs @@ -55,7 +55,7 @@ async fn public_pane_move_focus_follows_the_moved_pane() { let source_id = server.app.public_pane_id(0, source).unwrap(); let destination_id = server.app.public_workspace_id(1); let (control_rx, render_rx) = connect_test_shell(&mut server, 9, 80, 23); - let initial = client_shell_snapshot(read_server_message(control_rx.recv().unwrap())); + let initial = client_shell_snapshot(&control_rx); let moved = public_move( &mut server, @@ -81,7 +81,7 @@ async fn public_pane_move_focus_follows_the_moved_pane() { assert_eq!(location.focused_tab_id(), Some(moved.pane.tab_id.as_str())); server.render_and_stream(); - let snapshot = client_shell_snapshot(read_server_message(control_rx.recv().unwrap())); + let snapshot = client_shell_snapshot(&control_rx); assert!(snapshot.revision > initial.revision); assert_eq!( snapshot.focused_workspace_id.as_deref(), diff --git a/src/server/headless/tests/surface_interest.rs b/src/server/headless/tests/surface_interest.rs index 7c8b33f8..f3acc27b 100644 --- a/src/server/headless/tests/surface_interest.rs +++ b/src/server/headless/tests/surface_interest.rs @@ -79,10 +79,7 @@ async fn metadata_only_shell_is_isolated_until_surface_activation() { writer, }) ); - assert!(matches!( - read_server_message(control_rx.recv().expect("metadata snapshot")), - ServerMessage::EndpointControl { .. } - )); + let _ = client_shell_snapshot(&control_rx); assert_eq!(server.foreground_client_id, None); assert_eq!(server.effective_size, original_size); @@ -410,7 +407,7 @@ async fn presentation_sync_epoch_replays_modes_and_title() { writer, }) ); - let _ = control_rx.recv().expect("initial snapshot"); + let _ = client_shell_snapshot(&control_rx); server.api_window_title = Some("target title".into()); { let client = server.clients.get_mut(&client_id).unwrap(); @@ -526,11 +523,7 @@ async fn two_headless_servers_drive_atomic_endpoint_handoff() { writer: source_writer, }) ); - let source_snapshot = client_shell_snapshot(read_server_message( - source_control - .recv() - .expect("real source metadata snapshot"), - )); + let source_snapshot = client_shell_snapshot(&source_control); let mut target_server = test_headless_server(); let _target_input = install_focused_test_runtime(&mut target_server, b"remote target"); @@ -553,11 +546,7 @@ async fn two_headless_servers_drive_atomic_endpoint_handoff() { writer: target_writer, }) ); - let remote_snapshot = client_shell_snapshot(read_server_message( - target_control - .recv() - .expect("real target metadata snapshot"), - )); + let remote_snapshot = client_shell_snapshot(&target_control); let profile = SavedSshEndpoint { id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), @@ -685,9 +674,7 @@ async fn two_headless_servers_drive_atomic_endpoint_handoff() { ); target_server.render_and_stream(); - let coherent_snapshot = client_shell_snapshot(read_server_message( - target_control.recv().expect("target replacement snapshot"), - )); + let coherent_snapshot = client_shell_snapshot(&target_control); let snapshot_progress = activation.receive_snapshot(&target_id, 7, &coherent_snapshot); shell.set_endpoint_snapshot(&target_id, coherent_snapshot); assert_eq!( diff --git a/src/server/notifications.rs b/src/server/notifications.rs index 4ce33a24..724156b8 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -25,7 +25,6 @@ pub(crate) fn toast_message_from_state_change( suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, - previous_agent_label: Option<&str>, ) -> Option<String> { state .workspaces @@ -38,12 +37,10 @@ pub(crate) fn toast_message_from_state_change( .terminals .get(&pane.attached_terminal_id) .and_then(|terminal| terminal.effective_agent_label())?; - let kind = app::actions::notification_toast_for_state_change_with_agent_labels( + let kind = app::actions::notification_toast_for_state_change( suppress_active_tab_notifications, prev_state, new_state, - previous_agent_label, - Some(agent_label), )?; let workspace_label = ws.display_name_from(&state.terminals, terminal_runtimes); Some(format!( @@ -142,7 +139,6 @@ mod tests { false, AgentState::Working, AgentState::Idle, - Some("codex"), ); assert_eq!( diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs index 5c3935de..dcafa1a9 100644 --- a/src/server/render_scale_benchmark.rs +++ b/src/server/render_scale_benchmark.rs @@ -233,13 +233,36 @@ fn profile_snapshot_encoding( count: usize, client_count: usize, ) -> StageStats { - let pipeline = RenderPipeline::new(build(count)); + let mut pipeline = RenderPipeline::new(build(count)); + pipeline.app.state.ensure_test_terminals(); + for terminal in pipeline.app.state.terminals.values_mut() { + terminal.set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + terminal.last_agent_state_change_seq = Some(1); + terminal.last_agent_completion_seq = Some(1); + } let run = || { let started = Instant::now(); - let template = super::client_shell::snapshot(&pipeline.app, "bench-boot", 1, None, None); + let (template, completions) = super::client_shell::snapshot_with_completions( + &pipeline.app, + "bench-boot", + 1, + None, + None, + ); for client_index in 0..client_count { let mut snapshot = template.clone(); snapshot.revision = client_index as u64 + 1; + let mut completions = completions.clone(); + completions.revision = snapshot.revision; + let companion = crate::protocol::endpoint::agent_completions_message(&completions) + .expect("benchmark completion projection should serialize"); + black_box( + bincode::serde::encode_to_vec(companion, bincode::config::standard()) + .expect("benchmark completion projection should frame"), + ); let message = crate::protocol::endpoint::snapshot_message(&snapshot) .expect("benchmark snapshot should serialize"); black_box( @@ -256,7 +279,7 @@ fn profile_snapshot_encoding( } fn print_snapshot_encoding_profiles(label: &str, build: fn(usize) -> Vec<Workspace>) { - println!("{label} snapshot projection + JSON framing"); + println!("{label} populated agent snapshots + completions + JSON framing"); println!(" panes clients median_us p95_us max_us"); for count in CARDINALITIES { for client_count in CLIENT_CARDINALITIES { diff --git a/src/terminal/state.rs b/src/terminal/state.rs index c084b034..119916c8 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -142,6 +142,7 @@ pub struct TerminalState { metadata_token_sequence_sources: std::collections::HashSet<String>, pub state: AgentState, pub last_agent_state_change_seq: Option<u64>, + pub last_agent_completion_seq: Option<u64>, pub revision: u64, pub launch_argv: Option<Vec<String>>, pub respawn_shell_on_exit: bool, @@ -178,6 +179,7 @@ impl TerminalState { metadata_token_sequence_sources: std::collections::HashSet::new(), state: AgentState::Unknown, last_agent_state_change_seq: None, + last_agent_completion_seq: None, revision: 0, launch_argv: None, respawn_shell_on_exit: false, @@ -584,14 +586,18 @@ impl TerminalState { if agent.is_none() && self.recent_agent_process_exit.is_some() { self.clear_agent_name(); } + let effective_state_change = self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + now, + ); + if fallback_state == AgentState::Working && self.state == AgentState::Working { + self.agent_process_acquisition_pending = false; + } TerminalStateMutation { - effective_state_change: self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - previous_presentation, - now, - ), + effective_state_change, session_ref_changed: previous_session != self.current_session_identity_for_persistence(), agent_released, @@ -742,14 +748,18 @@ impl TerminalState { session_ref, }); let current_session = self.current_session_identity_for_persistence(); + let effective_state_change = self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + now, + ); + if state == AgentState::Working && self.state == AgentState::Working { + self.agent_process_acquisition_pending = false; + } Some(TerminalStateMutation { - effective_state_change: self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - previous_presentation, - now, - ), + effective_state_change, session_ref_changed: previous_session != current_session, agent_released: false, }) @@ -1615,6 +1625,10 @@ impl TerminalState { } self.persisted_agent_session = Some(persisted_session); let current_session = self.current_session_identity_for_persistence(); + if previous_session.is_some() && previous_session != current_session { + // Rebinding can expose a cached Working screen; only a fresh report ends acquisition. + self.agent_process_acquisition_pending = true; + } Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( previous_agent_label, @@ -1925,6 +1939,7 @@ impl TerminalState { timeout: Duration, ) { self.set_agent_name(name); + self.agent_process_acquisition_pending = true; self.agent_name_owner = Some(AgentNameOwner { agent_label: crate::detect::agent_label(kind).to_string(), session_ref: None, @@ -2092,6 +2107,7 @@ impl TerminalState { self.stale_full_lifecycle_hook_sessions.clear(); self.state = AgentState::Unknown; self.last_agent_state_change_seq = None; + self.last_agent_completion_seq = None; self.launch_argv = None; self.respawn_shell_on_exit = false; self.recent_agent_process_exit = None; |
