返回 CodeWhale
observer_hooks.rs
根目录 / crates / tui / src / tui / ui / observer_hooks.rs
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
695 lines RUST