| 1 | //! Observer-hook projection: subagent and turn-end hook payload construction, |
| 2 | //! preview bounding, and completion classification (TUI_MODULARIZATION.md |
| 3 | //! slice 4). The executor lives in `crate::hooks`; this module builds the |
| 4 | //! payloads the UI submits and classifies completion results for display. |
| 5 | |
| 6 | use super::*; |
| 7 | |
| 8 | pub(super) fn execute_subagent_observer_hook( |
| 9 | app: &App, |
| 10 | event: HookEvent, |
| 11 | agent_id: &str, |
| 12 | text_field: &str, |
| 13 | text: &str, |
| 14 | ) -> Result<(), String> { |
| 15 | let (preview, truncated) = bounded_subagent_hook_preview(text); |
| 16 | |
| 17 | // Lifecycle outbox (`[lifecycle_outbox]`): fires even when no shell hook |
| 18 | // is configured for this event — the outbox is independent of the hook |
| 19 | // command list. Preview is bounded (preview ceiling) and only ever the |
| 20 | // preview text, never the raw prompt/result. No-op when disabled. |
| 21 | match &event { |
| 22 | HookEvent::SubagentSpawn => { |
| 23 | app.lifecycle_outbox.emit(codewhale_hooks::LifecycleEvent { |
| 24 | event: "subagent_spawn".to_string(), |
| 25 | kind: "subagent.spawned".to_string(), |
| 26 | thread_id: app.hooks.session_id().to_string(), |
| 27 | turn_id: app.runtime_turn_id.clone(), |
| 28 | item_id: None, |
| 29 | payload: serde_json::json!({ |
| 30 | "agent_id": agent_id, |
| 31 | "subagent": agent_id, |
| 32 | "workspace": app.workspace.display().to_string(), |
| 33 | "prompt_preview": codewhale_hooks::bounded_text( |
| 34 | &preview, |
| 35 | codewhale_hooks::OUTBOX_PREVIEW_MAX_CHARS, |
| 36 | ), |
| 37 | "prompt_truncated": truncated, |
| 38 | }), |
| 39 | }); |
| 40 | } |
| 41 | HookEvent::SubagentComplete => { |
| 42 | let status = subagent_completion_status(text).unwrap_or_else(|| "unknown".to_string()); |
| 43 | app.lifecycle_outbox.emit(codewhale_hooks::LifecycleEvent { |
| 44 | event: "subagent_complete".to_string(), |
| 45 | kind: "subagent.completed".to_string(), |
| 46 | thread_id: app.hooks.session_id().to_string(), |
| 47 | turn_id: app.runtime_turn_id.clone(), |
| 48 | item_id: None, |
| 49 | payload: serde_json::json!({ |
| 50 | "agent_id": agent_id, |
| 51 | "subagent": agent_id, |
| 52 | "workspace": app.workspace.display().to_string(), |
| 53 | "status": status, |
| 54 | "result_preview": codewhale_hooks::bounded_text( |
| 55 | &preview, |
| 56 | codewhale_hooks::OUTBOX_PREVIEW_MAX_CHARS, |
| 57 | ), |
| 58 | "result_truncated": truncated, |
| 59 | }), |
| 60 | }); |
| 61 | } |
| 62 | _ => {} |
| 63 | } |
| 64 | |
| 65 | if !app.hooks.has_hooks_for_event(event) { |
| 66 | return Ok(()); |
| 67 | } |
| 68 | |
| 69 | let context = app.base_hook_context().with_message(&preview); |
| 70 | let mut payload = serde_json::json!({ |
| 71 | "event": event.as_str(), |
| 72 | "agent_id": agent_id, |
| 73 | "session_id": context.session_id.as_deref(), |
| 74 | "workspace": context.workspace.as_ref().map(|path| path.display().to_string()), |
| 75 | "mode": context.mode.as_deref(), |
| 76 | "model": context.model.as_deref(), |
| 77 | "total_tokens": context.total_tokens, |
| 78 | }); |
| 79 | if let Some(object) = payload.as_object_mut() { |
| 80 | object.insert( |
| 81 | format!("{text_field}_preview"), |
| 82 | serde_json::Value::String(preview), |
| 83 | ); |
| 84 | object.insert( |
| 85 | format!("{text_field}_truncated"), |
| 86 | serde_json::Value::Bool(truncated), |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | if event == HookEvent::SubagentComplete { |
| 91 | payload["status"] = serde_json::Value::String( |
| 92 | subagent_completion_status(text).unwrap_or_else(|| "unknown".to_string()), |
| 93 | ); |
| 94 | } |
| 95 | |
| 96 | app.hooks.submit_json_observer(event, context, payload) |
| 97 | } |
| 98 | |
| 99 | pub(super) fn execute_turn_end_observer_hook( |
| 100 | app: &App, |
| 101 | turn: Option<&ActiveTurnMetadata>, |
| 102 | usage: &Usage, |
| 103 | billing_surface: Option<&str>, |
| 104 | duration: Duration, |
| 105 | error: Option<&str>, |
| 106 | ) -> Result<(), String> { |
| 107 | if !app.hooks.has_hooks_for_event(HookEvent::TurnEnd) { |
| 108 | return Ok(()); |
| 109 | } |
| 110 | |
| 111 | let metadata = turn_end_observer_metadata(turn); |
| 112 | let context = app.base_hook_context(); |
| 113 | let payload = crate::hooks::turn_end_payload(TurnEndPayloadInput { |
| 114 | context: &context, |
| 115 | created_at: metadata.created_at, |
| 116 | model_backed: metadata.route.is_some(), |
| 117 | provider: metadata.route.map(|route| route.provider_identity.as_str()), |
| 118 | billing_surface: metadata.route.and(billing_surface), |
| 119 | model: metadata.route.map(|route| route.model.as_str()), |
| 120 | turn_id: metadata.turn_id.as_ref(), |
| 121 | status: app.runtime_turn_status.as_deref().unwrap_or("unknown"), |
| 122 | error, |
| 123 | duration, |
| 124 | usage, |
| 125 | totals: TurnEndTotals { |
| 126 | session_tokens: app.session.total_tokens, |
| 127 | conversation_tokens: app.session.total_conversation_tokens, |
| 128 | input_tokens: app.session.total_input_tokens, |
| 129 | output_tokens: app.session.total_output_tokens, |
| 130 | }, |
| 131 | tool_count: app.tool_evidence.len(), |
| 132 | queued_message_count: app.queued_message_count(), |
| 133 | }); |
| 134 | app.hooks |
| 135 | .submit_json_observer(HookEvent::TurnEnd, context, payload) |
| 136 | } |
| 137 | |
| 138 | pub(super) fn surface_observer_hook_submission_failure(app: &mut App, error: String) { |
| 139 | app.surface_observer_hook_submission_failure(error); |
| 140 | } |
| 141 | |
| 142 | /// Why the agent is waiting on the person, in the payload of |
| 143 | /// [`HookEvent::WaitingForUser`]. |
| 144 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 145 | pub(super) enum SessionWaitReason { |
| 146 | Approval, |
| 147 | UserInput, |
| 148 | GoalContinuation, |
| 149 | } |
| 150 | |
| 151 | impl SessionWaitReason { |
| 152 | fn as_str(self) -> &'static str { |
| 153 | match self { |
| 154 | Self::Approval => "approval", |
| 155 | Self::UserInput => "user_input", |
| 156 | Self::GoalContinuation => "goal_continuation", |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | /// The session's wait reason right now, when one exists. |
| 162 | pub(super) fn session_wait_reason(app: &App) -> Option<SessionWaitReason> { |
| 163 | if app.view_stack.top_kind() == Some(crate::tui::views::ModalKind::Approval) { |
| 164 | return Some(SessionWaitReason::Approval); |
| 165 | } |
| 166 | if app.pending_user_input_prompt.is_some() { |
| 167 | return Some(SessionWaitReason::UserInput); |
| 168 | } |
| 169 | if app.goal_continuation_waiting { |
| 170 | return Some(SessionWaitReason::GoalContinuation); |
| 171 | } |
| 172 | None |
| 173 | } |
| 174 | |
| 175 | /// The hook a turn-state edge fires, if any. Pure so the transition table is |
| 176 | /// directly testable: into `Waiting` is `waiting_for_user`; into `Idle` from |
| 177 | /// work or a wait is `session_idle`; into `InProgress` is `session_busy`. |
| 178 | /// Repeated observations of the same state are silent. |
| 179 | pub(super) fn session_state_transition_event( |
| 180 | previous: crate::tui::control_socket::TurnState, |
| 181 | current: crate::tui::control_socket::TurnState, |
| 182 | ) -> Option<HookEvent> { |
| 183 | use crate::tui::control_socket::TurnState; |
| 184 | match (previous, current) { |
| 185 | (TurnState::InProgress | TurnState::Waiting, TurnState::Idle) => { |
| 186 | Some(HookEvent::SessionIdle) |
| 187 | } |
| 188 | (TurnState::Idle | TurnState::InProgress, TurnState::Waiting) => { |
| 189 | Some(HookEvent::WaitingForUser) |
| 190 | } |
| 191 | (TurnState::Idle | TurnState::Waiting, TurnState::InProgress) => { |
| 192 | Some(HookEvent::SessionBusy) |
| 193 | } |
| 194 | _ => None, |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | /// Fire the session-state hooks on transitions of the shared |
| 199 | /// [`crate::tui::control_socket::turn_state_from_app`] projection (#6004): |
| 200 | /// `waiting_for_user` when a wait begins, `session_idle` when the session |
| 201 | /// settles back to idle after work or a wait, and `session_busy` when work |
| 202 | /// begins or resumes. The first observed state is |
| 203 | /// recorded without firing so startup never emits a spurious transition. |
| 204 | pub(super) fn execute_session_state_transition_hooks( |
| 205 | app: &App, |
| 206 | previous: &mut Option<crate::tui::control_socket::TurnState>, |
| 207 | ) { |
| 208 | use crate::tui::control_socket::turn_state_from_app; |
| 209 | let current = turn_state_from_app(app); |
| 210 | let previous = previous.replace(current); |
| 211 | let Some(previous) = previous else { |
| 212 | return; |
| 213 | }; |
| 214 | let Some(event) = session_state_transition_event(previous, current) else { |
| 215 | return; |
| 216 | }; |
| 217 | if !app.hooks.has_hooks_for_event(event) { |
| 218 | return; |
| 219 | } |
| 220 | let mut payload = serde_json::json!({ |
| 221 | "from": turn_state_name(previous), |
| 222 | "to": turn_state_name(current), |
| 223 | }); |
| 224 | if event == HookEvent::WaitingForUser |
| 225 | && let Some(reason) = session_wait_reason(app) |
| 226 | { |
| 227 | payload["reason"] = serde_json::Value::String(reason.as_str().to_string()); |
| 228 | } |
| 229 | if event == HookEvent::SessionIdle |
| 230 | && let Some(status) = app.runtime_turn_status.as_deref() |
| 231 | { |
| 232 | payload["last_turn_status"] = serde_json::Value::String(status.to_string()); |
| 233 | } |
| 234 | if let Err(error) = app |
| 235 | .hooks |
| 236 | .submit_json_observer(event, app.base_hook_context(), payload) |
| 237 | { |
| 238 | tracing::warn!("session-state hook submission failed: {error}"); |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | fn turn_state_name(state: crate::tui::control_socket::TurnState) -> &'static str { |
| 243 | match state { |
| 244 | crate::tui::control_socket::TurnState::Idle => "idle", |
| 245 | crate::tui::control_socket::TurnState::InProgress => "in_progress", |
| 246 | crate::tui::control_socket::TurnState::Waiting => "waiting", |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | /// Fire `session_error` for a turn whose terminal status is failed (#6004). |
| 251 | /// Transient tool failures the agent absorbs never reach this: only the |
| 252 | /// turn-ending failure fires it, so an alert here means the agent stopped. |
| 253 | pub(super) fn execute_session_error_hook(app: &App, error: Option<&str>) { |
| 254 | if !app.hooks.has_hooks_for_event(HookEvent::SessionError) { |
| 255 | return; |
| 256 | } |
| 257 | let payload = serde_json::json!({ |
| 258 | "status": "failed", |
| 259 | "error": error.unwrap_or_default(), |
| 260 | }); |
| 261 | if let Err(error) = |
| 262 | app.hooks |
| 263 | .submit_json_observer(HookEvent::SessionError, app.base_hook_context(), payload) |
| 264 | { |
| 265 | tracing::warn!("session-error hook submission failed: {error}"); |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | pub(super) struct TurnEndObserverMetadata<'a> { |
| 270 | pub(super) turn_id: std::borrow::Cow<'a, str>, |
| 271 | pub(super) created_at: chrono::DateTime<chrono::Utc>, |
| 272 | pub(super) route: Option<&'a crate::core::events::TurnRoute>, |
| 273 | } |
| 274 | |
| 275 | pub(super) fn turn_end_observer_metadata( |
| 276 | turn: Option<&ActiveTurnMetadata>, |
| 277 | ) -> TurnEndObserverMetadata<'_> { |
| 278 | turn.map_or_else( |
| 279 | || TurnEndObserverMetadata { |
| 280 | // Manual compaction, purge, and shell-only completions predate the |
| 281 | // TurnStarted lifecycle event. Preserve their observer contract |
| 282 | // with a distinct non-model identity instead of borrowing a stale |
| 283 | // model turn id. |
| 284 | turn_id: std::borrow::Cow::Owned(format!("lifecycle_{}", uuid::Uuid::new_v4())), |
| 285 | created_at: chrono::Utc::now(), |
| 286 | route: None, |
| 287 | }, |
| 288 | |turn| TurnEndObserverMetadata { |
| 289 | turn_id: std::borrow::Cow::Borrowed(&turn.turn_id), |
| 290 | created_at: turn.created_at, |
| 291 | route: turn.route.as_ref(), |
| 292 | }, |
| 293 | ) |
| 294 | } |
| 295 | |
| 296 | pub(super) fn bounded_subagent_hook_preview(text: &str) -> (String, bool) { |
| 297 | if text.len() <= SUBAGENT_HOOK_PREVIEW_LIMIT { |
| 298 | return (text.to_string(), false); |
| 299 | } |
| 300 | let safe_end = text |
| 301 | .char_indices() |
| 302 | .take_while(|(idx, ch)| idx + ch.len_utf8() <= SUBAGENT_HOOK_PREVIEW_LIMIT) |
| 303 | .last() |
| 304 | .map(|(idx, ch)| idx + ch.len_utf8()) |
| 305 | .unwrap_or(0); |
| 306 | (format!("{}...[truncated]", &text[..safe_end]), true) |
| 307 | } |
| 308 | |
| 309 | pub(super) fn subagent_completion_status(result: &str) -> Option<String> { |
| 310 | const START: &str = "<codewhale:subagent.done>"; |
| 311 | const END: &str = "</codewhale:subagent.done>"; |
| 312 | |
| 313 | if let Some(start) = result.find(START).map(|idx| idx + START.len()) |
| 314 | && let Some(end) = result[start..].find(END).map(|idx| idx + start) |
| 315 | && let Ok(value) = serde_json::from_str::<serde_json::Value>(&result[start..end]) |
| 316 | && let Some(status) = value.get("status").and_then(serde_json::Value::as_str) |
| 317 | { |
| 318 | return Some(status.to_string()); |
| 319 | } |
| 320 | |
| 321 | let summary = result.lines().find_map(|line| { |
| 322 | let trimmed = line.trim(); |
| 323 | (!trimmed.is_empty()).then_some(trimmed) |
| 324 | })?; |
| 325 | let summary = summary.to_ascii_lowercase(); |
| 326 | if matches!(summary.as_str(), "cancelled" | "canceled") |
| 327 | || summary.starts_with("cancelled:") |
| 328 | || summary.starts_with("canceled:") |
| 329 | { |
| 330 | Some("cancelled".to_string()) |
| 331 | } else if summary == "failed" || summary.starts_with("failed:") { |
| 332 | Some("failed".to_string()) |
| 333 | } else if summary == "interrupted" || summary.starts_with("interrupted:") { |
| 334 | Some("interrupted".to_string()) |
| 335 | } else { |
| 336 | None |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | pub(super) fn subagent_failure_notice(result: &str) -> Option<String> { |
| 341 | const START: &str = "<codewhale:subagent.done>"; |
| 342 | const END: &str = "</codewhale:subagent.done>"; |
| 343 | let start = result.find(START)? + START.len(); |
| 344 | let end = result[start..].find(END)? + start; |
| 345 | let value = serde_json::from_str::<serde_json::Value>(&result[start..end]).ok()?; |
| 346 | (value.get("event").and_then(serde_json::Value::as_str) == Some("subagent.failed")) |
| 347 | .then(|| { |
| 348 | let name = value |
| 349 | .get("name") |
| 350 | .and_then(serde_json::Value::as_str) |
| 351 | .unwrap_or("unknown"); |
| 352 | let agent_id = value |
| 353 | .get("agent_id") |
| 354 | .and_then(serde_json::Value::as_str) |
| 355 | .unwrap_or("unknown"); |
| 356 | let class = value |
| 357 | .get("failure_class") |
| 358 | .and_then(serde_json::Value::as_str) |
| 359 | .unwrap_or("unavailable"); |
| 360 | let steps = value |
| 361 | .get("steps") |
| 362 | .and_then(serde_json::Value::as_u64) |
| 363 | .map_or_else(|| "?".to_string(), |steps| steps.to_string()); |
| 364 | let elapsed_ms = value |
| 365 | .get("elapsed_ms") |
| 366 | .and_then(serde_json::Value::as_u64) |
| 367 | .map_or_else(|| "?".to_string(), |elapsed| elapsed.to_string()); |
| 368 | let transcript_handle = value |
| 369 | .get("transcript_handle") |
| 370 | .and_then(serde_json::Value::as_str) |
| 371 | .unwrap_or("unavailable"); |
| 372 | format!( |
| 373 | "{name} ({agent_id}) · {class} · {steps} steps · {elapsed_ms} ms · inspect {transcript_handle}" |
| 374 | ) |
| 375 | }) |
| 376 | } |
| 377 | |
| 378 | #[cfg(test)] |
| 379 | mod tests { |
| 380 | use super::*; |
| 381 | use crate::tui::control_socket::TurnState; |
| 382 | use crate::tui::control_socket::turn_state_from_app; |
| 383 | |
| 384 | #[test] |
| 385 | fn session_state_transition_table_fires_only_on_real_edges() { |
| 386 | use HookEvent::*; |
| 387 | use TurnState::{Idle, InProgress, Waiting}; |
| 388 | let states = [Idle, InProgress, Waiting]; |
| 389 | let expected = [ |
| 390 | [None, Some(SessionBusy), Some(WaitingForUser)], |
| 391 | [Some(SessionIdle), None, Some(WaitingForUser)], |
| 392 | [Some(SessionIdle), Some(SessionBusy), None], |
| 393 | ]; |
| 394 | for (row, previous) in states.into_iter().enumerate() { |
| 395 | for (column, current) in states.into_iter().enumerate() { |
| 396 | assert_eq!( |
| 397 | session_state_transition_event(previous, current), |
| 398 | expected[row][column], |
| 399 | "{previous:?} -> {current:?}" |
| 400 | ); |
| 401 | } |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | #[cfg(unix)] |
| 406 | #[test] |
| 407 | fn session_state_transitions_dispatch_real_ordered_payloads_without_duplicates() { |
| 408 | use crate::hooks::{Hook, HookExecutor, HooksConfig}; |
| 409 | use std::path::{Path, PathBuf}; |
| 410 | use std::time::{Duration, Instant}; |
| 411 | |
| 412 | fn paths_with_extension(dir: &Path, extension: &str) -> Vec<PathBuf> { |
| 413 | std::fs::read_dir(dir) |
| 414 | .expect("receipt directory") |
| 415 | .map(|entry| entry.expect("receipt entry").path()) |
| 416 | .filter(|path| path.extension().is_some_and(|ext| ext == extension)) |
| 417 | .collect() |
| 418 | } |
| 419 | |
| 420 | fn wait_for_paths( |
| 421 | dir: &Path, |
| 422 | extension: &str, |
| 423 | count: usize, |
| 424 | deadline: Instant, |
| 425 | ) -> Vec<PathBuf> { |
| 426 | loop { |
| 427 | let paths = paths_with_extension(dir, extension); |
| 428 | assert!( |
| 429 | Instant::now() < deadline, |
| 430 | "expected {count} .{extension} receipts, got {}", |
| 431 | paths.len() |
| 432 | ); |
| 433 | if paths.len() >= count { |
| 434 | return paths; |
| 435 | } |
| 436 | std::thread::sleep(Duration::from_millis(10)); |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | struct ReleaseBarriers { |
| 441 | workspace: PathBuf, |
| 442 | submitted: usize, |
| 443 | } |
| 444 | impl Drop for ReleaseBarriers { |
| 445 | fn drop(&mut self) { |
| 446 | if std::fs::write(self.workspace.join("release"), "release").is_err() { |
| 447 | return; |
| 448 | } |
| 449 | // Keep the release file alive during assertion unwinding too. |
| 450 | // This wait must not panic, and is bounded beyond the hooks' |
| 451 | // own ten-second timeout if a child cannot report completion. |
| 452 | let deadline = Instant::now() + Duration::from_secs(12); |
| 453 | while Instant::now() < deadline { |
| 454 | let released = std::fs::read_dir(&self.workspace) |
| 455 | .map(|entries| { |
| 456 | entries |
| 457 | .filter_map(Result::ok) |
| 458 | .filter(|entry| { |
| 459 | entry |
| 460 | .path() |
| 461 | .extension() |
| 462 | .is_some_and(|ext| ext == "released") |
| 463 | }) |
| 464 | .count() |
| 465 | }) |
| 466 | .unwrap_or_default(); |
| 467 | if released >= self.submitted { |
| 468 | return; |
| 469 | } |
| 470 | std::thread::sleep(Duration::from_millis(10)); |
| 471 | } |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | let _env_lock = crate::test_support::lock_test_env(); |
| 476 | let dir = tempfile::tempdir().expect("isolated workspace"); |
| 477 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path()); |
| 478 | let mut release = ReleaseBarriers { |
| 479 | workspace: dir.path().to_path_buf(), |
| 480 | submitted: 0, |
| 481 | }; |
| 482 | std::fs::write( |
| 483 | dir.path().join("receipt.sh"), |
| 484 | r#"set -eu |
| 485 | if [ "$1" = barrier ]; then |
| 486 | barrier=$(mktemp ./barrier.XXXXXX) |
| 487 | mv "$barrier" "$barrier.ready" |
| 488 | while [ ! -f ./release ]; do sleep 0.01; done |
| 489 | mv "$barrier.ready" "$barrier.released" |
| 490 | exit 0 |
| 491 | fi |
| 492 | receipt=$(mktemp ./receipt.XXXXXX) |
| 493 | { printf '%s\n' "$1"; cat; } > "$receipt" |
| 494 | mv "$receipt" "$receipt.done" |
| 495 | "#, |
| 496 | ) |
| 497 | .expect("receipt command"); |
| 498 | let mut app = App::new( |
| 499 | crate::test_support::test_tui_options(dir.path()), |
| 500 | &crate::config::Config::default(), |
| 501 | ); |
| 502 | app.hooks = HookExecutor::new( |
| 503 | HooksConfig { |
| 504 | enabled: true, |
| 505 | hooks: vec![ |
| 506 | Hook::new(HookEvent::SessionBusy, "sh ./receipt.sh session_busy"), |
| 507 | Hook::new( |
| 508 | HookEvent::WaitingForUser, |
| 509 | "sh ./receipt.sh waiting_for_user", |
| 510 | ), |
| 511 | Hook::new(HookEvent::SessionIdle, "sh ./receipt.sh session_idle"), |
| 512 | Hook::new(HookEvent::SessionEnd, "sh ./receipt.sh barrier").with_timeout(10), |
| 513 | ], |
| 514 | ..HooksConfig::default() |
| 515 | }, |
| 516 | dir.path().to_path_buf(), |
| 517 | ); |
| 518 | |
| 519 | // Startup is silent in every possible initial state, including a |
| 520 | // restored busy or waiting session. Repeated observations stay silent. |
| 521 | for state in [TurnState::Idle, TurnState::InProgress, TurnState::Waiting] { |
| 522 | app.is_loading = state != TurnState::Idle; |
| 523 | app.goal_continuation_waiting = state == TurnState::Waiting; |
| 524 | let mut previous = None; |
| 525 | execute_session_state_transition_hooks(&app, &mut previous); |
| 526 | execute_session_state_transition_hooks(&app, &mut previous); |
| 527 | assert_eq!(previous, Some(state)); |
| 528 | } |
| 529 | app.is_loading = false; |
| 530 | app.goal_continuation_waiting = false; |
| 531 | let mut previous = None; |
| 532 | execute_session_state_transition_hooks(&app, &mut previous); |
| 533 | |
| 534 | let mut seen = std::collections::HashSet::new(); |
| 535 | let mut receipts = Vec::new(); |
| 536 | for state in [ |
| 537 | TurnState::InProgress, |
| 538 | TurnState::Waiting, |
| 539 | TurnState::InProgress, |
| 540 | TurnState::Idle, |
| 541 | ] { |
| 542 | app.is_loading = state != TurnState::Idle; |
| 543 | app.runtime_turn_status = Some( |
| 544 | if state == TurnState::Idle { |
| 545 | "completed" |
| 546 | } else { |
| 547 | "in_progress" |
| 548 | } |
| 549 | .to_string(), |
| 550 | ); |
| 551 | app.pending_user_input_prompt = (state == TurnState::Waiting).then(|| { |
| 552 | ( |
| 553 | "hook-fixture-question".to_string(), |
| 554 | crate::tools::user_input::UserInputRequest { |
| 555 | questions: Vec::new(), |
| 556 | }, |
| 557 | ) |
| 558 | }); |
| 559 | execute_session_state_transition_hooks(&app, &mut previous); |
| 560 | execute_session_state_transition_hooks(&app, &mut previous); |
| 561 | assert_eq!(previous, Some(state)); |
| 562 | // Advance only after this command records its payload. Concurrent |
| 563 | // dispatcher workers do not promise command completion order. |
| 564 | let paths = wait_for_paths( |
| 565 | dir.path(), |
| 566 | "done", |
| 567 | receipts.len() + 1, |
| 568 | Instant::now() + Duration::from_secs(2), |
| 569 | ); |
| 570 | assert_eq!(paths.len(), receipts.len() + 1, "extra transition command"); |
| 571 | let path = paths |
| 572 | .into_iter() |
| 573 | .find(|path| !seen.contains(path)) |
| 574 | .expect("new receipt"); |
| 575 | let raw = std::fs::read_to_string(&path).expect("atomic receipt"); |
| 576 | let (event, payload) = raw.split_once('\n').expect("event and JSON stdin"); |
| 577 | receipts.push(( |
| 578 | event.to_string(), |
| 579 | serde_json::from_str::<serde_json::Value>(payload).expect("JSON stdin"), |
| 580 | )); |
| 581 | seen.insert(path); |
| 582 | } |
| 583 | |
| 584 | // Two foreground barriers park both persistent dispatcher workers. |
| 585 | // FIFO receipt of jobs then proves all preceding jobs have completed, |
| 586 | // including any erroneous startup or same-state submission. The total |
| 587 | // deadline is shorter than either barrier's timeout, so one worker |
| 588 | // cannot time out and masquerade as both workers becoming ready. |
| 589 | let deadline = Instant::now() + Duration::from_secs(2); |
| 590 | for _ in 0..2 { |
| 591 | app.hooks |
| 592 | .submit_observer(HookEvent::SessionEnd, app.base_hook_context()) |
| 593 | .expect("barrier submission"); |
| 594 | release.submitted += 1; |
| 595 | } |
| 596 | wait_for_paths(dir.path(), "ready", 2, deadline); |
| 597 | let completed_count = paths_with_extension(dir.path(), "done").len(); |
| 598 | drop(release); |
| 599 | wait_for_paths( |
| 600 | dir.path(), |
| 601 | "released", |
| 602 | 2, |
| 603 | Instant::now() + Duration::from_secs(2), |
| 604 | ); |
| 605 | |
| 606 | assert_eq!( |
| 607 | completed_count, 4, |
| 608 | "startup and same-state calls must be silent" |
| 609 | ); |
| 610 | assert_eq!( |
| 611 | receipts, |
| 612 | vec![ |
| 613 | ( |
| 614 | "session_busy".to_string(), |
| 615 | serde_json::json!({"from": "idle", "to": "in_progress"}) |
| 616 | ), |
| 617 | ( |
| 618 | "waiting_for_user".to_string(), |
| 619 | serde_json::json!({"from": "in_progress", "to": "waiting", "reason": "user_input"}) |
| 620 | ), |
| 621 | ( |
| 622 | "session_busy".to_string(), |
| 623 | serde_json::json!({"from": "waiting", "to": "in_progress"}) |
| 624 | ), |
| 625 | ( |
| 626 | "session_idle".to_string(), |
| 627 | serde_json::json!({"from": "in_progress", "to": "idle", "last_turn_status": "completed"}) |
| 628 | ), |
| 629 | ] |
| 630 | ); |
| 631 | } |
| 632 | |
| 633 | fn test_app() -> App { |
| 634 | let config = crate::config::Config::default(); |
| 635 | App::new( |
| 636 | crate::test_support::test_tui_options(std::env::current_dir().unwrap()), |
| 637 | &config, |
| 638 | ) |
| 639 | } |
| 640 | |
| 641 | #[test] |
| 642 | fn turn_state_projection_covers_every_wait_on_the_person() { |
| 643 | let mut app = test_app(); |
| 644 | assert_eq!(turn_state_from_app(&app), TurnState::Idle); |
| 645 | |
| 646 | app.is_loading = true; |
| 647 | assert_eq!(turn_state_from_app(&app), TurnState::InProgress); |
| 648 | app.runtime_turn_status = Some("in_progress".to_string()); |
| 649 | |
| 650 | app.pending_user_input_prompt = Some(( |
| 651 | "q1".to_string(), |
| 652 | crate::tools::user_input::UserInputRequest { |
| 653 | questions: Vec::new(), |
| 654 | }, |
| 655 | )); |
| 656 | assert_eq!(turn_state_from_app(&app), TurnState::Waiting); |
| 657 | assert_eq!( |
| 658 | session_wait_reason(&app), |
| 659 | Some(SessionWaitReason::UserInput) |
| 660 | ); |
| 661 | assert_eq!( |
| 662 | session_state_transition_event(TurnState::InProgress, turn_state_from_app(&app)), |
| 663 | Some(crate::hooks::HookEvent::WaitingForUser) |
| 664 | ); |
| 665 | app.pending_user_input_prompt = None; |
| 666 | assert_eq!(turn_state_from_app(&app), TurnState::InProgress); |
| 667 | |
| 668 | app.goal_continuation_waiting = true; |
| 669 | assert_eq!(turn_state_from_app(&app), TurnState::Waiting); |
| 670 | assert_eq!( |
| 671 | session_wait_reason(&app), |
| 672 | Some(SessionWaitReason::GoalContinuation) |
| 673 | ); |
| 674 | app.goal_continuation_waiting = false; |
| 675 | |
| 676 | app.view_stack.push( |
| 677 | crate::tui::approval::ApprovalView::new_with_default_selection( |
| 678 | crate::tui::approval::ApprovalRequest::new_with_intent( |
| 679 | "a1", |
| 680 | "exec_shell", |
| 681 | "run the tests", |
| 682 | &serde_json::json!({"cmd": "cargo test"}), |
| 683 | "key", |
| 684 | None, |
| 685 | &app.workspace, |
| 686 | ), |
| 687 | codewhale_localization::Locale::En, |
| 688 | crate::config::ApprovalDefaultSelection::default(), |
| 689 | ), |
| 690 | ); |
| 691 | assert_eq!(turn_state_from_app(&app), TurnState::Waiting); |
| 692 | assert_eq!(session_wait_reason(&app), Some(SessionWaitReason::Approval)); |
| 693 | } |
| 694 | } |
| 695 |