diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-27 20:57:00 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-27 20:57:00 +0300 |
| commit | 2552d101afe46b8622a7695f38f6cd1766dd1afc (patch) | |
| tree | e560c2cc8ef02689711af0865a5b541ee3050bf1 /src/app | |
| parent | 0d1a39dcee6a0f244a036c55935970f40cfa76b8 (diff) | |
feat: let agents report their own resume command (#4687)
* feat: let agents report their own resume command
* fix: keep reported resume commands tied to accepted sessions and current owners
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 225 | ||||
| -rw-r--r-- | src/app/api.rs | 1 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 132 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 1 |
4 files changed, 338 insertions, 21 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 6a6e99d2..e1ae2ffc 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1500,6 +1500,28 @@ impl AppState { .collect() } } + AppEvent::AgentResumeReported { + pane_id, + source, + agent_label, + seq, + argv, + } => self + .update_terminal_state(pane_id, |terminal| { + terminal.record_reported_resume(&source, &agent_label, seq, argv); + None + }) + .into_iter() + .collect(), + AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at, + } => self + .update_terminal_state(pane_id, |terminal| { + terminal.clear_self_reported_agent(observed_at) + }) + .into_iter() + .collect(), AppEvent::AgentSessionReported { pane_id, source, @@ -1653,7 +1675,14 @@ impl AppState { let terminal = self.terminals.get_mut(&terminal_id)?; let previous_agent_name = terminal.agent_name.clone(); let had_completion = terminal.last_agent_completion_seq.is_some() || !previous_seen; - let mutation = update(terminal)?; + let resume_revision = terminal.reported_resume_revision(); + let mutation = update(terminal); + terminal.reconcile_reported_resume(); + // Resume-only changes return no mutation but must still be saved. + if terminal.reported_resume_revision() != resume_revision { + self.session_dirty = true; + } + let mutation = mutation?; let completion_reset = mutation.session_ref_changed || mutation .effective_state_change @@ -3541,6 +3570,200 @@ mod tests { assert!(state.toast.is_none()); } + fn report_custom_agent_with_resume(state: &mut AppState, pane_id: PaneId, argv: &[&str]) { + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "prime-agent".into(), + agent_label: "prime-agent".into(), + state: AgentState::Idle, + message: None, + seq: Some(1), + session_ref: None, + }); + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "prime-agent".into(), + agent_label: "prime-agent".into(), + seq: Some(1), + argv: argv.iter().map(|part| part.to_string()).collect(), + }); + } + + fn first_pane_terminal(state: &AppState) -> (PaneId, crate::terminal::TerminalId) { + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + let terminal_id = state.workspaces[0].panes[&pane_id] + .attached_terminal_id + .clone(); + (pane_id, terminal_id) + } + + #[test] + fn reported_resume_follows_the_reporting_agent_until_release() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]); + + let resume = state.terminals[&terminal_id].reported_resume().unwrap(); + assert_eq!(resume.agent, "prime-agent"); + assert_eq!(resume.argv, vec!["prime-agent", "--resume", "a"]); + + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "someone-else".into(), + agent_label: "other".into(), + seq: None, + argv: vec!["other".into()], + }); + assert_eq!( + state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .argv, + vec!["prime-agent", "--resume", "a"] + ); + + state.handle_app_event(AppEvent::HookAgentReleased { + pane_id, + source: "prime-agent".into(), + agent_label: "prime-agent".into(), + known_agent: None, + seq: Some(2), + }); + assert!(state.terminals[&terminal_id].reported_resume().is_none()); + } + + #[test] + fn reported_resume_is_dropped_and_saved_when_another_agent_takes_the_pane() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]); + state.session_dirty = false; + + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "custom:other".into(), + agent_label: "other".into(), + state: AgentState::Working, + message: None, + seq: None, + session_ref: None, + }); + + assert!(state.terminals[&terminal_id].reported_resume().is_none()); + assert!(state.session_dirty); + } + + #[test] + fn reported_resume_of_recognized_agent_is_dropped_when_its_process_exits() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + seq: None, + argv: vec!["pi".into(), "--continue".into()], + }); + assert!(state.terminals[&terminal_id].reported_resume().is_some()); + state.session_dirty = false; + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at: std::time::Instant::now(), + }); + + assert!(state.terminals[&terminal_id].reported_resume().is_none()); + assert!(state.session_dirty); + + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + seq: None, + argv: vec!["pi".into(), "--continue".into()], + }); + assert!( + state.terminals[&terminal_id].reported_resume().is_none(), + "a late report must not revive an exited agent" + ); + } + + #[test] + fn shell_return_drops_self_reported_agent_and_its_resume() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]); + assert!(state.terminals[&terminal_id].self_reported_agent_active()); + + state.handle_app_event(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: std::time::Instant::now(), + }); + + let terminal = &state.terminals[&terminal_id]; + assert_eq!(terminal.effective_agent_label(), None); + assert!(terminal.reported_resume().is_none()); + assert!(!terminal.self_reported_agent_active()); + } + + #[test] + fn delayed_shell_return_keeps_an_agent_that_claimed_the_pane_afterwards() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + let shell_seen_idle_at = std::time::Instant::now(); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "b"]); + + state.handle_app_event(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: shell_seen_idle_at, + }); + + let terminal = &state.terminals[&terminal_id]; + assert_eq!(terminal.effective_agent_label(), Some("prime-agent")); + assert!(terminal.reported_resume().is_some()); + } + + #[test] + fn shell_return_keeps_agents_herdr_recognizes_by_process() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + state: AgentState::Working, + message: None, + seq: Some(1), + session_ref: None, + }); + assert!(!state.terminals[&terminal_id].self_reported_agent_active()); + + state.handle_app_event(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: std::time::Instant::now(), + }); + + assert_eq!( + state.terminals[&terminal_id].effective_agent_label(), + Some("pi") + ); + } + #[test] fn hook_reported_unknown_agent_sets_toast_title_from_label() { let mut state = app_with_workspaces(&["active", "background"]); diff --git a/src/app/api.rs b/src/app/api.rs index 62c8a6b5..20ca9d0f 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -462,6 +462,7 @@ impl App { runtime.set_full_lifecycle_authority_active( terminal.full_lifecycle_hook_authority_active(), ); + runtime.set_self_reported_agent_active(terminal.self_reported_agent_active()); } } } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 119d9298..e4e03e71 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1549,28 +1549,44 @@ impl App { id: String, params: PaneReportAgentParams, ) -> String { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; + if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) { + return encode_error(id, "invalid_resume_argv", message); + } + let report_is_newer = self + .pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.hook_report_is_newer(¶ms.source, params.seq)); + let session_ref = crate::agent_resume::session_ref_from_report( + ¶ms.source, + &agent_label, + params.agent_session_id, + params.agent_session_path, + ); self.handle_internal_event(crate::events::AppEvent::HookStateReported { pane_id, - session_ref: crate::agent_resume::session_ref_from_report( - ¶ms.source, - &agent_label, - params.agent_session_id, - params.agent_session_path, - ), - source: params.source, - agent_label, + session_ref: session_ref.clone(), + source: params.source.clone(), + agent_label: agent_label.clone(), state: detect_state_from_api(params.state), message: params.message, seq: params.seq, }); - - encode_success(id, ResponseResult::Ok {}) + let applied = + report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref()); + self.report_agent_resume( + id, + ws_idx, + pane_id, + params.source, + agent_label, + params.seq.filter(|_| applied), + applied.then_some(params.resume_argv).flatten(), + ) } pub(super) fn handle_pane_report_agent_session( @@ -1578,28 +1594,100 @@ impl App { id: String, params: PaneReportAgentSessionParams, ) -> String { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; + if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) { + return encode_error(id, "invalid_resume_argv", message); + } + let report_is_newer = self + .pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.hook_report_is_newer(¶ms.source, params.seq)); + let session_ref = crate::agent_resume::session_ref_from_report( + ¶ms.source, + &agent_label, + params.agent_session_id, + params.agent_session_path, + ); self.handle_internal_event(crate::events::AppEvent::AgentSessionReported { pane_id, - session_ref: crate::agent_resume::session_ref_from_report( - ¶ms.source, - &agent_label, - params.agent_session_id, - params.agent_session_path, - ), - source: params.source, - agent_label, + session_ref: session_ref.clone(), + source: params.source.clone(), + agent_label: agent_label.clone(), seq: params.seq, session_start_source: crate::agent_resume::normalize_session_start_source( params.session_start_source, ), }); + let applied = + report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref()); + self.report_agent_resume( + id, + ws_idx, + pane_id, + params.source, + agent_label, + params.seq.filter(|_| applied), + applied.then_some(params.resume_argv).flatten(), + ) + } + /// A resume command belongs to the session it was reported with, so it is + /// kept only when Herdr accepted that session. + fn session_report_applied( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + session_ref: Option<&crate::agent_resume::AgentSessionRef>, + ) -> bool { + session_ref.is_none_or(|session_ref| { + self.pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.session_ref_is_current(session_ref)) + }) + } + + fn pane_terminal( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalState> { + let pane = self.state.workspaces.get(ws_idx)?.pane_state(pane_id)?; + self.state.terminals.get(&pane.attached_terminal_id) + } + + fn report_agent_resume( + &mut self, + id: String, + ws_idx: usize, + pane_id: crate::layout::PaneId, + source: String, + agent_label: String, + seq: Option<u64>, + resume_argv: Option<Vec<String>>, + ) -> String { + let Some(argv) = resume_argv else { + return encode_success(id, ResponseResult::Ok {}); + }; + let can_record = self + .pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.can_record_reported_resume(&source, &agent_label)); + if !can_record { + return encode_error( + id, + "resume_not_accepted", + "resume_argv requires the reporter to hold the pane; report its state with pane.report_agent first", + ); + } + self.handle_internal_event(crate::events::AppEvent::AgentResumeReported { + pane_id, + source, + agent_label, + seq, + argv, + }); encode_success(id, ResponseResult::Ok {}) } @@ -2214,6 +2302,10 @@ fn invalid_agent(id: String) -> String { encode_error(id, "invalid_agent", "agent label must not be empty") } +fn validate_optional_resume_argv(argv: Option<&[String]>) -> Result<(), String> { + argv.map_or(Ok(()), crate::agent_resume::validate_resume_argv) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 35dbee2f..c9b37bfe 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -3328,6 +3328,7 @@ action = "missing" seq: None, agent_session_id: None, agent_session_path: None, + resume_argv: None, }, ); |
