diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-24 02:49:33 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-24 02:50:58 +0300 |
| commit | e608a75159fc5cca70485d83ffb8ee681c9da385 (patch) | |
| tree | 5ffdec62d0bee9a798418d6414ecbddae7ff8ae5 /src/app | |
| parent | 169a4fd90c20a00aec77d684ecec98b9f0f996cc (diff) | |
fix: make known-agent exit process-owned
refs #1792
refs #1648
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 104 | ||||
| -rw-r--r-- | src/app/api.rs | 22 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 142 |
3 files changed, 224 insertions, 44 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index b7d335b1..ff786846 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2793,7 +2793,7 @@ impl AppState { seq, .. } => { - if crate::agent_resume::is_reserved_native_state_source(&source, &agent_label) { + if crate::agent_resume::is_official_agent_source(&source, &agent_label) { Vec::new() } else { self.update_terminal_state(pane_id, |terminal| { @@ -2890,8 +2890,16 @@ impl AppState { previous_state: change.previous_state, previous_seen, previous_presentation: change.previous_presentation.clone(), - agent_label: change.agent_label.clone(), - known_agent: change.known_agent, + agent_label: if agent_released { + change.previous_agent_label.clone() + } else { + change.agent_label.clone() + }, + known_agent: if agent_released { + change.previous_known_agent + } else { + change.known_agent + }, state: change.state, seen, presentation: change.presentation.clone(), @@ -3011,7 +3019,11 @@ impl AppState { return None; } - let agent_label = change.agent_label.clone()?; + let agent_label = change + .agent_label + .clone() + .or_else(|| change.previous_agent_label.clone())?; + let known_agent = change.known_agent.or(change.previous_known_agent); let kind = client_notification_kind.unwrap_or(match sound { Some(crate::sound::Sound::Request) => ToastKind::NeedsAttention, Some(crate::sound::Sound::Done) | None => ToastKind::Finished, @@ -3024,7 +3036,7 @@ impl AppState { pane_id, workspace_id, agent_label, - change.known_agent, + known_agent, kind, change.state, ); @@ -3036,7 +3048,7 @@ impl AppState { pane_id, workspace_id, agent_label, - known_agent: change.known_agent, + known_agent, kind, state: change.state, deadline: { @@ -3071,7 +3083,10 @@ impl AppState { if terminal_state.state != expected_state { return None; } - if terminal_state.effective_agent_label() != Some(agent_label.as_str()) { + if terminal_state + .effective_agent_label() + .is_some_and(|current| current != agent_label) + { return None; } @@ -4978,7 +4993,7 @@ mod tests { } #[test] - fn reserved_native_release_report_does_not_clear_screen_state() { + fn official_release_preserves_process_owned_agent_identity() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let terminal_id = state.workspaces[0] @@ -4990,24 +5005,51 @@ mod tests { state.handle_app_event(AppEvent::StateChanged { pane_id, - agent: Some(Agent::Claude), + agent: Some(Agent::Pi), state: AgentState::Working, visible_blocker: false, visible_working: true, process_exited: false, observed_at: std::time::Instant::now(), }); - state.handle_app_event(AppEvent::HookAgentReleased { + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:pi".into(), + agent: "pi".into(), + session_ref: crate::agent_resume::AgentSessionRef::path( + std::env::current_dir() + .unwrap() + .join("release-session.jsonl") + .display() + .to_string(), + ) + .unwrap(), + }); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(1), + ); + terminal.set_agent_name("reviewer".into()); + state.session_dirty = false; + + let updates = state.handle_app_event(AppEvent::HookAgentReleased { pane_id, - source: "herdr:claude".into(), - agent_label: "claude".into(), - known_agent: Some(Agent::Claude), - seq: Some(1), + source: "herdr:pi".into(), + agent_label: "pi".into(), + known_agent: Some(Agent::Pi), + seq: Some(2), }); - let terminal = state.terminals.get(&terminal_id).unwrap(); + assert!(updates.is_empty()); + let terminal = &state.terminals[&terminal_id]; assert_eq!(terminal.state, AgentState::Working); - assert_eq!(terminal.detected_agent, Some(Agent::Claude)); + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(terminal.full_lifecycle_hook_authority_active()); + assert!(!state.session_dirty); } #[test] @@ -5081,7 +5123,7 @@ mod tests { } #[test] - fn releasing_an_agent_alias_marks_the_session_dirty() { + fn custom_release_clears_report_owned_agent() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let terminal_id = state.workspaces[0] @@ -5089,21 +5131,29 @@ mod tests { .unwrap() .attached_terminal_id .clone(); - let terminal = state.terminals.get_mut(&terminal_id).unwrap(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_agent_name("reviewer".into()); - state.session_dirty = false; + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_hook_authority( + "custom:agent".into(), + "custom-agent".into(), + AgentState::Working, + None, + Some(1), + ); state.handle_app_event(AppEvent::HookAgentReleased { pane_id, - source: "herdr:pi".into(), - agent_label: "pi".into(), - known_agent: Some(Agent::Pi), - seq: Some(1), + source: "custom:agent".into(), + agent_label: "custom-agent".into(), + known_agent: None, + seq: Some(2), }); - assert!(state.terminals[&terminal_id].agent_name.is_none()); - assert!(state.session_dirty); + let terminal = &state.terminals[&terminal_id]; + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Unknown); } #[test] diff --git a/src/app/api.rs b/src/app/api.rs index 0cf55fd5..02a61067 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1529,7 +1529,7 @@ mod tests { } #[tokio::test] - async fn agent_explain_reports_hook_only_full_lifecycle_authority() { + async fn agent_explain_rejects_hook_only_full_lifecycle_authority() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), @@ -1567,17 +1567,7 @@ mod tests { }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(response["result"]["type"], "agent_explain"); - assert_eq!(response["result"]["explain"]["agent"], "omp"); - assert_eq!(response["result"]["explain"]["state"], "working"); - assert_eq!( - response["result"]["explain"]["screen_detection_skip_reason"], - "full_lifecycle_hook_authority" - ); - assert_eq!( - response["result"]["explain"]["matched_rule"], - serde_json::Value::Null - ); + assert_eq!(response["error"]["code"], "agent_not_found"); } #[tokio::test] @@ -1941,7 +1931,7 @@ mod tests { } #[test] - fn stale_detector_exit_does_not_release_a_newer_hook_owned_agent() { + fn process_exit_releases_a_newer_hook_owned_agent() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( @@ -1983,9 +1973,9 @@ mod tests { }); let terminal = &app.state.terminals[&terminal_id]; - assert_eq!(terminal.state, AgentState::Working); - assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); - assert!(!event_hub.events_after(0).iter().any(|(_, event)| matches!( + assert_eq!(terminal.state, AgentState::Idle); + assert!(terminal.agent_name.is_none()); + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( event.data, crate::api::schema::EventData::PaneAgentDetected { released: true, .. } ))); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 2da6d308..43206d1e 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1357,9 +1357,21 @@ impl App { let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { return pane_not_found(id, ¶ms.pane_id); }; + if terminal.metadata_report_blocked_by_process_exit( + &source, + agent_label.as_deref(), + applies_to_source.as_deref(), + ) { + return encode_success(id, ResponseResult::Ok {}); + } if !terminal.metadata_report_sequence_is_fresh(&source, params.seq) { return encode_success(id, ResponseResult::Ok {}); } + let metadata_agent = crate::terminal::TerminalState::metadata_report_agent( + &source, + agent_label.as_deref(), + applies_to_source.as_deref(), + ); if let Some(tokens) = tokens.as_ref() { if terminal.metadata_tokens.key_count_after_patch(tokens) > MAX_METADATA_TOKEN_KEYS_PER_RESOURCE @@ -1373,7 +1385,8 @@ impl App { ); } } - match terminal.accept_metadata_report(&source, params.seq, tokens.is_some()) { + match terminal.accept_metadata_report(&source, params.seq, tokens.is_some(), metadata_agent) + { Ok(true) => {} Ok(false) => return encode_success(id, ResponseResult::Ok {}), Err(()) => { @@ -3819,6 +3832,133 @@ mod tests { } #[test] + fn pane_metadata_ignored_after_process_exit_does_not_poison_sequence() { + let (mut app, pane_id) = app_with_test_workspace(); + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Pi), AgentState::Idle); + + let mut initial = metadata_params(pane_id.clone()); + initial.source = "custom:pi-metadata".into(); + initial.agent = Some("pi".into()); + initial.seq = Some(100); + let response = app.handle_pane_report_metadata("initial".into(), initial); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let mut initial_tokens = metadata_params(pane_id.clone()); + initial_tokens.source = "custom:pi-tokens".into(); + initial_tokens.agent = Some("pi".into()); + initial_tokens.title = None; + initial_tokens.tokens = + std::collections::HashMap::from([("generation".into(), Some("old".into()))]); + initial_tokens.seq = Some(100); + let response = app.handle_pane_report_metadata("initial-tokens".into(), initial_tokens); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let exit_at = std::time::Instant::now() + std::time::Duration::from_millis(1); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + exit_at, + ); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + exit_at + std::time::Duration::from_millis(1), + ); + + let mut stale = metadata_params(pane_id.clone()); + stale.source = "custom:pi-metadata".into(); + stale.agent = Some("pi".into()); + stale.title = Some("stale".into()); + stale.seq = Some(200); + let response = app.handle_pane_report_metadata("stale".into(), stale); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let mut official = metadata_params(pane_id.clone()); + official.source = "herdr:pi".into(); + official.seq = Some(200); + let response = app.handle_pane_report_metadata("official".into(), official); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let terminal = &app.state.terminals[&terminal_id]; + assert!(terminal.metadata_report_sequence_is_fresh("custom:pi-metadata", Some(1))); + assert!(terminal.metadata_report_sequence_is_fresh("custom:pi-tokens", Some(1))); + assert!(terminal.metadata_report_sequence_is_fresh("herdr:pi", Some(1))); + + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + false, + exit_at + std::time::Duration::from_millis(2), + ); + let mut fresh = metadata_params(pane_id.clone()); + fresh.source = "custom:pi-metadata".into(); + fresh.agent = Some("pi".into()); + fresh.title = Some("fresh".into()); + fresh.seq = Some(1); + let response = app.handle_pane_report_metadata("fresh".into(), fresh); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let mut fresh_tokens = metadata_params(pane_id); + fresh_tokens.source = "custom:pi-tokens".into(); + fresh_tokens.agent = Some("pi".into()); + fresh_tokens.title = None; + fresh_tokens.tokens = + std::collections::HashMap::from([("generation".into(), Some("new".into()))]); + fresh_tokens.seq = Some(1); + let response = app.handle_pane_report_metadata("fresh-tokens".into(), fresh_tokens); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let terminal = &app.state.terminals[&terminal_id]; + assert_eq!( + terminal.agent_metadata["custom:pi-metadata"] + .title + .as_deref(), + Some("fresh") + ); + assert_eq!( + terminal + .metadata_tokens + .values() + .get("generation") + .map(String::as_str), + Some("new") + ); + } + + #[test] fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() { let (mut app, pane_id) = app_with_test_workspace(); let mut params = metadata_params(pane_id); |
