diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-11 16:13:17 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-11 16:13:17 +0300 |
| commit | 55cdf2e8f8af2dd3feebb4bb7d44c9cb7e23b384 (patch) | |
| tree | 91feb43a39812d63e19fc5068f95dbe0c896e118 /src/server | |
| parent | e201a647635905a5141bfce335619b0e232b396d (diff) | |
fix: stabilize integration agent state reports
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/headless.rs | 366 |
1 files changed, 189 insertions, 177 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs index 7bd8638a..bd1bf55e 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -689,6 +689,19 @@ impl HeadlessServer { changed } + fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { + self.app + .state + .workspaces + .iter() + .find_map(|ws| { + ws.tabs + .iter() + .find_map(|tab| tab.panes.get(&pane_id).map(|pane| pane.state)) + }) + .unwrap_or(crate::detect::AgentState::Unknown) + } + /// Handles a single internal event with forwarding logic for clipboard, /// sound, and toast notifications to connected clients. /// @@ -710,31 +723,16 @@ impl HeadlessServer { // ClipboardWrite doesn't change visual state — no render needed. false } - AppEvent::StateChanged { - pane_id, - agent, - state, - } => { + AppEvent::StateChanged { pane_id, agent, .. } => { // Capture toast before handling. let toast_before = self.app.state.toast.clone(); let pane_id_val = *pane_id; let agent_val = *agent; - let state_val = *state; - // Find the previous state of this pane before the event - // is processed. We need this to determine if a sound - // notification would be triggered. - let prev_state = self - .app - .state - .workspaces - .iter() - .find_map(|ws| { - ws.tabs - .iter() - .find_map(|tab| tab.panes.get(&pane_id_val).map(|p| p.state)) - }) - .unwrap_or(crate::detect::AgentState::Unknown); + // 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. + let prev_state = self.pane_effective_state(pane_id_val); // Handle the state change (updates pane state, sets toast on AppState). // Headless mode disables local sound playback separately from the @@ -756,11 +754,13 @@ impl HeadlessServer { let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); + let next_state = self.pane_effective_state(pane_id_val); + if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( suppress_active_tab_notifications, prev_state, - state_val, + next_state, ) { let msg = match sound { crate::sound::Sound::Done => "agent done", @@ -787,7 +787,7 @@ impl HeadlessServer { pane_id_val, suppress_active_tab_notifications, prev_state, - state_val, + next_state, ) } } else { @@ -806,43 +806,24 @@ impl HeadlessServer { AppEvent::HookStateReported { pane_id, agent_label, - state, .. } => { - // The hook authority may not change the effective pane state if - // the detected agent doesn't match. We forward based on the - // hook-reported state transition regardless. + // Hook reports can be stale or no-op after sequence rejection. + // Forward only effective state changes observed after handling. let toast_before = self.app.state.toast.clone(); let pane_id_val = *pane_id; let agent_val = crate::detect::parse_agent_label(agent_label); - let hook_state_val = *state; - // Capture the previous hook authority state for this pane. - // If no hook authority exists, use the effective state. - let prev_hook_state = self - .app - .state - .workspaces - .iter() - .find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - tab.panes.get(&pane_id_val).map(|p| { - p.hook_authority - .as_ref() - .map(|h| h.state) - .unwrap_or(p.state) - }) - }) - }) - .unwrap_or(crate::detect::AgentState::Unknown); + // 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. + let prev_state = self.pane_effective_state(pane_id_val); self.sync_foreground_client_state(); self.app.handle_internal_event(ev); - // Forward sound notification based on hook state transition when - // server-side sound policy allows it. This ensures API-reported state - // changes (pane.report_agent) produce notifications even before - // fallback detection confirms. + // Forward sound notification based on the effective transition when + // server-side sound policy allows it. let is_active_tab = self .app .state @@ -856,11 +837,13 @@ impl HeadlessServer { let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); + let next_state = self.pane_effective_state(pane_id_val); + if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( suppress_active_tab_notifications, - prev_hook_state, - hook_state_val, + prev_state, + next_state, ) { let msg = match sound { crate::sound::Sound::Done => "agent done", @@ -886,8 +869,8 @@ impl HeadlessServer { &self.app.state, pane_id_val, suppress_active_tab_notifications, - prev_hook_state, - hook_state_val, + prev_state, + next_state, ) } } else { @@ -1228,42 +1211,26 @@ impl HeadlessServer { let changed = api::request_changes_ui(&msg.request); - // Capture toast and pane agent states before the API call so we can - // forward any resulting notifications to connected clients. - // API requests like pane.report_agent trigger handle_internal_event - // internally, which bypasses drain_internal_events_with_forwarding. - // Headless mode disables local sound playback, so sound notifications - // need to be forwarded to clients here; toasts may be set but not forwarded. - // - // Note: pane.report_agent sets hook_authority on the pane, but the - // effective state may not change until the fallback detector confirms - // the agent (detected_agent must match). So we capture both the - // effective state AND the hook authority state for comparison. + // Capture toast and effective pane states before the API call so we can + // forward resulting client-local notifications. API requests like + // pane.report_agent trigger handle_internal_event internally, which + // bypasses drain_internal_events_with_forwarding. Headless mode disables + // local sound playback, so sound notifications need to be forwarded here. let toast_before = self.app.state.toast.clone(); - let pane_states_before: Vec<( - usize, - crate::layout::PaneId, - crate::detect::AgentState, - Option<crate::detect::AgentState>, - )> = self - .app - .state - .workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, ws)| { - ws.tabs.iter().flat_map(move |tab| { - tab.panes.iter().map(move |(&pane_id, pane)| { - ( - ws_idx, - pane_id, - pane.state, - pane.hook_authority.as_ref().map(|h| h.state), - ) + let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> = + self.app + .state + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, ws)| { + ws.tabs.iter().flat_map(move |tab| { + tab.panes + .iter() + .map(move |(&pane_id, pane)| (ws_idx, pane_id, pane.state)) }) }) - }) - .collect(); + .collect(); self.sync_foreground_client_state(); let response = self.app.handle_api_request(msg.request); @@ -1293,16 +1260,10 @@ impl HeadlessServer { false }; - // Forward sound notifications for any pane state changes that occurred - // during the API request. Compare before/after pane states (including - // hook authority state) to find transitions that would trigger a sound. - // - // pane.report_agent sets hook_authority on the pane, but the effective - // state may not change until the fallback detector confirms the agent - // (detected_agent must match hook_authority.agent). We check BOTH the - // effective state AND the hook authority state so that API-reported - // state changes trigger notifications even before fallback confirmation. - for (ws_idx, pane_id, prev_effective_state, prev_hook_state) in &pane_states_before { + // Forward notifications for effective pane state changes that occurred + // during the API request. Hook authority is already folded into + // pane.state, so raw hook transitions must not produce separate sounds. + for (ws_idx, pane_id, prev_state) in &pane_states_before { let pane_after = self .app .state @@ -1314,97 +1275,77 @@ impl HeadlessServer { continue; }; - let new_effective_state = pane_after.state; - let new_hook_state = pane_after.hook_authority.as_ref().map(|h| h.state); - - // Check effective state change first. - let effective_changed = new_effective_state != *prev_effective_state; - // Check hook authority state change — this catches API-reported - // state changes that haven't been confirmed by fallback detection. - let hook_changed = new_hook_state != *prev_hook_state; + let new_state = pane_after.state; + if new_state == *prev_state { + continue; + } - if effective_changed || hook_changed { - // Use the hook state if available (it reflects the API-reported - // state), otherwise use the effective state. - let prev_state = prev_hook_state.unwrap_or(*prev_effective_state); - let new_state = new_hook_state.unwrap_or(new_effective_state); + let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id); + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); - // Skip if the derived transition is a no-op. - if prev_state == new_state { - continue; - } + let agent = pane_after.effective_known_agent(); - let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id); - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - // Get the known agent for sound settings. Unknown custom labels - // fall back to None so clients use the generic sound behavior. - let agent = pane_after.effective_known_agent(); - - debug!( - ws_idx, - pane_id = pane_id.raw(), - prev_state = ?prev_state, - new_state = ?new_state, - agent = ?agent, - effective_changed, - hook_changed, - "pane state changed during API request, checking sound notification" - ); - - if !forwarded_toast_from_state - && should_forward_toast_to_clients(self.app.state.toast_config.delivery) - { - 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) = pane_after.effective_agent_label() { - let event_text = match kind { - crate::app::state::ToastKind::NeedsAttention => "needs attention", - crate::app::state::ToastKind::Finished => "finished", - crate::app::state::ToastKind::UpdateInstalled => "updated", - }; - let msg_text = format!( - "{} {}: {}", - agent_label, - event_text, - crate::app::actions::notification_context( - &self.app.state.workspaces[*ws_idx], - *ws_idx, - *pane_id, - ) - ); - self.send_to_foreground_client(ServerMessage::Notify { - kind: protocol::NotifyKind::Toast, - message: msg_text, - }); - } - } - } + debug!( + ws_idx, + pane_id = pane_id.raw(), + prev_state = ?prev_state, + new_state = ?new_state, + agent = ?agent, + "pane effective state changed during API request, checking notification" + ); - // Forward sound notification when server-side sound policy allows it. - // Clients still decide locally whether they can execute the side effect. - if self.app.state.sound.allows(agent) { - if let Some(sound) = crate::app::actions::notification_sound_for_state_change( - suppress_active_tab_notifications, - prev_state, - new_state, - ) { - let msg_text = match sound { - crate::sound::Sound::Done => "agent done", - crate::sound::Sound::Request => "agent attention", + if !forwarded_toast_from_state + && should_forward_toast_to_clients(self.app.state.toast_config.delivery) + { + 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) = pane_after.effective_agent_label() { + let event_text = match kind { + crate::app::state::ToastKind::NeedsAttention => "needs attention", + crate::app::state::ToastKind::Finished => "finished", + crate::app::state::ToastKind::UpdateInstalled => "updated", }; - debug!(sound = ?sound, "forwarding sound notification from API request"); + let msg_text = format!( + "{} {}: {}", + agent_label, + event_text, + crate::app::actions::notification_context( + &self.app.state.workspaces[*ws_idx], + *ws_idx, + *pane_id, + ) + ); self.send_to_foreground_client(ServerMessage::Notify { - kind: protocol::NotifyKind::Sound, - message: msg_text.to_owned(), + kind: protocol::NotifyKind::Toast, + message: msg_text, }); } } } + + // Forward sound notification when server-side sound policy allows it. + // Clients still decide locally whether they can execute the side effect. + if self.app.state.sound.allows(agent) { + if let Some(sound) = crate::app::actions::notification_sound_for_state_change( + suppress_active_tab_notifications, + *prev_state, + new_state, + ) { + let msg_text = match sound { + crate::sound::Sound::Done => "agent done", + crate::sound::Sound::Request => "agent attention", + }; + debug!(sound = ?sound, "forwarding sound notification from API request"); + self.send_to_foreground_client(ServerMessage::Notify { + kind: protocol::NotifyKind::Sound, + message: msg_text.to_owned(), + }); + } + } } changed @@ -2646,6 +2587,77 @@ mod tests { ); } + #[test] + fn stale_api_agent_report_does_not_forward_done_sound() { + let mut server = test_headless_server(); + let mut background = crate::workspace::Workspace::test_new("background"); + let pane_id = background.tabs[0].root_pane; + let public_pane_id = format!("{}-1", background.id); + background.tabs[0] + .panes + .get_mut(&pane_id) + .unwrap() + .set_hook_authority( + "herdr:pi".into(), + "pi".into(), + crate::detect::AgentState::Working, + None, + Some(20), + ); + let foreground = crate::workspace::Workspace::test_new("foreground"); + server.app.state.workspaces = vec![background, foreground]; + server.app.state.active = Some(1); + server.app.state.selected = 1; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "stale".into(), + method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { + pane_id: public_pane_id, + source: "herdr:pi".into(), + agent: "pi".into(), + state: api::schema::PaneAgentState::Idle, + message: None, + seq: Some(19), + }), + }, + respond_to, + }); + + assert!(changed); + assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok()); + assert_eq!( + server.app.state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .state, + crate::detect::AgentState::Working + ); + assert!( + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "stale idle report must not forward a done sound" + ); + } + /// Verify that no direct calls to `self.app.handle_internal_event` /// exist outside of `handle_internal_event_with_forwarding` in this /// module. This ensures the forwarding bypass cannot be reintroduced. |
