diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-24 02:23:42 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-24 02:23:42 +0200 |
| commit | 9c96f7ddb3be2cc575a159d4d1f1d49fb10d7006 (patch) | |
| tree | 182e89183981160397af301c075ab11316b15850 /src | |
| parent | 21fd121a63db53ff44b947bbe38c47bbf35e632c (diff) | |
fix: avoid inferring codex idle from terminal output (#4563)
refs #4507
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 42 | ||||
| -rw-r--r-- | src/cli/agent.rs | 6 | ||||
| -rw-r--r-- | src/detect/manifest.rs | 27 | ||||
| -rw-r--r-- | src/detect/manifest/tests.rs | 20 | ||||
| -rw-r--r-- | src/detect/manifests/codex.toml | 16 | ||||
| -rw-r--r-- | src/detect/mod.rs | 4 | ||||
| -rw-r--r-- | src/events.rs | 2 | ||||
| -rw-r--r-- | src/pane.rs | 211 | ||||
| -rw-r--r-- | src/pane/agent_detection.rs | 54 | ||||
| -rw-r--r-- | src/terminal/state.rs | 82 |
10 files changed, 384 insertions, 80 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index e1bedd26..26bdf0e4 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1442,6 +1442,12 @@ impl AppState { }) .into_iter() .collect(), + AppEvent::CodexPromptObserved { pane_id, ready } => self + .update_terminal_state(pane_id, |terminal| { + terminal.observe_codex_prompt_ready(ready) + }) + .into_iter() + .collect(), AppEvent::StateChanged { pane_id, agent, @@ -3124,6 +3130,42 @@ mod tests { } #[test] + fn codex_prompt_observation_changes_readiness_without_completing_a_turn() { + let mut app = app_with_workspaces(&["active", "background"]); + 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( + "reviewer".into(), + Agent::Codex, + Instant::now(), + std::time::Duration::ZERO, + std::time::Duration::from_secs(60), + ); + app.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Codex), + state: AgentState::Unknown, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: Instant::now(), + }); + app.handle_app_event(AppEvent::CodexPromptObserved { + pane_id, + ready: true, + }); + let terminal = &app.terminals[&terminal_id]; + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.state, AgentState::Unknown); + assert!(terminal.last_agent_completion_seq.is_none()); + } + + #[test] fn completion_guard_same_state_agent_replacement_clears_old_work() { let mut app = app_with_workspaces(&["active", "background"]); app.active = Some(0); diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 1af3e0d6..09043460 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -610,6 +610,12 @@ fn wait_for_named_agent( "agent_not_ready", format!("agent {name} is blocked during startup and is not ready for prompts"), ))), + Some("unknown") + if expected_kind == "codex" + && agent["interactive_ready"].as_bool() == Some(true) => + { + Some(Ok(agent.clone())) + } Some("working" | "unknown") => None, Some("idle" | "done") if agent["interactive_ready"].as_bool() == Some(true) => { Some(Ok(agent.clone())) diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index acd6b8f9..e224adfe 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -416,23 +416,6 @@ pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionEx explain(agent, screen_content) } -pub fn should_skip_state_update(agent: Agent, screen_content: &str) -> bool { - let Some(loaded) = load_manifest(agent) else { - return false; - }; - evaluate_loaded_manifest( - agent, - DetectionInput { - screen: screen_content, - osc_title: "", - osc_progress: "", - }, - loaded, - false, - ) - .skip_state_update -} - impl DetectionExplain { fn into_detection(self) -> AgentDetection { AgentDetection { @@ -557,14 +540,14 @@ fn fallback_explain( ) }) .unwrap_or((None, Vec::new(), None, None, None, false)); - let known_agent = agent.is_some(); + let assume_idle = agent.is_some_and(|agent| agent != Agent::Codex); let remote_update_status = include_update_status .then(|| agent.and_then(remote_update_status)) .flatten(); DetectionExplain { agent: agent.map(|agent| agent_label(agent).to_string()), - state: if known_agent { + state: if assume_idle { AgentState::Idle } else { AgentState::Unknown @@ -577,7 +560,11 @@ fn fallback_explain( visible_working: false, skip_state_update: false, skipped_update_reason: None, - fallback_reason: known_agent.then(|| DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()), + fallback_reason: match agent { + Some(Agent::Codex) => Some("codex_state_ambiguous".to_string()), + Some(_) => Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()), + None => None, + }, evaluated_rules, warning, manifest_version, diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 07e9aa38..1e6b1a85 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -89,15 +89,21 @@ fn write_local_codex(content: &str) { } #[test] -fn known_agent_no_match_defaults_to_idle_fallback() { +fn codex_no_match_is_unknown_without_changing_other_agents() { with_manifest_dirs("no-match", || { write_local_codex(&local_manifest("working", "active-marker")); let explain = explain(Agent::Codex, "unmatched-marker"); - assert_eq!(explain.state, AgentState::Idle); + assert_eq!(explain.state, AgentState::Unknown); assert!(!explain.visible_idle); assert_eq!( explain.fallback_reason.as_deref(), + Some("codex_state_ambiguous") + ); + let other = fallback_explain(Some(Agent::Pi), None, false); + assert_eq!(other.state, AgentState::Idle); + assert_eq!( + other.fallback_reason.as_deref(), Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) ); }); @@ -184,10 +190,10 @@ fn fallback_explain_preserves_active_manifest_version() { let explain = explain(Agent::Codex, "ordinary prompt text"); - assert_eq!(explain.state, AgentState::Idle); + assert_eq!(explain.state, AgentState::Unknown); assert_eq!( explain.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + Some("codex_state_ambiguous") ); assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1")); assert!(matches!( @@ -267,10 +273,10 @@ fn detection_uses_cached_manifest_until_explicit_reload() { write_remote_codex_without_reload(&remote_manifest("9999.01.01.2", "working", "new-ready")); let unchanged = explain(Agent::Codex, "new-ready"); - assert_eq!(unchanged.state, AgentState::Idle); + assert_eq!(unchanged.state, AgentState::Unknown); assert_eq!( unchanged.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + Some("codex_state_ambiguous") ); assert_eq!( unchanged.cached_remote_version.as_deref(), @@ -479,8 +485,6 @@ contains = ["overlay-marker"] assert!(!result.visible_working); assert!(!result.visible_blocker); assert!(detect(Agent::Codex, screen).skip_state_update); - assert!(should_skip_state_update(Agent::Codex, screen)); - assert!(!should_skip_state_update(Agent::Codex, "activity-marker")); }); } diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml index ecef12b7..dd275758 100644 --- a/src/detect/manifests/codex.toml +++ b/src/detect/manifests/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.09.22.1" +version = "2026.09.23.1" min_engine_version = 3 -updated_at = "2026-09-22T00:00:00Z" +updated_at = "2026-09-23T22:12:00Z" [[rules]] id = "osc_title_blocked" @@ -92,15 +92,3 @@ any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }] regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z'] # A failed reconnect keeps its final elapsed timer but is no longer working. not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }] - -[[rules]] -id = "osc_title_idle" -state = "idle" -priority = 100 -region = "osc_title" -visible_idle = true -regex = ['\S'] -not = [ - { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] }, - { contains = ["Action Required"] }, -] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 3dae571b..408ee669 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -320,10 +320,6 @@ pub fn detect_agent_with_osc( ) } -pub fn should_skip_state_update(agent: Option<Agent>, screen_content: &str) -> bool { - agent.is_some_and(|agent| manifest::should_skip_state_update(agent, screen_content)) -} - pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool { matches!( (source, agent_label), diff --git a/src/events.rs b/src/events.rs index a614ee0c..0e61f5f8 100644 --- a/src/events.rs +++ b/src/events.rs @@ -89,6 +89,8 @@ pub enum AppEvent { agent: Agent, observed_at: Instant, }, + /// The current Codex input screen is visible during managed startup. + CodexPromptObserved { pane_id: PaneId, ready: bool }, /// Fallback detector state changed in a pane. StateChanged { pane_id: PaneId, diff --git a/src/pane.rs b/src/pane.rs index 9bc35eca..bfeec638 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -34,7 +34,7 @@ mod terminal; mod xtgettcap; use self::agent_detection::{ - decide_detection_screen_read, decide_screen_detection_publish, + codex_prompt_ready, decide_detection_screen_read, decide_screen_detection_publish, detection_update_for_publish_with_osc, mark_detection_content_changed, observe_detection_content_change, DetectionPublishDecision, DetectionScreenReadDecision, DetectionScreenReadInput, PendingIdleConfirmation, ScreenDetectionPublishInput, @@ -290,6 +290,31 @@ async fn publish_agent_process_detected_event( } } +async fn publish_codex_prompt_observation( + state_events: &mpsc::Sender<AppEvent>, + pane_id: PaneId, + agent: Option<Agent>, + content: &str, + detection: Option<&crate::detect::AgentDetection>, + process_exited: bool, + last_ready: &mut bool, +) { + let ready = agent == Some(Agent::Codex) + && !process_exited + && detection.is_some_and(|detection| detection.state == AgentState::Unknown) + && codex_prompt_ready(content); + if ready == *last_ready { + return; + } + *last_ready = ready; + if let Err(err) = state_events + .send(AppEvent::CodexPromptObserved { pane_id, ready }) + .await + { + warn!(pane = pane_id.raw(), %err, "failed to deliver Codex prompt observation"); + } +} + #[derive(Debug, Clone, Copy)] struct AgentDetectionPublishUpdate { state: AgentState, @@ -771,6 +796,7 @@ fn spawn_basic_detection_task( let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); + let mut last_codex_prompt_ready = false; loop { let sleep_duration = if pending_idle.active() { @@ -781,6 +807,10 @@ fn spawn_basic_detection_task( tokio::select! { _ = tokio::time::sleep(sleep_duration) => {} _ = detect_reset.notified() => { + publish_codex_prompt_observation( + &state_events, pane_id, Some(Agent::Codex), "", None, false, + &mut last_codex_prompt_ready, + ).await; agent_presence = AgentDetectionPresence::from_agent(None); state = AgentState::Unknown; last_visible_idle = false; @@ -888,6 +918,7 @@ fn spawn_basic_detection_task( == ForegroundShellAgentAction::ReportReplacementProcess; if agent_changed { pending_idle.clear(); + last_codex_prompt_ready = false; last_screen_scan_detection_content_seq = None; // A replacement agent must not inherit OSC evidence // from the previous process; a first acquisition keeps @@ -961,10 +992,29 @@ fn spawn_basic_detection_task( last_screen_scan_detection_content_seq = current_detection_content_seq; let content_changed = content != last_detection_text; last_detection_text.clone_from(&content); - if !process_exited && crate::detect::should_skip_state_update(agent, &content) { + let osc_title = terminal.agent_osc_title(); + let osc_progress = terminal.agent_osc_progress(); + let screen_detection = detection_update_for_publish_with_osc( + agent, + &content, + &osc_title, + &osc_progress, + process_exited, + ); + publish_codex_prompt_observation( + &state_events, + pane_id, + agent, + &content, + screen_detection.as_ref(), + process_exited, + &mut last_codex_prompt_ready, + ) + .await; + let Some(screen_detection) = screen_detection else { pending_idle.clear(); continue; - } + }; sync_content_change_acquisition( agent_presence.current_agent(), suppressed_agent, @@ -974,19 +1024,6 @@ fn spawn_basic_detection_task( &mut acquisition_started_at, &mut last_content_change_at, ); - - let osc_title = terminal.agent_osc_title(); - let osc_progress = terminal.agent_osc_progress(); - let Some(screen_detection) = detection_update_for_publish_with_osc( - agent, - &content, - &osc_title, - &osc_progress, - process_exited, - ) else { - pending_idle.clear(); - continue; - }; match decide_screen_detection_publish( ScreenDetectionPublishInput { screen_detection, @@ -2660,6 +2697,7 @@ impl PaneRuntime { let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); + let mut last_codex_prompt_ready = false; tokio::time::sleep(Duration::from_millis(50)).await; @@ -2680,6 +2718,10 @@ impl PaneRuntime { tokio::select! { _ = tokio::time::sleep(tick) => {} _ = detect_reset.notified() => { + publish_codex_prompt_observation( + &state_events, pane_id, Some(Agent::Codex), "", None, false, + &mut last_codex_prompt_ready, + ).await; agent_presence = AgentDetectionPresence::from_agent(None); state = AgentState::Unknown; last_visible_idle = false; @@ -2823,6 +2865,7 @@ impl PaneRuntime { == ForegroundShellAgentAction::ReportReplacementProcess { pending_idle.clear(); + last_codex_prompt_ready = false; last_screen_scan_detection_content_seq = None; // A replacement agent must not inherit OSC // evidence from the previous process; a first @@ -2930,10 +2973,29 @@ impl PaneRuntime { last_screen_scan_detection_content_seq = current_detection_content_seq; let content_changed = content != last_detection_text; last_detection_text.clone_from(&content); - if detect::should_skip_state_update(agent, &content) { + let osc_title = terminal.agent_osc_title(); + let osc_progress = terminal.agent_osc_progress(); + let screen_detection = detection_update_for_publish_with_osc( + agent, + &content, + &osc_title, + &osc_progress, + process_exited, + ); + publish_codex_prompt_observation( + &state_events, + pane_id, + agent, + &content, + screen_detection.as_ref(), + process_exited, + &mut last_codex_prompt_ready, + ) + .await; + let Some(screen_detection) = screen_detection else { pending_idle.clear(); continue; - } + }; sync_content_change_acquisition( agent_presence.current_agent(), suppressed_agent, @@ -2943,19 +3005,6 @@ impl PaneRuntime { &mut acquisition_started_at, &mut last_content_change_at, ); - - let osc_title = terminal.agent_osc_title(); - let osc_progress = terminal.agent_osc_progress(); - let Some(screen_detection) = detection_update_for_publish_with_osc( - agent, - &content, - &osc_title, - &osc_progress, - process_exited, - ) else { - pending_idle.clear(); - continue; - }; match decide_screen_detection_publish( ScreenDetectionPublishInput { screen_detection, @@ -5804,4 +5853,104 @@ mod tests { } if delivered_pane == pane_id )); } + + #[tokio::test] + async fn codex_prompt_observation_revokes_on_working_or_skipped_screen() { + let (tx, mut rx) = mpsc::channel(4); + let pane_id = PaneId::from_raw(42); + let detection = crate::detect::AgentDetection { + state: AgentState::Unknown, + skip_state_update: false, + visible_idle: false, + visible_blocker: false, + visible_working: false, + }; + let mut last_ready = false; + let prompt = "› Ask Codex to do anything"; + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + Some(&detection), + false, + &mut last_ready, + ) + .await; + assert!(matches!( + rx.recv().await, + Some(AppEvent::CodexPromptObserved { ready: true, .. }) + )); + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + Some(&detection), + false, + &mut last_ready, + ) + .await; + assert!(rx.try_recv().is_err()); + let working = crate::detect::AgentDetection { + state: AgentState::Working, + ..detection + }; + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + Some(&working), + false, + &mut last_ready, + ) + .await; + assert!(matches!( + rx.recv().await, + Some(AppEvent::CodexPromptObserved { ready: false, .. }) + )); + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + Some(&detection), + false, + &mut last_ready, + ) + .await; + assert!(matches!( + rx.recv().await, + Some(AppEvent::CodexPromptObserved { ready: true, .. }) + )); + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + None, + false, + &mut last_ready, + ) + .await; + assert!(matches!( + rx.recv().await, + Some(AppEvent::CodexPromptObserved { ready: false, .. }) + )); + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + Some(&detection), + false, + &mut last_ready, + ) + .await; + assert!(matches!( + rx.recv().await, + Some(AppEvent::CodexPromptObserved { ready: true, .. }) + )); + } } diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs index 9ef10dfe..e37aec3b 100644 --- a/src/pane/agent_detection.rs +++ b/src/pane/agent_detection.rs @@ -89,7 +89,9 @@ pub(super) struct IdleScreenScanSkipInput { } pub(super) fn should_skip_idle_screen_scan(input: IdleScreenScanSkipInput) -> bool { - if input.state != AgentState::Idle + let stable_state = input.state == AgentState::Idle + || (input.state == AgentState::Unknown && input.agent == Some(Agent::Codex)); + if !stable_state || input.agent.is_none() || input.pending_idle_active || input.agent_changed @@ -316,6 +318,23 @@ pub(super) fn detection_update_for_publish_with_osc( (!detection.skip_state_update).then_some(detection) } +pub(super) fn codex_prompt_ready(content: &str) -> bool { + // The composer can remain visible during a turn; this is startup evidence only. + let recent: String = content + .lines() + .rev() + .take(12) + .collect::<Vec<_>>() + .into_iter() + .rev() + .flat_map(str::chars) + .filter(|ch| !ch.is_whitespace()) + .collect(); + recent.contains("›AskCodextodoanything") + && !recent.contains("model:loading") + && !recent.contains("Resumingsession") +} + pub(super) fn observe_detection_content_change(bytes: &[u8], detection_content_seq: &AtomicU64) { if !bytes.is_empty() { detection_content_seq.fetch_add(1, Ordering::Relaxed); @@ -380,6 +399,13 @@ mod tests { } #[test] + fn codex_startup_prompt_survives_terminal_wraps() { + let wrapped = "header\n› Ask Codex to do\nanything\nfooter"; + assert!(codex_prompt_ready(wrapped)); + assert!(!codex_prompt_ready(&format!("model: load\ning\n{wrapped}"))); + } + + #[test] fn screen_read_skips_unchanged_idle_bottom_buffer() { assert_eq!( decide_detection_screen_read(screen_read_input(AgentState::Idle, 10)), @@ -388,6 +414,32 @@ mod tests { } #[test] + fn screen_read_skips_unchanged_ambiguous_codex_but_not_new_content_or_replacement() { + let mut input = screen_read_input(AgentState::Unknown, 10); + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Skip + ); + input.current_detection_content_seq = Some(11); + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Read + ); + input.current_detection_content_seq = Some(10); + input.agent_changed = true; + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Read + ); + input.agent_changed = false; + input.agent = Some(Agent::Pi); + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Read + ); + } + + #[test] fn screen_read_reads_when_idle_bottom_buffer_changes() { assert_eq!( decide_detection_screen_read(screen_read_input(AgentState::Idle, 11)), diff --git a/src/terminal/state.rs b/src/terminal/state.rs index fadd6e10..4a5abb12 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -142,6 +142,7 @@ pub struct TerminalState { pub agent_name: Option<String>, agent_name_owner: Option<AgentNameOwner>, managed_agent: Option<ManagedAgent>, + codex_prompt_ready: bool, managed_agent_launch_session: Option<crate::agent_resume::PersistedAgentSession>, hook_report_sequences: HashMap<String, u64>, suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, @@ -179,6 +180,7 @@ impl TerminalState { agent_name: None, agent_name_owner: None, managed_agent: None, + codex_prompt_ready: false, managed_agent_launch_session: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), @@ -217,6 +219,7 @@ impl TerminalState { now, ); if starts_acquisition { + self.codex_prompt_ready = false; self.agent_process_acquisition_pending = true; } mutation @@ -419,6 +422,9 @@ impl TerminalState { }; } self.detected_agent = agent; + if process_exited || agent != Some(Agent::Codex) || fallback_state == AgentState::Blocked { + self.codex_prompt_ready = false; + } if let Some(agent) = agent { let agent_label = crate::detect::agent_label(agent); self.reconcile_agent_name_owner(agent_label, None); @@ -1972,6 +1978,7 @@ impl TerminalState { settle_delay: Duration, timeout: Duration, ) { + self.codex_prompt_ready = false; self.set_agent_name(name); self.agent_process_acquisition_pending = true; self.agent_name_owner = Some(AgentNameOwner { @@ -2002,6 +2009,20 @@ impl TerminalState { .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Active)) } + pub fn observe_codex_prompt_ready(&mut self, ready: bool) -> Option<TerminalStateMutation> { + if self.detected_agent != Some(Agent::Codex) + || self.recent_agent_process_exit.is_some() + || !self.managed_agent.is_some_and(|managed| { + managed.kind == Agent::Codex && managed.phase != ManagedAgentPhase::Active + }) + || self.codex_prompt_ready == ready + { + return None; + } + self.codex_prompt_ready = ready; + Some(TerminalStateMutation::default()) + } + pub fn managed_agent_kind(&self) -> Option<Agent> { self.managed_agent.map(|managed| managed.kind) } @@ -2039,7 +2060,12 @@ impl TerminalState { return true; } if managed.phase == ManagedAgentPhase::Blocked { - if known_agent == Some(managed.kind) && self.state == AgentState::Idle { + if known_agent == Some(managed.kind) + && (self.state == AgentState::Idle + || managed.kind == Agent::Codex + && self.state == AgentState::Unknown + && self.codex_prompt_ready) + { self.managed_agent = Some(ManagedAgent { kind: managed.kind, phase: ManagedAgentPhase::Active, @@ -2067,7 +2093,12 @@ impl TerminalState { return true; } if ready_after.is_none_or(|ready_after| now >= ready_after) { - if known_agent == Some(managed.kind) && self.state == AgentState::Idle { + if known_agent == Some(managed.kind) + && (self.state == AgentState::Idle + || managed.kind == Agent::Codex + && self.state == AgentState::Unknown + && self.codex_prompt_ready) + { self.managed_agent = Some(ManagedAgent { kind: managed.kind, phase: ManagedAgentPhase::Active, @@ -2115,6 +2146,7 @@ impl TerminalState { } pub fn clear_agent_name(&mut self) { + self.codex_prompt_ready = false; if self .managed_agent_launch_session .take() @@ -2329,6 +2361,52 @@ mod tests { } #[test] + fn codex_managed_readiness_requires_current_prompt_without_idle() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.begin_managed_agent( + "reviewer".into(), + Agent::Codex, + now, + Duration::from_millis(100), + Duration::from_secs(1), + ); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Unknown); + terminal.observe_codex_prompt_ready(true); + terminal.reconcile_managed_agent_at(now, false); + assert!(!terminal.managed_agent_interactive_ready()); + terminal.observe_codex_prompt_ready(false); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(100), false)); + assert!(!terminal.managed_agent_interactive_ready()); + + terminal.observe_codex_prompt_ready(true); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false)); + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.state, AgentState::Unknown); + terminal.observe_codex_prompt_ready(false); + assert!(terminal.managed_agent_interactive_ready()); + + terminal.begin_managed_agent( + "next".into(), + Agent::Codex, + now, + Duration::ZERO, + Duration::from_secs(1), + ); + terminal.reconcile_managed_agent_at(now, false); + assert!(!terminal.managed_agent_interactive_ready()); + + terminal.set_detected_state(Some(Agent::Codex), AgentState::Blocked); + assert!(terminal.reconcile_managed_agent_at(now, false)); + terminal.observe_codex_prompt_ready(false); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Unknown); + assert!(!terminal.reconcile_managed_agent_at(now, false)); + terminal.observe_codex_prompt_ready(true); + assert!(terminal.reconcile_managed_agent_at(now, false)); + assert!(terminal.managed_agent_interactive_ready()); + } + + #[test] fn managed_agent_mismatch_and_timeout_release_name() { let now = Instant::now(); let mut mismatch = test_terminal(); |
