From 3019cec03d2b85141599ef4efbdaa5839281ac1e Mon Sep 17 00:00:00 2001 From: daeho im Date: Wed, 5 Aug 2026 17:45:46 +0900 Subject: [PATCH] fix(agent-status): recover Claude turns after blocked Stop hooks --- src-tauri/crates/acorn-session/src/session.rs | 80 +++++++++- src-tauri/crates/acorn-session/src/status.rs | 46 +++++- src-tauri/crates/acorn-transcript/src/line.rs | 136 ++++++++++++++-- src-tauri/src/agent_hooks.rs | 81 +++++++++- src-tauri/src/commands.rs | 145 +++++++++++++++++- 5 files changed, 463 insertions(+), 25 deletions(-) diff --git a/src-tauri/crates/acorn-session/src/session.rs b/src-tauri/crates/acorn-session/src/session.rs index ff4c6fb4..528d8079 100644 --- a/src-tauri/crates/acorn-session/src/session.rs +++ b/src-tauri/crates/acorn-session/src/session.rs @@ -599,6 +599,17 @@ struct HookRuntimeState { /// Kept apart from `permission_waiting_at`, whose clearing rules belong /// to the Codex turn lifecycle. attention_at: Option, + /// Claude's most recent `Stop` hook boundary. A separate Stop hook can + /// reject completion and resume the same turn without emitting a new + /// `UserPromptSubmit`; later agent-owned transcript activity closes this + /// boundary. + claude_stop_at: Option, +} + +#[derive(Clone, Copy)] +enum ClaudeWaitingBoundary { + Attention, + Stop, } impl HookRuntimeState { @@ -619,6 +630,7 @@ impl HookRuntimeState { self.turn_id = None; self.permission_waiting_at = None; self.attention_at = None; + self.claude_stop_at = None; } } @@ -1040,10 +1052,10 @@ impl SessionStore { /// Record that an attention request without a resolving hook is open. pub fn mark_attention_at(&self, id: &Uuid, requested_at: SystemTime) { - self.lock_hook_runtime() - .entry(*id) - .or_default() - .attention_at = Some(requested_at); + let mut runtime = self.lock_hook_runtime(); + let state = runtime.entry(*id).or_default(); + state.attention_at = Some(requested_at); + state.claude_stop_at = None; } pub fn attention_at(&self, id: &Uuid) -> Option { @@ -1052,9 +1064,25 @@ impl SessionStore { .and_then(|state| state.attention_at) } - pub fn clear_attention(&self, id: &Uuid) { + /// Record a Claude Stop boundary that may still be rejected by another + /// session-scoped Stop hook. + pub fn mark_claude_stop_at(&self, id: &Uuid, stopped_at: SystemTime) { + let mut runtime = self.lock_hook_runtime(); + let state = runtime.entry(*id).or_default(); + state.attention_at = None; + state.claude_stop_at = Some(stopped_at); + } + + pub fn claude_stop_at(&self, id: &Uuid) -> Option { + self.lock_hook_runtime() + .get(id) + .and_then(|state| state.claude_stop_at) + } + + pub fn clear_claude_waiting(&self, id: &Uuid) { if let Some(state) = self.lock_hook_runtime().get_mut(id) { state.attention_at = None; + state.claude_stop_at = None; } } @@ -1074,6 +1102,41 @@ impl SessionStore { expected_source: Option, expected_lifecycle_revision: u64, resumed_at: SystemTime, + ) -> SessionResult> { + self.resolve_claude_waiting_if_current( + id, + expected_source, + expected_lifecycle_revision, + resumed_at, + ClaudeWaitingBoundary::Attention, + ) + } + + /// Resolve a Stop that another hook rejected after Claude resumed the + /// same turn without a new prompt lifecycle event. + pub fn resolve_claude_stop_if_current( + &self, + id: &Uuid, + expected_source: Option, + expected_lifecycle_revision: u64, + resumed_at: SystemTime, + ) -> SessionResult> { + self.resolve_claude_waiting_if_current( + id, + expected_source, + expected_lifecycle_revision, + resumed_at, + ClaudeWaitingBoundary::Stop, + ) + } + + fn resolve_claude_waiting_if_current( + &self, + id: &Uuid, + expected_source: Option, + expected_lifecycle_revision: u64, + resumed_at: SystemTime, + boundary: ClaudeWaitingBoundary, ) -> SessionResult> { let mut runtime = self.lock_hook_runtime(); let mut entry = self @@ -1081,12 +1144,16 @@ impl SessionStore { .get_mut(id) .ok_or_else(|| SessionError::NotFound(id.to_string()))?; let state = runtime.entry(*id).or_default(); + let waiting_at = match boundary { + ClaudeWaitingBoundary::Attention => state.attention_at, + ClaudeWaitingBoundary::Stop => state.claude_stop_at, + }; if entry.status != SessionStatus::WaitingForInput || state.status_source != expected_source || state.lifecycle_revision != expected_lifecycle_revision || !state.confirmed || !entry.hook_active - || state.attention_at.is_none_or(|at| resumed_at <= at) + || waiting_at.is_none_or(|at| resumed_at <= at) { return Ok(None); } @@ -1094,6 +1161,7 @@ impl SessionStore { entry.status = SessionStatus::Working; state.status_source = Some(AgentStatusSource::TranscriptFallback); state.attention_at = None; + state.claude_stop_at = None; Ok(Some(state.advance_lifecycle_revision())) } diff --git a/src-tauri/crates/acorn-session/src/status.rs b/src-tauri/crates/acorn-session/src/status.rs index f735d061..43753991 100644 --- a/src-tauri/crates/acorn-session/src/status.rs +++ b/src-tauri/crates/acorn-session/src/status.rs @@ -85,6 +85,10 @@ pub struct StatusDetection { /// Provider timestamp of the transcript line this detection classified. /// Only transcript evidence carries one. pub turn_timestamp: Option, + /// Newest provider line that proves the main agent loop is actively + /// running. Claude uses this to distinguish a Stop hook that actually + /// returned control from one that rejected completion and resumed. + pub agent_activity_timestamp: Option, } impl StatusDetection { @@ -95,6 +99,7 @@ impl StatusDetection { evidence, completed_provider_turn_id: None, turn_timestamp: None, + agent_activity_timestamp: None, } } @@ -107,6 +112,11 @@ impl StatusDetection { self.turn_timestamp = timestamp; self } + + fn with_agent_activity_timestamp(mut self, timestamp: Option) -> Self { + self.agent_activity_timestamp = timestamp; + self + } } /// Infer a session's status from its transcript tail (when one was resolved) @@ -167,19 +177,23 @@ pub fn detect_with_reason( state: TurnState::Ready, provider_turn_id, timestamp, + agent_activity_timestamp, }) => StatusDetection::new( completed_turn_status(kind), Some(StatusReason::TurnComplete), StatusEvidence::Transcript, ) .with_completed_provider_turn_id(provider_turn_id) - .with_turn_timestamp(timestamp), + .with_turn_timestamp(timestamp) + .with_agent_activity_timestamp(agent_activity_timestamp), Some(TurnObservation { state: TurnState::Working, timestamp, + agent_activity_timestamp, .. }) => StatusDetection::new(SessionStatus::Working, None, StatusEvidence::Transcript) - .with_turn_timestamp(timestamp), + .with_turn_timestamp(timestamp) + .with_agent_activity_timestamp(agent_activity_timestamp), // Transcript exists but the tail held no turn lines; keep // whatever the caller previously observed instead of regressing // to Ready. The next poll that lands on a real turn line corrects @@ -552,6 +566,34 @@ mod tests { ); } + #[test] + fn detect_carries_claude_agent_activity_for_blocked_stop_recovery() { + let path = write_status_transcript(concat!( + r#"{"timestamp":"2026-08-05T07:38:01Z","type":"user","isMeta":true,"message":{"role":"user","content":"Stop hook feedback: keep working"}}"#, + "\n", + r#"{"timestamp":"2026-08-05T07:38:08Z","type":"assistant","message":{"role":"assistant","stop_reason":"tool_use","content":[]}}"#, + "\n", + r#"{"timestamp":"2026-08-05T07:38:09Z","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-1","content":"done"}]}}"#, + )); + + let detection = detect_with_reason( + Some((path, AgentKind::Claude)), + SessionStatus::WaitingForInput, + Some(ShellHint::Running), + ); + + assert_eq!(detection.status, SessionStatus::Working); + assert_eq!(detection.evidence, StatusEvidence::Transcript); + assert_eq!( + detection.turn_timestamp.as_deref(), + Some("2026-08-05T07:38:09Z") + ); + assert_eq!( + detection.agent_activity_timestamp.as_deref(), + Some("2026-08-05T07:38:08Z") + ); + } + #[test] fn detect_reports_turn_complete_reason_for_finished_transcript() { let path = write_status_transcript( diff --git a/src-tauri/crates/acorn-transcript/src/line.rs b/src-tauri/crates/acorn-transcript/src/line.rs index d4168e5e..4804c46f 100644 --- a/src-tauri/crates/acorn-transcript/src/line.rs +++ b/src-tauri/crates/acorn-transcript/src/line.rs @@ -40,6 +40,11 @@ pub struct TurnObservation { /// it against the moment the hook raised the attention request: a turn /// line written afterwards proves the agent resumed past the dialog. pub timestamp: Option, + /// Newest Claude line that proves the main agent loop is actively running: + /// either an in-progress assistant turn or explicit feedback from a Stop + /// hook that rejected completion. Unlike a user-side tool result, this is + /// safe evidence that a blocked Stop resumed without a new prompt hook. + pub agent_activity_timestamp: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -93,12 +98,20 @@ pub struct TailRead { } pub fn parse_transcript_line(kind: AgentKind, line: &str) -> Option { + parse_transcript_line_details(kind, line).map(|(parsed, _)| parsed) +} + +fn parse_transcript_line_details( + kind: AgentKind, + line: &str, +) -> Option<(ParsedTranscriptLine, bool)> { let trimmed = line.trim(); if trimmed.is_empty() || !trimmed.starts_with('{') { return None; } let value = serde_json::from_str::(trimmed).ok()?; - Some(parse_transcript_value(kind, &value)) + let is_stop_hook_feedback = kind == AgentKind::Claude && is_claude_stop_hook_feedback(&value); + Some((parse_transcript_value(kind, &value), is_stop_hook_feedback)) } pub fn parse_transcript_value(kind: AgentKind, value: &Value) -> ParsedTranscriptLine { @@ -119,19 +132,51 @@ pub fn latest_turn_observation( tail: &str, read_full: bool, ) -> Option { + let mut observation = None; + let mut agent_activity_timestamp = None; + let mut agent_activity_resolved = kind != AgentKind::Claude; + for line in tail_lines_newest_first(tail, read_full) { - let Some(parsed) = parse_transcript_line(kind, line) else { + let Some((parsed, is_stop_hook_feedback)) = parse_transcript_line_details(kind, line) + else { continue; }; - if let Some(state) = parsed.turn_state { - return Some(TurnObservation { - state, - provider_turn_id: parsed.provider_turn_id, - timestamp: parsed.timestamp, - }); + + if observation.is_none() { + if let Some(state) = parsed.turn_state { + observation = Some(TurnObservation { + state, + provider_turn_id: parsed.provider_turn_id.clone(), + timestamp: parsed.timestamp.clone(), + agent_activity_timestamp: None, + }); + } + } + + if !agent_activity_resolved { + if is_stop_hook_feedback { + agent_activity_timestamp = parsed.timestamp.clone(); + agent_activity_resolved = true; + } else if parsed.role == TranscriptRole::Assistant { + if parsed.turn_state == Some(TurnState::Working) { + agent_activity_timestamp = parsed.timestamp.clone(); + } + // The newest assistant line is the relevant boundary. A + // completed assistant turn deliberately prevents older tool + // activity from reviving a genuine Stop. + agent_activity_resolved = true; + } + } + + if observation.is_some() && agent_activity_resolved { + break; } } - None + + observation.map(|observation| TurnObservation { + agent_activity_timestamp, + ..observation + }) } pub fn read_tail(path: &Path, max_bytes: u64) -> io::Result { @@ -759,6 +804,14 @@ fn is_claude_meta_event(value: &Value) -> bool { value.get("isMeta").and_then(Value::as_bool) == Some(true) } +fn is_claude_stop_hook_feedback(value: &Value) -> bool { + value.get("type").and_then(Value::as_str) == Some("user") + && is_claude_meta_event(value) + && value_texts(value) + .iter() + .any(|text| text.trim_start().starts_with("Stop hook feedback:")) +} + fn looks_like_claude_control_text(text: &str) -> bool { let lower = text.trim_start().to_ascii_lowercase(); [ @@ -933,6 +986,69 @@ mod tests { ); } + #[test] + fn claude_stop_hook_feedback_proves_a_blocked_stop_resumed() { + let tail = concat!( + r#"{"timestamp":"2026-08-05T07:37:55Z","type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":[]}}"#, + "\n", + r#"{"timestamp":"2026-08-05T07:38:01Z","type":"user","isMeta":true,"message":{"role":"user","content":"Stop hook feedback: keep working"}}"#, + ); + + assert_eq!( + latest_turn_observation(AgentKind::Claude, tail, true), + Some(TurnObservation { + state: TurnState::Working, + provider_turn_id: None, + timestamp: Some("2026-08-05T07:38:01Z".to_string()), + agent_activity_timestamp: Some("2026-08-05T07:38:01Z".to_string()), + }) + ); + } + + #[test] + fn claude_in_progress_assistant_proves_activity_over_a_later_tool_result() { + let tail = concat!( + r#"{"timestamp":"2026-08-05T07:38:01Z","type":"user","isMeta":true,"message":{"role":"user","content":"Stop hook feedback: keep working"}}"#, + "\n", + r#"{"timestamp":"2026-08-05T07:38:08Z","type":"assistant","message":{"role":"assistant","stop_reason":"tool_use","content":[]}}"#, + "\n", + r#"{"timestamp":"2026-08-05T07:38:09Z","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-1","content":"done"}]}}"#, + ); + + assert_eq!( + latest_turn_observation(AgentKind::Claude, tail, true), + Some(TurnObservation { + state: TurnState::Working, + provider_turn_id: None, + timestamp: Some("2026-08-05T07:38:09Z".to_string()), + agent_activity_timestamp: Some("2026-08-05T07:38:08Z".to_string()), + }) + ); + } + + #[test] + fn claude_old_stop_feedback_and_background_result_do_not_revive_a_completed_turn() { + let tail = concat!( + r#"{"timestamp":"2026-08-05T07:37:40Z","type":"user","isMeta":true,"message":{"role":"user","content":"Stop hook feedback: keep working"}}"#, + "\n", + r#"{"timestamp":"2026-08-05T07:37:45Z","type":"assistant","message":{"role":"assistant","stop_reason":"tool_use","content":[]}}"#, + "\n", + r#"{"timestamp":"2026-08-05T07:37:55Z","type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":[]}}"#, + "\n", + r#"{"timestamp":"2026-08-05T07:38:09Z","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"background-1","content":"done"}]}}"#, + ); + + assert_eq!( + latest_turn_observation(AgentKind::Claude, tail, true), + Some(TurnObservation { + state: TurnState::Working, + provider_turn_id: None, + timestamp: Some("2026-08-05T07:38:09Z".to_string()), + agent_activity_timestamp: None, + }) + ); + } + #[test] fn claude_queue_operation_is_status_meta() { let tail = format!( @@ -1209,6 +1325,7 @@ mod tests { state: TurnState::Ready, provider_turn_id: Some("t1".to_string()), timestamp: Some("t".to_string()), + agent_activity_timestamp: None, }), ); } @@ -1282,6 +1399,7 @@ mod tests { state: TurnState::Ready, provider_turn_id: Some("prompt-7".to_string()), timestamp: None, + agent_activity_timestamp: None, }) ); } diff --git a/src-tauri/src/agent_hooks.rs b/src-tauri/src/agent_hooks.rs index 4739de3b..e94f4905 100644 --- a/src-tauri/src/agent_hooks.rs +++ b/src-tauri/src/agent_hooks.rs @@ -1522,12 +1522,17 @@ fn event_can_switch_provider(event: &AgentHookEvent) -> bool { } /// Whether this Claude event is a dialog that stays open until the user acts. -/// `Stop` also reports `NeedsInput`, but its close is the next -/// `UserPromptSubmit`, so only the dialog sources need transcript recovery. fn claude_event_opens_attention_dialog(event: &AgentHookEvent) -> bool { event.event == AgentHookEventKind::NeedsInput && event.source.as_deref() == Some("native") } +/// Whether this is a Stop that another session-scoped Stop hook may reject. +/// A rejected Stop resumes the same turn without `UserPromptSubmit`, so the +/// transcript poll must retain this boundary until it sees agent-owned work. +fn claude_event_opens_stop_boundary(event: &AgentHookEvent) -> bool { + event.event == AgentHookEventKind::NeedsInput && event.source.as_deref() == Some("native_stop") +} + fn apply_validated_agent_hook_event( sessions: &SessionStore, event: AgentHookEvent, @@ -1553,8 +1558,10 @@ fn apply_validated_agent_hook_event( if event.provider == SessionAgentProvider::Claude { if claude_event_opens_attention_dialog(&event) { sessions.mark_attention_at(&event.session_id, SystemTime::now()); + } else if claude_event_opens_stop_boundary(&event) { + sessions.mark_claude_stop_at(&event.session_id, SystemTime::now()); } else { - sessions.clear_attention(&event.session_id); + sessions.clear_claude_waiting(&event.session_id); } } @@ -5435,7 +5442,7 @@ mod tests { } #[test] - fn claude_dialog_marks_attention_and_turn_boundaries_clear_it() { + fn claude_waiting_boundaries_track_dialogs_and_stops_separately() { let (sessions, session_id, reducer) = claude_reducer_fixture(); // A permission prompt or elicitation dialog has no resolving hook, so @@ -5448,6 +5455,7 @@ mod tests { )) .expect("dialog applies"); assert!(sessions.attention_at(&session_id).is_some()); + assert_eq!(sessions.claude_stop_at(&session_id), None); // Answering the dialog is invisible to hooks, but the next prompt is // not: a turn boundary retires the request outright. @@ -5459,9 +5467,11 @@ mod tests { )) .expect("prompt applies"); assert_eq!(sessions.attention_at(&session_id), None); + assert_eq!(sessions.claude_stop_at(&session_id), None); - // Stop reports NeedsInput too, yet its close (`UserPromptSubmit`) is - // reliable, so it must not leave a request behind for the poll. + // Stop usually closes on the next UserPromptSubmit, but another Stop + // hook can reject completion and resume the same turn. Retain its own + // timestamp so transcript activity can prove that happened. reducer .apply(claude_event( session_id, @@ -5470,6 +5480,17 @@ mod tests { )) .expect("stop applies"); assert_eq!(sessions.attention_at(&session_id), None); + assert!(sessions.claude_stop_at(&session_id).is_some()); + + reducer + .apply(claude_event( + session_id, + AgentHookEventKind::Start, + "native", + )) + .expect("next prompt applies"); + assert_eq!(sessions.attention_at(&session_id), None); + assert_eq!(sessions.claude_stop_at(&session_id), None); } #[test] @@ -5522,6 +5543,54 @@ mod tests { assert_eq!(sessions.attention_at(&session_id), None); } + #[test] + fn claude_blocked_stop_resolves_only_from_later_agent_activity() { + let (sessions, session_id, reducer) = claude_reducer_fixture(); + reducer + .apply(claude_event( + session_id, + AgentHookEventKind::NeedsInput, + "native_stop", + )) + .expect("stop applies"); + let stop_at = sessions + .claude_stop_at(&session_id) + .expect("stop boundary recorded"); + let (_, source, _, lifecycle_revision) = + sessions.lifecycle_snapshot(&session_id).expect("snapshot"); + + assert_eq!( + sessions + .resolve_claude_stop_if_current( + &session_id, + source, + lifecycle_revision, + stop_at - Duration::from_secs(1), + ) + .expect("store reachable"), + None + ); + assert_eq!( + sessions.get(&session_id).expect("session").status, + SessionStatus::WaitingForInput + ); + + assert!(sessions + .resolve_claude_stop_if_current( + &session_id, + source, + lifecycle_revision, + stop_at + Duration::from_secs(1), + ) + .expect("store reachable") + .is_some()); + assert_eq!( + sessions.get(&session_id).expect("session").status, + SessionStatus::Working + ); + assert_eq!(sessions.claude_stop_at(&session_id), None); + } + #[test] fn claude_attention_resolution_loses_to_a_newer_lifecycle_write() { let (sessions, session_id, reducer) = claude_reducer_fixture(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4d819d34..1eb72994 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9090,13 +9090,16 @@ enum HookStatusReconciliation { Boot, CurrentCodexTurn(String), ClaudeAttentionResolved(SystemTime), + ClaudeBlockedStopResumed(SystemTime), } impl HookStatusReconciliation { fn target(&self) -> SessionStatus { match self { Self::Boot | Self::CurrentCodexTurn(_) => SessionStatus::WaitingForInput, - Self::ClaudeAttentionResolved(_) => SessionStatus::Working, + Self::ClaudeAttentionResolved(_) | Self::ClaudeBlockedStopResumed(_) => { + SessionStatus::Working + } } } } @@ -9143,6 +9146,36 @@ fn claude_attention_resolution( )) } +/// Recover when a session-scoped Claude Stop hook rejects completion. +/// +/// Claude still emits its native `Stop`, so Acorn first marks the session as +/// waiting. The rejecting hook then injects `Stop hook feedback` and resumes +/// the same turn without another `UserPromptSubmit`. Only explicit feedback +/// or an in-progress assistant line counts as agent activity here; a late +/// background tool result must not revive a genuinely completed turn. +fn claude_blocked_stop_resolution( + hook_provider: Option, + detection: &session_status::StatusDetection, + stop_at: Option, +) -> Option { + if hook_provider != Some(AgentKind::Claude) { + return None; + } + let stop_at = stop_at?; + if detection.status != SessionStatus::Working + || detection.evidence != session_status::StatusEvidence::Transcript + { + return None; + } + let resumed_at: SystemTime = + chrono::DateTime::parse_from_rfc3339(detection.agent_activity_timestamp.as_deref()?) + .ok()? + .into(); + (resumed_at > stop_at).then_some(HookStatusReconciliation::ClaudeBlockedStopResumed( + resumed_at, + )) +} + /// Recover a hook-owned Working status from a durable turn-complete marker. /// /// `hook_active` persists across restarts, so right after boot the poll @@ -9163,9 +9196,11 @@ fn hook_status_reconciliation( hook_provider: Option, detection: &session_status::StatusDetection, attention_at: Option, + claude_stop_at: Option, ) -> Option { if stored == SessionStatus::WaitingForInput { - return claude_attention_resolution(hook_provider, detection, attention_at); + return claude_attention_resolution(hook_provider, detection, attention_at) + .or_else(|| claude_blocked_stop_resolution(hook_provider, detection, claude_stop_at)); } if stored != SessionStatus::Working { return None; @@ -9383,6 +9418,7 @@ fn detect_session_statuses_blocking( let codex_permission_waiting_at = parsed_id.and_then(|uuid| state.sessions.codex_permission_waiting_at(&uuid)); let attention_at = parsed_id.and_then(|uuid| state.sessions.attention_at(&uuid)); + let claude_stop_at = parsed_id.and_then(|uuid| state.sessions.claude_stop_at(&uuid)); let codex_activity_started_at = match (codex_tool_started_at, codex_permission_waiting_at) { (Some(tool), Some(permission)) => Some(tool.max(permission)), @@ -9506,6 +9542,7 @@ fn detect_session_statuses_blocking( hook_provider, &detection, attention_at, + claude_stop_at, ); let reconciled = parsed_id.and_then(|uuid| { reconciliation_requested @@ -9538,6 +9575,14 @@ fn detect_session_statuses_blocking( *resumed_at, ) } + HookStatusReconciliation::ClaudeBlockedStopResumed(resumed_at) => { + state.sessions.resolve_claude_stop_if_current( + &uuid, + stored_source, + lifecycle_revision, + *resumed_at, + ) + } }; applied.ok().flatten().map(|_| reconciliation.target()) }) @@ -11934,6 +11979,7 @@ mod tests { evidence: super::session_status::StatusEvidence::Transcript, completed_provider_turn_id: None, turn_timestamp: None, + agent_activity_timestamp: None, }; assert_eq!( super::hook_status_reconciliation( @@ -11942,6 +11988,7 @@ mod tests { None, &detection, None, + None, ), Some(super::HookStatusReconciliation::Boot) ); @@ -11960,6 +12007,7 @@ mod tests { evidence: super::session_status::StatusEvidence::Transcript, completed_provider_turn_id: None, turn_timestamp: Some(timestamp.to_string()), + agent_activity_timestamp: None, }; let resolution = |detection, attention_at| { super::hook_status_reconciliation( @@ -11968,6 +12016,7 @@ mod tests { Some(AgentKind::Claude), &detection, attention_at, + None, ) }; @@ -12031,6 +12080,82 @@ mod tests { "2023-11-14T22:13:21Z" ), Some(attention_at), + None, + ), + None + ); + } + + #[test] + fn blocked_claude_stop_requires_later_agent_owned_activity() { + use acorn_agent::AgentKind; + use std::time::Duration; + + // 2023-11-14T22:13:20Z + let stop_at = std::time::UNIX_EPOCH + Duration::from_secs(1_700_000_000); + let detection = + |status, activity_timestamp: Option<&str>| super::session_status::StatusDetection { + status, + reason: None, + evidence: super::session_status::StatusEvidence::Transcript, + completed_provider_turn_id: None, + turn_timestamp: Some("2023-11-14T22:13:22Z".to_string()), + agent_activity_timestamp: activity_timestamp.map(str::to_string), + }; + let resolution = |detection, stop_at| { + super::hook_status_reconciliation( + acorn_session::SessionStatus::WaitingForInput, + true, + Some(AgentKind::Claude), + &detection, + None, + stop_at, + ) + }; + + assert_eq!( + resolution( + detection( + acorn_session::SessionStatus::Working, + Some("2023-11-14T22:13:21Z"), + ), + Some(stop_at), + ), + Some(super::HookStatusReconciliation::ClaudeBlockedStopResumed( + stop_at + Duration::from_secs(1), + )) + ); + + // A late background tool result leaves the turn classified Working, + // but carries no main-agent activity and cannot revive a real Stop. + assert_eq!( + resolution( + detection(acorn_session::SessionStatus::Working, None), + Some(stop_at), + ), + None + ); + + // Activity before the Stop belongs to the completed turn. + assert_eq!( + resolution( + detection( + acorn_session::SessionStatus::Working, + Some("2023-11-14T22:13:19Z"), + ), + Some(stop_at), + ), + None + ); + + // A subsequent completed turn remains waiting for the next prompt. + assert_eq!( + resolution( + detection( + acorn_session::SessionStatus::Ready, + Some("2023-11-14T22:13:21Z"), + ), + Some(stop_at), ), None ); @@ -12046,6 +12171,7 @@ mod tests { evidence: super::session_status::StatusEvidence::Transcript, completed_provider_turn_id: None, turn_timestamp: None, + agent_activity_timestamp: None, }; assert_eq!( super::hook_status_reconciliation( @@ -12054,6 +12180,7 @@ mod tests { None, &detection, None, + None, ), None ); @@ -12075,6 +12202,7 @@ mod tests { evidence: super::session_status::StatusEvidence::Previous, completed_provider_turn_id: None, turn_timestamp: None, + agent_activity_timestamp: None, }; assert_eq!( super::hook_status_reconciliation( @@ -12083,6 +12211,7 @@ mod tests { None, &detection, None, + None, ), None ); @@ -12096,6 +12225,7 @@ mod tests { evidence: super::session_status::StatusEvidence::Transcript, completed_provider_turn_id: Some("turn-1".to_string()), turn_timestamp: None, + agent_activity_timestamp: None, }; assert_eq!( super::hook_status_reconciliation( @@ -12104,6 +12234,7 @@ mod tests { Some(super::AgentKind::Codex), &detection, None, + None, ), None ); @@ -12119,6 +12250,7 @@ mod tests { evidence: super::session_status::StatusEvidence::Transcript, completed_provider_turn_id: None, turn_timestamp: None, + agent_activity_timestamp: None, }; assert_eq!( super::hook_status_reconciliation( @@ -12127,6 +12259,7 @@ mod tests { Some(super::AgentKind::Codex), &detection, None, + None, ), None ); @@ -12140,6 +12273,7 @@ mod tests { evidence: super::session_status::StatusEvidence::Transcript, completed_provider_turn_id: Some("turn-1".to_string()), turn_timestamp: None, + agent_activity_timestamp: None, }; assert_eq!( super::hook_status_reconciliation( @@ -12148,6 +12282,7 @@ mod tests { Some(super::AgentKind::Codex), &detection, None, + None, ), Some(super::HookStatusReconciliation::CurrentCodexTurn( "turn-1".to_string() @@ -12163,6 +12298,7 @@ mod tests { evidence: super::session_status::StatusEvidence::Transcript, completed_provider_turn_id: Some("turn-1".to_string()), turn_timestamp: None, + agent_activity_timestamp: None, }; assert_eq!( super::hook_status_reconciliation( @@ -12171,6 +12307,7 @@ mod tests { Some(super::AgentKind::Claude), &detection, None, + None, ), None ); @@ -12186,6 +12323,7 @@ mod tests { evidence: super::session_status::StatusEvidence::Previous, completed_provider_turn_id: None, turn_timestamp: None, + agent_activity_timestamp: None, }; assert_eq!( super::hook_status_reconciliation( @@ -12194,6 +12332,7 @@ mod tests { None, &detection, None, + None, ), None ); @@ -12209,6 +12348,7 @@ mod tests { evidence: super::session_status::StatusEvidence::Process, completed_provider_turn_id: None, turn_timestamp: None, + agent_activity_timestamp: None, }; assert_eq!( super::hook_status_reconciliation( @@ -12217,6 +12357,7 @@ mod tests { None, &detection, None, + None, ), None );