aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-24 02:49:33 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-24 02:50:58 +0300
commite608a75159fc5cca70485d83ffb8ee681c9da385 (patch)
tree5ffdec62d0bee9a798418d6414ecbddae7ff8ae5 /src/app
parent169a4fd90c20a00aec77d684ecec98b9f0f996cc (diff)
fix: make known-agent exit process-owned
refs #1792 refs #1648
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs104
-rw-r--r--src/app/api.rs22
-rw-r--r--src/app/api/panes.rs142
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, &params.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);