返回 CodeWhale
session_state.rs
根目录 / crates / tui / src / tui / ui / session_state.rs
1 //! Session durability: snapshot/restore, recovery after a crash or stall,
2 //! and workspace/worktree switching.
3 //!
4 //! Moved verbatim out of `ui.rs`.
5
6 use super::*;
7
8 pub(crate) struct OfflineQueueTransition {
9 lease: Arc<crate::session_manager::OfflineQueueLease>,
10 restored: Option<OfflineQueueState>,
11 }
12
13 /// A session load/resume failure must survive past the next footer update.
14 ///
15 /// The status line is replaced almost immediately, which left a failed
16 /// resume looking like a silent new session — the screen even offered to
17 /// resume the id it had just created (#6138). Keep both: the transcript
18 /// error cell is the durable record, the status line the immediate one.
19 pub(crate) fn surface_session_load_failure(app: &mut App, message: String) {
20 app.add_message(crate::tui::history::HistoryCell::Error {
21 message: message.clone(),
22 severity: crate::error_taxonomy::ErrorSeverity::Error,
23 });
24 app.status_message = Some(message);
25 }
26
27 /// Complete all fallible queue work before a session switch mutates the App.
28 /// A second editor must fail without touching either composer or queue file.
29 pub(crate) fn prepare_offline_queue_transition(
30 app: &App,
31 session_id: &str,
32 ) -> Result<Option<OfflineQueueTransition>, String> {
33 if app
34 .offline_queue_lease
35 .as_ref()
36 .is_some_and(|lease| lease.session_id() == session_id)
37 {
38 return Ok(None);
39 }
40 let manager = SessionManager::default_location().map_err(|error| error.to_string())?;
41 let lease = manager
42 .acquire_offline_queue_lease(session_id)
43 .map_err(|error| error.to_string())?;
44 let restored = manager
45 .load_offline_queue_state(session_id)
46 .map_err(|error| {
47 format!("Could not restore queued input for session {session_id}: {error}")
48 })?;
49 Ok(Some(OfflineQueueTransition { lease, restored }))
50 }
51
52 pub(crate) fn install_offline_queue_transition(
53 app: &mut App,
54 transition: Option<OfflineQueueTransition>,
55 ) -> bool {
56 let Some(transition) = transition else {
57 return false;
58 };
59 // The request retains the old Arc until the actor finishes its write.
60 // Acquiring the next lease does not release the previous editor early.
61 persist_offline_queue_state(app);
62 if app.queued_draft.take().is_some() {
63 app.clear_input();
64 }
65 app.queued_messages.clear();
66 app.current_session_id = Some(transition.lease.session_id().to_string());
67 app.offline_queue_lease = Some(transition.lease);
68 transition
69 .restored
70 .is_some_and(|state| restore_matching_offline_queue_state(app, state))
71 }
72
73 /// The editable composer is the durable draft. Keep `queued_draft` itself as
74 /// the original message so Escape can still cancel the edit in this window.
75 pub(crate) fn offline_queue_projection(
76 app: &App,
77 ) -> (VecDeque<QueuedMessage>, Option<QueuedMessage>) {
78 let draft = app.queued_draft.as_ref().map(|original| {
79 let mut edited = original.clone();
80 edited.display.clone_from(&app.input);
81 edited
82 });
83 (app.queued_messages.clone(), draft)
84 }
85
86 pub(crate) async fn publish_pending_work_projection(app: &mut App) -> Result<bool, String> {
87 let Some(work) = app.runtime_services.work.clone() else {
88 return Ok(false);
89 };
90 let published = work.publish_pending().await?;
91 Ok(published)
92 }
93
94 pub(crate) async fn persist_pending_work_checkpoint(app: &mut App) -> Result<bool, String> {
95 let Some(work) = app.runtime_services.work.clone() else {
96 return Ok(false);
97 };
98 if !work.has_pending_publish() {
99 return Ok(false);
100 }
101 let manager = SessionManager::default_location()
102 .map_err(|err| format!("could not open sessions directory: {err}"))?;
103 let session = build_session_snapshot(app, &manager)?;
104 if app.current_session_id.is_none() {
105 app.current_session_id = Some(session.metadata.id.clone());
106 }
107 if !persistence_actor::try_persist(PersistRequest::SaveCheckpoint { session }) {
108 return Err("persistence actor is unavailable".to_string());
109 }
110 publish_pending_work_projection(app).await
111 }
112
113 pub(crate) fn persist_with_pending_work_boundary(
114 app: &mut App,
115 request: PersistRequest,
116 ) -> Result<(), String> {
117 let has_pending = app
118 .runtime_services
119 .work
120 .as_ref()
121 .is_some_and(|work| work.has_pending_publish());
122 if !has_pending {
123 persistence_actor::persist(request);
124 return Ok(());
125 }
126 if !persistence_actor::try_persist(request) {
127 return Err("persistence actor is unavailable".to_string());
128 }
129 app.publish_pending_work_state().map(|_| ())
130 }
131
132 pub(crate) fn restore_matching_offline_queue_state(
133 app: &mut App,
134 state: OfflineQueueState,
135 ) -> bool {
136 if state.session_id.as_deref() != app.current_session_id.as_deref()
137 || state.session_id.is_none()
138 {
139 return false;
140 }
141 app.queued_messages = state
142 .messages
143 .into_iter()
144 .map(queued_session_to_ui)
145 .collect();
146 if let Some(draft) = state.draft.map(queued_session_to_ui) {
147 app.input.clone_from(&draft.display);
148 app.cursor_position = app.input.chars().count();
149 app.active_skill.clone_from(&draft.skill_instruction);
150 app.active_skill_provenance
151 .clone_from(&draft.skill_provenance);
152 app.queued_draft = Some(draft);
153 } else {
154 app.queued_draft = None;
155 }
156 app.needs_redraw = true;
157 true
158 }
159
160 /// A Running sub-agent older than every child's wall budget cannot still be
161 /// doing bounded work: its terminal event was lost or its task is wedged
162 /// (#6184 H2). The default child wall budget plus generous grace.
163 pub(crate) const SUBAGENT_SUSPECT_AFTER: Duration =
164 crate::tools::subagent::DEFAULT_CHILD_WALL_TIME.saturating_add(Duration::from_secs(5 * 60));
165
166 /// Running sub-agents that are past their bound: shown as suspect, and never a
167 /// veto on turn recovery. A prior-session row still marked Running cannot be
168 /// live in this process at all.
169 pub(crate) fn suspect_running_agents(app: &App, now: Instant) -> Vec<String> {
170 app.subagent_cache
171 .iter()
172 .filter(|agent| matches!(agent.status, SubAgentStatus::Running))
173 .filter(|agent| match agent.started_at {
174 Some(started) => now.saturating_duration_since(started) > SUBAGENT_SUSPECT_AFTER,
175 None => agent.from_prior_session,
176 })
177 .map(|agent| agent.agent_id.clone())
178 .collect()
179 }
180
181 /// Running sub-agents that still legitimately hold the turn open.
182 pub(crate) fn live_running_agent_count(app: &App, now: Instant) -> usize {
183 let suspects = suspect_running_agents(app, now);
184 let mut ids: std::collections::HashSet<&str> =
185 app.agent_progress.keys().map(String::as_str).collect();
186 for agent in app
187 .subagent_cache
188 .iter()
189 .filter(|agent| matches!(agent.status, SubAgentStatus::Running))
190 {
191 ids.insert(agent.agent_id.as_str());
192 }
193 ids.retain(|id| !suspects.iter().any(|suspect| suspect == id));
194 ids.len()
195 }
196
197 /// Queued follow-ups the stalled turn is holding back, as a sentence suffix.
198 fn held_queue_note(app: &App) -> String {
199 match app.queued_messages.len() {
200 0 => String::new(),
201 1 => " 1 queued message is held until the turn ends.".to_string(),
202 n => format!(" {n} queued messages are held until the turn ends."),
203 }
204 }
205
206 /// Log, record under `crashes/`, and name a stall the UI watchdog saw.
207 fn record_ui_stall(app: &App, phase: &str, since_progress: Duration, bound: Duration) {
208 let suspects = suspect_running_agents(app, Instant::now());
209 let detail = (!suspects.is_empty()).then(|| {
210 format!(
211 "sub-agent(s) past their bound, treated as suspect: {}",
212 suspects.join(", ")
213 )
214 });
215 crate::core::engine::turn_heartbeat::report_stall(
216 &crate::core::engine::turn_heartbeat::StallReport {
217 source: "ui",
218 phase: phase.to_string(),
219 detail,
220 turn_id: app.runtime_turn_id.clone(),
221 provider_request: app
222 .active_turn
223 .as_ref()
224 .and_then(|turn| turn.route.as_ref())
225 .map(|route| format!("{} / {}", route.provider_identity, route.model)),
226 since_progress,
227 bound: Some(bound),
228 },
229 );
230 }
231
232 /// The UI watchdog, supervised by the engine heartbeat (#6184). Suspect
233 /// sub-agents no longer veto recovery, and an engine-reported stall is shown
234 /// with the phase it stalled in.
235 ///
236 /// Recovering a turn that had started is all-or-nothing (#6800): the engine's
237 /// turn is cancelled with the UI's, through the cancellation the cancel key
238 /// issues. Clearing only the UI left the engine owning the turn, so the next
239 /// message was refused and no outcome was ever recorded. The engine's
240 /// terminal event for that turn then arrives as it does after a local cancel.
241 ///
242 /// Known limitation: a turn wedged somewhere that does not observe
243 /// cancellation still holds the engine; this only removes the disagreement.
244 pub(crate) fn reconcile_turn_liveness_supervised(
245 app: &mut App,
246 now: Instant,
247 heartbeat: &crate::core::engine::turn_heartbeat::HeartbeatSnapshot,
248 engine: &EngineHandle,
249 ) -> bool {
250 let turn_in_progress = matches!(app.runtime_turn_status.as_deref(), Some("in_progress"));
251 if (app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")))
252 && let Some(stall) = heartbeat.stall.as_ref()
253 {
254 // Coalesced by text while visible, so one toast per stall episode.
255 let text = format!("{}{}", stall.status_line(), held_queue_note(app));
256 app.push_status_toast(text, StatusToastLevel::Error, None);
257 }
258 let has_live_agents = live_running_agent_count(app, now) > 0;
259 let recovered = reconcile_turn_liveness_with(app, now, has_live_agents, Some(heartbeat));
260 if recovered && turn_in_progress && app.runtime_turn_status.is_none() {
261 engine.cancel_with_reason(crate::core::engine::CancelReason::Stalled);
262 app.suppress_stream_events_until_turn_complete = true;
263 }
264 recovered
265 }
266
267 /// Unsupervised form (no engine heartbeat), kept for focused tests.
268 #[cfg(test)]
269 pub(crate) fn reconcile_turn_liveness(
270 app: &mut App,
271 now: Instant,
272 has_running_agents: bool,
273 ) -> bool {
274 reconcile_turn_liveness_with(app, now, has_running_agents, None)
275 }
276
277 pub(crate) fn reconcile_turn_liveness_with(
278 app: &mut App,
279 now: Instant,
280 has_running_agents: bool,
281 heartbeat: Option<&crate::core::engine::turn_heartbeat::HeartbeatSnapshot>,
282 ) -> bool {
283 // The engine is inside a wait it bounds itself and has not reported as
284 // overdue (a quiet model, a live stream). Its watchdog owns that bound;
285 // the UI does not second-guess it with a timer of its own.
286 let engine_owns_wait = heartbeat.is_some_and(|snapshot| snapshot.engine_owns_live_wait());
287 if app.is_loading
288 && app.runtime_turn_status.is_none()
289 && !has_running_agents
290 && !app.is_compacting
291 && !app.is_purging
292 && app.dispatch_started_at.is_some_and(|started| {
293 now.saturating_duration_since(started) > DISPATCH_WATCHDOG_TIMEOUT
294 })
295 {
296 if let Some(started) = app.dispatch_started_at {
297 record_ui_stall(
298 app,
299 "while dispatching the message to the engine",
300 now.saturating_duration_since(started),
301 DISPATCH_WATCHDOG_TIMEOUT,
302 );
303 }
304 // #2739: the user's prompt was already appended to api_messages
305 // before dispatch, but the turn never reached `in_progress`. Persist
306 // it before clearing turn state so `--continue` keeps the prompt
307 // instead of loading the previous save.
308 persist_recovery_snapshot(app);
309 app.is_loading = false;
310 app.dispatch_started_at = None;
311 app.turn_started_at = None;
312 app.turn_last_activity_at = None;
313 app.pending_turn_route = None;
314 app.pending_auto_route_receipt = None;
315 app.active_turn = None;
316 app.suppress_stream_events_until_turn_complete = false;
317 app.push_status_toast(
318 "Turn dispatch timed out; the engine may have stopped. Please try again.",
319 StatusToastLevel::Error,
320 None,
321 );
322 return true;
323 }
324
325 if app.is_loading
326 && matches!(
327 app.runtime_turn_status.as_deref(),
328 Some("completed" | "interrupted" | "failed")
329 )
330 && !has_running_agents
331 && !app.is_compacting
332 && !app.is_purging
333 {
334 app.is_loading = false;
335 app.dispatch_started_at = None;
336 app.turn_started_at = None;
337 app.turn_last_activity_at = None;
338 app.pending_turn_route = None;
339 app.pending_auto_route_receipt = None;
340 app.active_turn = None;
341 app.suppress_stream_events_until_turn_complete = false;
342 app.push_status_toast(
343 "Recovered from an inconsistent busy state.",
344 StatusToastLevel::Warning,
345 None,
346 );
347 return true;
348 }
349
350 // Branch 3: turn started but never completed — engine may have
351 // panicked, sub-agent may be stuck, or the completion event was lost.
352 if app.is_loading
353 && matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
354 && !has_running_agents
355 && !engine_owns_wait
356 && !app.is_compacting
357 && !active_turn_has_running_tool(app)
358 && let Some(last_activity) = app.turn_last_activity_at.or(app.turn_started_at)
359 && now.saturating_duration_since(last_activity) > turn_stall_watchdog_timeout(app)
360 {
361 record_ui_stall(
362 app,
363 "waiting for the turn's completion signal",
364 now.saturating_duration_since(last_activity),
365 turn_stall_watchdog_timeout(app),
366 );
367 recover_stalled_runtime_turn(
368 app,
369 "Turn stalled — no completion signal received. Please try again.",
370 StatusToastLevel::Error,
371 );
372 return true;
373 }
374
375 if app.is_loading
376 && matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
377 && !has_running_agents
378 && !app.is_compacting
379 && !app.is_purging
380 && active_turn_has_running_tool(app)
381 && let Some(last_activity) = app.turn_last_activity_at.or(app.turn_started_at)
382 && now.saturating_duration_since(last_activity) > TOOL_HANG_WATCHDOG_TIMEOUT
383 {
384 record_ui_stall(
385 app,
386 "while a tool ran with no progress",
387 now.saturating_duration_since(last_activity),
388 TOOL_HANG_WATCHDOG_TIMEOUT,
389 );
390 recover_stalled_runtime_turn(
391 app,
392 "Tool stalled with no progress for 10m — recovered; the command may still be running in the background. Use exec_shell_cancel or retry.",
393 StatusToastLevel::Error,
394 );
395 return true;
396 }
397
398 false
399 }
400
401 /// #2739: persist the current in-memory session state before a recovery or
402 /// cancellation path clears turn bookkeeping. Without this snapshot, the
403 /// just-finalised partial turn lives only in `app.api_messages` and is never
404 /// written to disk, so `--continue` loads the *previous* save — effectively
405 /// losing the entire in-progress turn.
406 pub(crate) fn persist_recovery_snapshot(app: &mut App) {
407 if let Ok(manager) = SessionManager::default_location()
408 && let Ok(session) = build_session_snapshot(app, &manager)
409 {
410 if app.current_session_id.is_none() {
411 app.current_session_id = Some(session.metadata.id.clone());
412 }
413 if let Err(err) =
414 persist_with_pending_work_boundary(app, PersistRequest::SaveCheckpoint { session })
415 {
416 app.status_message = Some(format!(
417 "To-do list update pending: recovery snapshot could not be queued ({err})"
418 ));
419 }
420 }
421 }
422
423 pub(crate) fn persist_full_reset_snapshot(app: &mut App) {
424 if let Ok(manager) = SessionManager::default_location()
425 && let Ok(session) = build_session_snapshot(app, &manager)
426 {
427 app.current_session_id = Some(session.metadata.id.clone());
428 if let Err(err) =
429 persist_with_pending_work_boundary(app, PersistRequest::SessionSnapshot(session))
430 {
431 app.status_message = Some(format!(
432 "To-do list update pending: reset snapshot could not be queued ({err})"
433 ));
434 }
435 }
436 // `/clear` and `/new` are explicit boundaries. Never let an older
437 // in-flight checkpoint resurrect the session the user just discarded,
438 // even if the replacement snapshot could not be constructed.
439 // `build_session_snapshot` reuses `current_session_id`, so this id is the
440 // discarded session's id whether or not the snapshot above succeeded.
441 if let Some(session_id) = app.current_session_id.clone() {
442 persistence_actor::persist(PersistRequest::ClearCheckpoint { session_id });
443 }
444 }
445
446 pub(crate) fn maybe_throttled_recovery_snapshot(
447 app: &mut App,
448 now: Instant,
449 last_snapshot_at: &mut Option<Instant>,
450 ) {
451 if !app.is_loading && !matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
452 return;
453 }
454 if last_snapshot_at
455 .is_some_and(|last| now.saturating_duration_since(last) < RECOVERY_SNAPSHOT_INTERVAL)
456 {
457 return;
458 }
459 persist_recovery_snapshot(app);
460 *last_snapshot_at = Some(now);
461 }
462
463 pub(crate) fn recover_stalled_runtime_turn(app: &mut App, message: &str, level: StatusToastLevel) {
464 // Capture the turn identity before the reset below clears it; the
465 // outbox event must name the turn that stalled.
466 let stalled_turn_id = app.runtime_turn_id.clone();
467 let stalled_session_id = app.hooks.session_id().to_string();
468 // Finalize in-flight thinking / assistant / tool cells so the
469 // transcript doesn't show permanent spinners after recovery.
470 streaming_thinking::finalize_current(app);
471 app.finalize_streaming_assistant_as_interrupted();
472 app.finalize_active_cell_as_interrupted();
473 app.streaming_state.reset();
474 app.streaming_message_index = None;
475 app.streaming_thinking_active_entry = None;
476
477 // #2739: persist the partial turn's api_messages before clearing
478 // turn state. Without this snapshot the stalled/cancelled turn's
479 // messages are held only in memory and --continue sees the
480 // *previous* save, losing the entire in-progress turn.
481 persist_recovery_snapshot(app);
482
483 app.is_loading = false;
484 // #6800: fail an unadmitted dispatch back now instead of after its bound.
485 app.cancel_in_flight_dispatch();
486 app.turn_started_at = None;
487 app.turn_last_activity_at = None;
488 app.runtime_turn_status = None;
489 app.runtime_turn_id = None;
490 app.dispatch_started_at = None;
491 app.pending_turn_route = None;
492 app.pending_auto_route_receipt = None;
493 app.active_turn = None;
494 app.suppress_stream_events_until_turn_complete = false;
495 // Per-turn scroll lock — clear so the next turn auto-scrolls.
496 app.user_scrolled_during_stream = false;
497 // #6184: queued follow-ups drain only on a TurnComplete this recovered
498 // turn will never send. Hand the latest one back to the composer so one
499 // Enter resends it (the rest drain after that turn), and say so.
500 let held = app.queued_messages.len();
501 let message = if held > 0 && app.pop_last_queued_into_draft() {
502 let rest = held - 1;
503 let tail = if rest == 0 {
504 String::new()
505 } else {
506 format!(" {rest} more queued message(s) send after it.")
507 };
508 format!(
509 "{message} Your queued message is back in the composer — press Enter to resend it.{tail}"
510 )
511 } else {
512 format!("{message}{}", held_queue_note(app))
513 };
514 let message = message.as_str();
515 app.push_status_toast(message, level, None);
516 // Lifecycle outbox (`[lifecycle_outbox]`): the first scriptable stall
517 // signal. Until now a wedged turn was only visible as this toast; with
518 // the outbox enabled a supervisor can react to the same moment.
519 // No-op when the feature is disabled.
520 app.lifecycle_outbox.emit(codewhale_hooks::LifecycleEvent {
521 event: "turn_stalled".to_string(),
522 kind: "turn.stalled".to_string(),
523 thread_id: stalled_session_id,
524 turn_id: stalled_turn_id,
525 item_id: None,
526 payload: serde_json::json!({
527 "message": codewhale_hooks::bounded_text(
528 message,
529 codewhale_hooks::OUTBOX_DETAIL_MAX_CHARS,
530 ),
531 "workspace": app.workspace.display().to_string(),
532 }),
533 });
534 }
535
536 pub(crate) fn recover_engine_event_disconnect(app: &mut App) -> bool {
537 let had_live_work = app.is_loading
538 || app.is_compacting
539 || app.manual_compaction_queued
540 || app.is_purging
541 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
542 || app.pending_turn_route.is_some()
543 || app.active_turn.is_some()
544 || app.suppress_stream_events_until_turn_complete
545 || app.streaming_message_index.is_some()
546 || app.streaming_thinking_active_entry.is_some()
547 || app
548 .active_cell
549 .as_ref()
550 .is_some_and(|cell| !cell.is_empty());
551
552 if !had_live_work {
553 return false;
554 }
555
556 streaming_thinking::finalize_current(app);
557 app.finalize_streaming_assistant_as_interrupted();
558 app.finalize_active_cell_as_interrupted();
559 app.streaming_state.reset();
560 app.streaming_message_index = None;
561 app.streaming_thinking_active_entry = None;
562
563 // #2739: persist partial turn before clearing state.
564 persist_recovery_snapshot(app);
565
566 app.is_loading = false;
567 app.is_compacting = false;
568 app.active_compaction = None;
569 app.manual_compaction_queued = false;
570 app.deferred_manual_compaction = None;
571 app.is_purging = false;
572 app.turn_started_at = None;
573 app.turn_last_activity_at = None;
574 app.runtime_turn_status = None;
575 app.runtime_turn_id = None;
576 app.dispatch_started_at = None;
577 app.pending_turn_route = None;
578 app.pending_auto_route_receipt = None;
579 app.active_turn = None;
580 app.suppress_stream_events_until_turn_complete = false;
581 app.user_scrolled_during_stream = false;
582
583 for msg in app.drain_pending_steers() {
584 app.queue_message(msg);
585 }
586
587 app.add_message(HistoryCell::Error {
588 message: "Engine stopped before completing the turn. Check ~/.codewhale/crashes and retry."
589 .to_string(),
590 severity: crate::error_taxonomy::ErrorSeverity::Error,
591 });
592 app.push_status_toast(
593 "Engine stopped before completing the turn.",
594 StatusToastLevel::Error,
595 None,
596 );
597 true
598 }
599
600 pub(crate) fn capture_turn_started_metadata(app: &mut App, event: &EngineEvent) {
601 match event {
602 EngineEvent::TurnStarted {
603 turn_id,
604 created_at,
605 route,
606 submission_id: _,
607 } => {
608 app.ocean_completion_started_at = None;
609 let auto_route_receipt = if route.as_ref().is_some_and(|route| route.auto_model) {
610 app.pending_auto_route_receipt.take()
611 } else if route.is_some() {
612 app.pending_auto_route_receipt = None;
613 None
614 } else {
615 None
616 };
617 // Bind the prompt-suggestion authority to the receipt the engine minted
618 // from the client it installed for this turn. Deliberately not read
619 // from `config`: web config events are drained ahead of engine events,
620 // so config here may already describe a different key or endpoint than
621 // the one this turn is actually running on.
622 let suggestion_authority = route
623 .as_ref()
624 .and_then(crate::tui::prompt_suggestion::capture_route_authority);
625 app.active_turn = Some(ActiveTurnMetadata {
626 turn_id: turn_id.clone(),
627 created_at: *created_at,
628 route: route.clone(),
629 auto_route_receipt,
630 suggestion_authority,
631 });
632 app.pending_turn_route = None;
633 }
634 // The dispatch boundary is the billing truth: refresh the active turn's
635 // route with the envelope that was actually put on the wire. Receipts
636 // already taken at `TurnStarted` are preserved — this event narrows the
637 // route, it never re-opens an authority decision.
638 EngineEvent::RouteDispatched { turn_id, route } => {
639 if let Some(active) = app
640 .active_turn
641 .as_mut()
642 .filter(|active| active.turn_id == *turn_id)
643 {
644 if route.auto_model && active.auto_route_receipt.is_none() {
645 active.auto_route_receipt = app.pending_auto_route_receipt.take();
646 } else if !route.auto_model {
647 app.pending_auto_route_receipt = None;
648 active.auto_route_receipt = None;
649 }
650 if active.suggestion_authority.is_none() {
651 active.suggestion_authority =
652 crate::tui::prompt_suggestion::capture_route_authority(route);
653 }
654 active.route = Some(route.clone());
655 }
656 }
657 _ => {}
658 }
659 }
660
661 pub(crate) fn record_turn_activity(app: &mut App, event: &EngineEvent, now: Instant) {
662 if matches!(event, EngineEvent::TurnStarted { .. }) {
663 app.turn_last_activity_at = Some(now);
664 return;
665 }
666
667 if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
668 app.turn_last_activity_at = Some(now);
669 }
670 }
671
672 pub(crate) fn persist_offline_queue_state(app: &App) {
673 let Some(lease) = app
674 .offline_queue_lease
675 .as_ref()
676 .filter(|lease| app.current_session_id.as_deref() == Some(lease.session_id()))
677 else {
678 return;
679 };
680 if app.queued_messages.is_empty() && app.queued_draft.is_none() {
681 persistence_actor::persist(PersistRequest::ClearOfflineQueue {
682 lease: Arc::clone(lease),
683 });
684 return;
685 }
686 let (messages, draft) = offline_queue_projection(app);
687 let state = OfflineQueueState {
688 messages: messages.iter().map(queued_ui_to_session).collect(),
689 draft: draft.as_ref().map(queued_ui_to_session),
690 ..OfflineQueueState::default()
691 };
692 persistence_actor::persist(PersistRequest::OfflineQueue {
693 state,
694 lease: Arc::clone(lease),
695 });
696 }
697
698 pub(crate) fn restore_queued_message(app: &mut App, index: Option<usize>, message: QueuedMessage) {
699 if let Some(index) = index
700 && index <= app.queued_messages.len()
701 {
702 app.queued_messages.insert(index, message);
703 } else {
704 app.queue_message(message);
705 }
706 }
707
708 pub(crate) fn restore_queued_or_draft_message(
709 app: &mut App,
710 recovery: DispatchRecovery,
711 message: QueuedMessage,
712 ) {
713 match recovery {
714 DispatchRecovery::Draft => {
715 app.input.clone_from(&message.display);
716 app.cursor_position = app.input.chars().count();
717 app.active_skill = message.skill_instruction.clone();
718 app.active_skill_provenance = message.skill_provenance.clone();
719 app.queued_draft = Some(message);
720 app.needs_redraw = true;
721 }
722 DispatchRecovery::Queued { restore_index } => {
723 restore_queued_message(app, restore_index, message);
724 }
725 DispatchRecovery::Immediate | DispatchRecovery::Initial => app.queue_message(message),
726 }
727 }
728
729 pub(crate) fn recover_unstarted_external_message(
730 app: &mut App,
731 message: QueuedMessage,
732 recovery: DispatchRecovery,
733 error: &str,
734 ) {
735 app.dispatch_in_flight = false;
736 match recovery {
737 DispatchRecovery::Immediate | DispatchRecovery::Initial => {
738 restore_failed_immediate_submit(app, message, &anyhow::Error::msg(error.to_string()));
739 }
740 DispatchRecovery::Draft => {
741 restore_queued_or_draft_message(app, recovery, message);
742 app.status_message = Some(format!("{error}; queued draft restored"));
743 }
744 DispatchRecovery::Queued { restore_index } => {
745 restore_queued_message(app, restore_index, message);
746 app.status_message = Some(format!(
747 "{error}; {} queued follow-up(s) restored",
748 app.queued_message_count()
749 ));
750 }
751 }
752 app.push_status_toast(
753 error.to_string(),
754 StatusToastLevel::Error,
755 Some(App::STICKY_ERROR_TTL_MS),
756 );
757 app.needs_redraw = true;
758 }
759
760 pub(crate) fn restore_message_submit_denial(
761 app: &mut App,
762 message: QueuedMessage,
763 recovery: DispatchRecovery,
764 ) {
765 let denial = app
766 .status_message
767 .clone()
768 .unwrap_or_else(|| "message_submit hook blocked submission".to_string());
769 app.dispatch_in_flight = false;
770 match recovery {
771 DispatchRecovery::Immediate | DispatchRecovery::Initial => {
772 app.restore_unsent_message(message);
773 }
774 DispatchRecovery::Draft => {
775 restore_queued_or_draft_message(app, recovery, message);
776 }
777 DispatchRecovery::Queued { restore_index } => {
778 restore_queued_message(app, restore_index, message);
779 }
780 }
781 app.status_message = Some(denial.clone());
782 app.push_status_toast(denial, StatusToastLevel::Warning, Some(6_000));
783 app.needs_redraw = true;
784 }
785
786 /// Resume one recent-work row from the startup card by session id. Mirrors
787 /// `/resume <id>`: the card dissolves and the saved session loads through
788 /// the normal `LoadSession` path; a session that vanished behind the card
789 /// leaves the card up with a status saying why instead of stranding the
790 /// user on an empty stage.
791 pub(crate) fn resume_launch_session(app: &mut App, session_id: &str) -> commands::CommandResult {
792 let failed = |app: &mut App, err: &str| {
793 app.launch.status = Some(
794 app.tr(MessageId::LaunchResumeFailed)
795 .replace("{error}", err),
796 );
797 commands::CommandResult::ok()
798 };
799 let manager = match crate::session_manager::SessionManager::default_location() {
800 Ok(manager) => manager,
801 Err(err) => return failed(app, &err.to_string()),
802 };
803 let saved = match manager.load_session_snapshot(session_id) {
804 Ok(saved) => saved,
805 Err(err) => return failed(app, &err.to_string()),
806 };
807 let path = manager
808 .sessions_dir()
809 .join(format!("{}.json", saved.metadata.id));
810 if !path.exists() {
811 return failed(app, "saved session file is gone");
812 }
813 app.launch.dissolve_card(app.ambient_clock_ms);
814 commands::CommandResult::action(AppAction::LoadSession(path))
815 }
816
817 /// `LaunchAction::McpRemedy` (#6085): type the remedy the problems row
818 /// prints into the composer — `/mcp login <name>` or `/mcp`. Typing beats
819 /// copying (no clipboard dependency over SSH), and the user reads the
820 /// command before a second Enter sends it.
821 pub(crate) fn type_launch_mcp_remedy(app: &mut App) {
822 let Some(command) = crate::tui::underwater::mcp_remedy_command(app) else {
823 return;
824 };
825 // Home can be revisited with an unsent draft. The manager exposes the
826 // same remedy without replacing user-authored composer content.
827 if !app.input.is_empty() {
828 app.launch.dissolve_card(app.ambient_clock_ms);
829 open_mcp_extensions(app);
830 return;
831 }
832 app.input = command;
833 app.cursor_position = app.input.chars().count();
834 app.launch.menu_selected = None;
835 app.launch.status = None;
836 }
837
838 pub(crate) fn begin_launch_session(
839 app: &mut App,
840 workspace: Option<PathBuf>,
841 ) -> commands::CommandResult {
842 let session_id = uuid::Uuid::new_v4().to_string();
843 let transition = match prepare_offline_queue_transition(app, &session_id) {
844 Ok(transition) => transition,
845 Err(error) => return commands::CommandResult::error(error),
846 };
847 install_offline_queue_transition(app, transition);
848 if let Some(workspace) = workspace {
849 app.workspace = workspace;
850 }
851 app.current_session_id = Some(session_id.clone());
852 app.current_session_metadata = None;
853 app.session_title = Some(app.tr(MessageId::SessionsNewSessionTitle).into_owned());
854 app.launch.dismiss();
855 app.launch.status = None;
856 app.status_message = None;
857 commands::CommandResult::action(AppAction::SyncSession {
858 session_id: Some(session_id),
859 messages: Vec::new(),
860 system_prompt: None,
861 model: app.model.clone(),
862 workspace: app.workspace.clone(),
863 mode: app.mode,
864 })
865 }
866
867 pub(crate) async fn sync_runtime_workspace_state(
868 task_manager: &SharedTaskManager,
869 workspace: PathBuf,
870 ) {
871 task_manager.set_default_workspace(workspace).await;
872 }
873
874 pub(crate) async fn switch_workspace(
875 app: &mut App,
876 engine_handle: &mut EngineHandle,
877 task_manager: &SharedTaskManager,
878 config: &Config,
879 workspace: PathBuf,
880 ) {
881 if app.is_loading {
882 app.status_message =
883 Some("Cannot switch workspace while a request is running.".to_string());
884 app.add_message(HistoryCell::System {
885 content: "Cannot switch workspace while a request is running.".to_string(),
886 });
887 return;
888 }
889
890 if app.workspace == workspace {
891 app.status_message = Some(format!("Workspace unchanged: {}", workspace.display()));
892 return;
893 }
894
895 apply_workspace_runtime_state(app, config, workspace.clone());
896 sync_runtime_workspace_state(task_manager, workspace.clone()).await;
897
898 let _ = engine_handle.send(Op::Shutdown).await;
899 let engine_config = build_engine_config(app, config);
900 *engine_handle = spawn_tui_engine(engine_config, config);
901 if !app.api_messages.is_empty() {
902 let _ = engine_handle
903 .send(Op::SyncSession {
904 session_id: app.current_session_id.clone(),
905 messages: app.api_messages.as_ref().clone(),
906 system_prompt: app.system_prompt.clone(),
907 system_prompt_override: false,
908 model: app.model.clone(),
909 workspace: workspace.clone(),
910 mode: app.mode,
911 })
912 .await;
913 }
914
915 app.add_message(HistoryCell::System {
916 content: format!("Switched workspace to {}", workspace.display()),
917 });
918 app.status_message = Some(format!("Workspace: {}", workspace.display()));
919 }
920
921 /// A message submitted with no usable key (#6566). Nothing reached a model:
922 /// the caller has already rolled back the optimistic echo, so the text goes
923 /// back into the composer — not lost, and sent once when the person presses
924 /// Enter after connecting, not doubled. One transcript line says what
925 /// happened and the provider picker opens.
926 ///
927 /// A new user never chose a provider, so the line does not name the built-in
928 /// default's key or print its help page. A returning user whose saved route
929 /// lost its key also gets the one command that saves it.
930 pub(crate) fn keep_unsent_message_for_connect(app: &mut App, message: QueuedMessage, error: &str) {
931 tracing::warn!(
932 error = %error,
933 "user message not sent: no usable credential; restored to composer"
934 );
935 app.restore_unsent_message(message);
936
937 let new_user = app.onboarding_had_provider_step;
938 let mut content = app.tr(MessageId::DispatchNotSentNoModel).into_owned();
939 if !new_user
940 && let Some(save) = error
941 .lines()
942 .map(str::trim)
943 .find(|line| line.starts_with("codewhale auth set"))
944 {
945 content.push('\n');
946 content.push_str(
947 &app.tr(MessageId::DispatchNotSentSaveKey)
948 .replace("{command}", save),
949 );
950 }
951 app.add_message(HistoryCell::System { content });
952 app.onboarding_needs_api_key = true;
953 // From the composer, the saved route is the one missing its key: this is
954 // missing-key recovery, as after `/logout`, so Esc returns to the
955 // composer. A first-run launch with an initial prompt is still in
956 // onboarding and keeps its remaining steps (Esc walks back as before).
957 if app.onboarding == OnboardingState::None {
958 app.onboarding_missing_key_recovery = true;
959 app.onboarding_provider = app.api_provider;
960 }
961 app.onboarding = OnboardingState::Provider;
962 // The footer keeps the provider's full message for a returning user; a
963 // new user's footer says only that no model is connected.
964 let reason = if new_user {
965 app.tr(MessageId::LaunchNoModelConnected).into_owned()
966 } else {
967 error.to_string()
968 };
969 let status = app
970 .tr(MessageId::DispatchNotSentStatus)
971 .replace("{reason}", &reason);
972 app.status_message = Some(status.clone());
973 app.set_sticky_status(
974 status,
975 StatusToastLevel::Error,
976 Some(App::STICKY_ERROR_TTL_MS),
977 );
978 app.needs_redraw = true;
979 }
980
981 /// The engine reported this turn's message as never sent: a key rejected
982 /// before any model output (#6566). Take the message back, and its bubble
983 /// out of the live transcript when nothing has landed after it, so sending it
984 /// again shows it once. `None` when no dispatched message is on record.
985 pub(crate) fn take_back_unsent_submission(app: &mut App) -> Option<QueuedMessage> {
986 let submission = app.unanswered_submission.take()?;
987 let cell = submission.history_cell;
988 let bubble_is_last = cell + 1 == app.history.len()
989 && matches!(
990 &app.history[cell],
991 HistoryCell::User { content } if content == &submission.message.display
992 );
993 if bubble_is_last {
994 app.truncate_history_to(cell);
995 }
996 Some(submission.message)
997 }
998
999 pub(crate) fn restore_failed_immediate_submit(
1000 app: &mut App,
1001 message: QueuedMessage,
1002 error: &anyhow::Error,
1003 ) {
1004 tracing::warn!(
1005 error = %error,
1006 "immediate user message dispatch failed; restored composer"
1007 );
1008 app.input = message.display;
1009 app.cursor_position = app.input.chars().count();
1010 app.active_skill = message.skill_instruction;
1011 app.active_skill_provenance = message.skill_provenance;
1012 let status = tr(app.ui_locale, MessageId::ComposerDispatchFailedRestored)
1013 .replace("{error}", &error.to_string());
1014 app.status_message = Some(status.clone());
1015 app.set_sticky_status(
1016 status,
1017 StatusToastLevel::Error,
1018 Some(App::STICKY_ERROR_TTL_MS),
1019 );
1020 app.needs_redraw = true;
1021 }
1022
1023 /// Show the default recommended Hotbar slots. Since #3807 an absent `hotbar`
1024 /// key means "hidden", so `/hotbar on` persists the explicit default bindings
1025 /// rather than deleting the key. This is an explicit reset, so any custom
1026 /// bindings are replaced with the recommended set.
1027 pub(crate) fn restore_hotbar_defaults(app: &mut App, config: &mut Config) {
1028 let defaults = codewhale_config::default_hotbar_bindings_toml();
1029 match crate::config_persistence::persist_hotbar_bindings(app.config_path.as_deref(), &defaults)
1030 {
1031 Ok(path) => {
1032 config.hotbar = Some(defaults);
1033 app.status_message = Some(format!(
1034 "Hotbar enabled with the default slots ({}). Customize with `/hotbar`.",
1035 path.display()
1036 ));
1037 }
1038 Err(err) => {
1039 app.status_message = Some(format!("Failed to enable the Hotbar: {err}"));
1040 app.add_message(HistoryCell::System {
1041 content: format!("Failed to enable the Hotbar: {err}"),
1042 });
1043 }
1044 }
1045 app.needs_redraw = true;
1046 }
1047
1048 pub(crate) fn persist_rules_from_approval(
1049 app: &mut App,
1050 config: &mut Config,
1051 rules: &[codewhale_config::ToolAskRule],
1052 ) {
1053 let action = rules.first().map(|rule| rule.action);
1054 match codewhale_config::ConfigStore::load(app.config_path.clone()).and_then(|mut store| {
1055 let added = match action {
1056 Some(codewhale_execpolicy::PermissionAction::Ask) => store.append_ask_rules(rules)?,
1057 Some(codewhale_execpolicy::PermissionAction::Allow) => {
1058 store.append_allow_rules(rules)?
1059 }
1060 Some(codewhale_execpolicy::PermissionAction::Deny) => {
1061 anyhow::bail!("the approval UI cannot persist deny rules")
1062 }
1063 None => 0,
1064 };
1065 let permissions_path = store.permissions_path();
1066 config
1067 .exec_policy_engine
1068 .set_ruleset(store.permissions().ruleset());
1069 Ok((added, permissions_path))
1070 }) {
1071 Ok((added, path)) if added > 0 => {
1072 let action = match action {
1073 Some(codewhale_execpolicy::PermissionAction::Allow) => "allow",
1074 _ => "ask",
1075 };
1076 app.status_message = Some(format!(
1077 "Saved {added} {action} permission rule(s) to {}",
1078 path.display()
1079 ));
1080 }
1081 Ok((_added, path)) => {
1082 let action = match action {
1083 Some(codewhale_execpolicy::PermissionAction::Allow) => "Allow",
1084 _ => "Ask",
1085 };
1086 app.status_message = Some(format!(
1087 "{action} permission rule already saved in {}",
1088 path.display()
1089 ));
1090 }
1091 Err(err) => {
1092 app.status_message = Some(format!("Failed to save permission rule: {err:#}"));
1093 }
1094 }
1095 }
1096
1097 pub(crate) fn mirror_saved_model_in_config(
1098 config: &mut Config,
1099 identity: &ProviderIdentity,
1100 model: String,
1101 ) -> Result<(), String> {
1102 config.verify_provider_identity(identity)?;
1103 if identity.provider == ProviderKind::Deepseek {
1104 config.default_text_model = Some(model);
1105 return Ok(());
1106 }
1107 config
1108 .set_provider_model_override(identity, Some(model))
1109 .map_err(|error| error.to_string())
1110 }
1111
1112 pub(crate) fn mirror_saved_context_window_in_config(
1113 config: &mut Config,
1114 identity: &ProviderIdentity,
1115 context_window: u32,
1116 ) -> Result<(), String> {
1117 config.verify_provider_identity(identity)?;
1118 if identity.provider == ProviderKind::Moonshot {
1119 config
1120 .provider_config_for_mut(identity)
1121 .map_err(|error| error.to_string())?
1122 .context_window = Some(context_window);
1123 }
1124 Ok(())
1125 }
1126
1127 pub(crate) fn mirror_saved_api_key_in_config(
1128 config: &mut Config,
1129 identity: &ProviderIdentity,
1130 api_key: String,
1131 ) -> Result<(), String> {
1132 config.verify_provider_identity(identity)?;
1133 let provider = identity.provider;
1134 // These shared auth leaves are intrinsic released credential contracts;
1135 // presentation names cannot select them.
1136 if provider == ProviderKind::Deepseek {
1137 let auth_owner = config.builtin_provider_identity(ProviderKind::Deepseek)?;
1138 config
1139 .set_provider_api_key_override(&auth_owner, Some(api_key))
1140 .map_err(|error| error.to_string())?;
1141 config.auth_mode = Some("api_key".to_string());
1142 return Ok(());
1143 }
1144 let pin_kimi_code_base_url = provider == ProviderKind::Moonshot
1145 && config.provider_config_for(identity).is_some_and(|entry| {
1146 crate::config::provider_config_uses_kimi_imported_token(entry)
1147 && entry
1148 .base_url
1149 .as_deref()
1150 .is_none_or(|base_url| base_url.trim().is_empty())
1151 });
1152 let auth_owner = if provider == ProviderKind::SiliconflowCN {
1153 config.builtin_provider_identity(ProviderKind::Siliconflow)?
1154 } else {
1155 identity.clone()
1156 };
1157 let entry = config
1158 .provider_config_for_mut(&auth_owner)
1159 .map_err(|error| error.to_string())?;
1160 if pin_kimi_code_base_url {
1161 entry.base_url = Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string());
1162 }
1163 entry.auth_mode = Some("api_key".to_string());
1164 entry.api_key = Some(api_key);
1165 entry.external_credentials = None;
1166 if provider == ProviderKind::Xai {
1167 entry.oauth_credential_generation = None;
1168 }
1169 Ok(())
1170 }
1171
1172 pub(crate) fn loaded_session_requires_engine_respawn(
1173 app: &App,
1174 previous_provider: ProviderKind,
1175 previous_provider_identity: &str,
1176 previous_workspace: &Path,
1177 ) -> bool {
1178 app.api_provider != previous_provider
1179 || app.provider_identity_for_persistence() != previous_provider_identity
1180 || app.workspace != previous_workspace
1181 }
1182
1183 pub(crate) fn restore_loaded_session_provider(
1184 app: &mut App,
1185 config: &mut Config,
1186 identity: ProviderIdentity,
1187 ) -> Result<(), String> {
1188 let provider = identity.provider;
1189 config.scope_to_provider_identity(&identity)?;
1190 app.set_provider_identity_record(identity.clone());
1191 app.billing_presentation = crate::route_billing::for_route(config, &identity);
1192 app.max_subagents = config
1193 .max_subagents_for_provider(&identity)
1194 .clamp(1, crate::config::MAX_SUBAGENTS);
1195 app.provider_chain = (identity.key.as_str() == provider.as_str()
1196 && provider != ProviderKind::Antigravity)
1197 .then(|| codewhale_config::ProviderChain::new(provider, &config.fallback_providers))
1198 .filter(|chain| chain.providers().len() > 1);
1199 app.last_fallback_reason = None;
1200 app.model_ids_passthrough = config.model_ids_pass_through();
1201 if !app.auto_model {
1202 let requested = app
1203 .reasoning_effort_preference
1204 .unwrap_or(app.reasoning_effort);
1205 app.reasoning_effort =
1206 requested.normalize_for_route(provider, &config.active_route_base_url(), &app.model);
1207 }
1208 app.set_active_context_window_override(config, &identity);
1209 app.active_route_limits = app.context_window_override_limits();
1210 app.active_route_base_url = config.active_route_base_url();
1211 app.active_context_window_source = app
1212 .configured_context_window_for(&app.model)
1213 .map(|resolution| resolution.source)
1214 .unwrap_or(crate::route_runtime::ContextWindowSource::Fallback);
1215 Ok(())
1216 }
1217
1218 pub(crate) fn resolve_loaded_session_route(app: &mut App, config: &Config) {
1219 let identity = match app
1220 .admitted_provider_identity()
1221 .cloned()
1222 .and_then(|identity| {
1223 config.verify_provider_identity(&identity)?;
1224 Ok(identity)
1225 }) {
1226 Ok(identity) => identity,
1227 Err(reason) => {
1228 app.push_status_toast(reason, StatusToastLevel::Error, Some(8_000));
1229 return;
1230 }
1231 };
1232 app.set_active_context_window_override(config, &identity);
1233 if app.auto_model {
1234 app.active_route_limits = app.context_window_override_limits();
1235 app.active_route_base_url = config.active_route_base_url();
1236 app.active_context_window_source = app
1237 .configured_context_window_for(&app.model)
1238 .map(|resolution| resolution.source)
1239 .unwrap_or(crate::route_runtime::ContextWindowSource::Fallback);
1240 return;
1241 }
1242
1243 match crate::route_runtime::resolve_runtime_route_for_identity(
1244 config,
1245 &identity,
1246 Some(&app.model),
1247 ) {
1248 Ok(resolution) => {
1249 app.set_active_route_resolution(
1250 resolution.candidate.endpoint().base_url.clone(),
1251 resolution.candidate.limits(),
1252 resolution.context_window.source,
1253 );
1254 }
1255 Err(_) => {
1256 app.active_route_limits = app.context_window_override_limits();
1257 app.active_route_base_url = config.active_route_base_url();
1258 app.active_context_window_source = app
1259 .configured_context_window_for(&app.model)
1260 .map(|resolution| resolution.source)
1261 .unwrap_or(crate::route_runtime::ContextWindowSource::Fallback);
1262 }
1263 }
1264 }
1265
1266 /// Derive a short display title from the API message list.
1267 ///
1268 /// Tries several strategies in order:
1269 /// 1. If the first user message starts with a known slash command (`/goal`,
1270 /// `/fleet`, `/workflow`, etc.), use the command + first argument.
1271 /// 2. Otherwise, take the first meaningful line and cut it at a natural
1272 /// phrase boundary (period, comma, colon, or word boundary) within
1273 /// `SESSION_TITLE_MAX_CHARS`, never splitting mid-word.
1274 ///
1275 /// Never leaks raw prompt text — the result is always a concise label.
1276 pub(crate) fn derive_session_title(messages: &[Message]) -> Option<String> {
1277 let text = crate::session_manager::conversation_title_prompt(messages)?;
1278
1279 let first_line =
1280 crate::session_manager::sanitize_session_title(text.lines().next().unwrap_or("").trim());
1281 let first_line = first_line.trim();
1282 if first_line.is_empty() {
1283 return None;
1284 }
1285
1286 // Slash command: extract command name + first reasonable argument.
1287 if let Some(rest) = first_line.strip_prefix('/') {
1288 let parts: Vec<&str> = rest.split_whitespace().collect();
1289 return match parts.as_slice() {
1290 [] => None,
1291 [cmd] => Some(format!("/{cmd}")),
1292 [cmd, arg, ..] => {
1293 let arg_short = short_title_truncate(arg, 24);
1294 Some(format!("/{cmd} {arg_short}"))
1295 }
1296 };
1297 }
1298
1299 Some(short_title_truncate(first_line, SESSION_TITLE_MAX_CHARS))
1300 }
1301
1302 #[cfg(test)]
1303 mod derived_title_tests {
1304 use super::*;
1305 use codewhale_models::Role;
1306
1307 fn user(text: &str) -> Message {
1308 Message {
1309 role: Role::User,
1310 content: vec![ContentBlock::Text {
1311 text: text.to_string(),
1312 cache_control: None,
1313 }],
1314 }
1315 }
1316
1317 #[test]
1318 fn derived_titles_drop_terminal_controls_and_bidi_format_chars() {
1319 // The first user message can carry pasted escape sequences; the
1320 // derived session name must never persist them.
1321 let msgs = [user("Fix \u{1b}]0;PWNED\u{7}the\u{202e} build 会議")];
1322 assert_eq!(
1323 derive_session_title(&msgs).as_deref(),
1324 Some("Fix ]0;PWNEDthe build 会議")
1325 );
1326 // Controls alone leave no title to derive.
1327 assert_eq!(derive_session_title(&[user("\u{1b}\u{7}\u{200b}")]), None);
1328 }
1329
1330 #[test]
1331 fn live_title_uses_the_same_user_prompt_after_runtime_handoffs() {
1332 let handoff = crate::runtime_handoff::operate_contract_runtime_message();
1333 assert_eq!(derive_session_title(std::slice::from_ref(&handoff)), None);
1334 let messages = [handoff, user("/goal Fix the diagnostic display")];
1335 assert_eq!(
1336 derive_session_title(&messages).as_deref(),
1337 Some("/goal Fix")
1338 );
1339 assert_eq!(
1340 crate::session_manager::conversation_title_prompt(&messages),
1341 Some("/goal Fix the diagnostic display")
1342 );
1343 }
1344 }
1345
1346 #[cfg(test)]
1347 mod stall_outbox_tests {
1348 use super::*;
1349 use crate::tui::app::TuiOptions;
1350
1351 /// `recover_stalled_runtime_turn` must emit a `turn_stalled` lifecycle
1352 /// outbox event naming the wedged turn — the first scriptable stall
1353 /// signal. The outbox is opt-in, so the test enables it through config.
1354 #[tokio::test]
1355 async fn stalled_turn_emits_turn_stalled_outbox_event() {
1356 let _lock = crate::test_support::lock_test_env();
1357 let dir = tempfile::tempdir().expect("tempdir");
1358 let outbox_path = dir.path().join("outbox.jsonl");
1359
1360 let config = Config {
1361 lifecycle_outbox: Some(codewhale_config::LifecycleOutboxToml {
1362 path: Some(outbox_path.clone()),
1363 webhook_url: None,
1364 webhook_token: None,
1365 }),
1366 ..Default::default()
1367 };
1368 let options = TuiOptions {
1369 start_in_agent_mode: true,
1370 ..crate::test_support::test_tui_options(dir.path())
1371 };
1372 let mut app = App::new(options, &config);
1373 assert!(app.lifecycle_outbox.is_enabled());
1374 let expected_workspace = app.workspace.display().to_string();
1375
1376 app.runtime_turn_id = Some("turn-1".to_string());
1377 app.runtime_turn_status = Some("in_progress".to_string());
1378 app.is_loading = true;
1379 recover_stalled_runtime_turn(
1380 &mut app,
1381 "Turn stalled — no completion signal received",
1382 StatusToastLevel::Error,
1383 );
1384
1385 // The outbox writer task drains asynchronously; wait for the line.
1386 let mut lines = Vec::new();
1387 for _ in 0..200 {
1388 if let Ok(text) = tokio::fs::read_to_string(&outbox_path).await {
1389 lines = text
1390 .lines()
1391 .map(|line| serde_json::from_str::<serde_json::Value>(line).expect("json"))
1392 .collect();
1393 if !lines.is_empty() {
1394 break;
1395 }
1396 }
1397 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1398 }
1399
1400 assert_eq!(lines.len(), 1, "expected one turn_stalled outbox line");
1401 let line = &lines[0];
1402 assert_eq!(line["event"], "turn_stalled");
1403 assert_eq!(line["kind"], "turn.stalled");
1404 assert_eq!(line["turn_id"], "turn-1");
1405 assert_eq!(line["schema_version"], 1);
1406 assert_eq!(line["seq"], 1);
1407 // Every payload carries the workspace for consumer-side routing.
1408 assert_eq!(
1409 line["payload"]["workspace"],
1410 serde_json::json!(expected_workspace)
1411 );
1412 // The stall message is engine-authored and safe, but still bounded
1413 // and never raw tool/environment content.
1414 let message = line["payload"]["message"].as_str().expect("message");
1415 assert!(message.contains("stalled"));
1416 assert!(
1417 message.chars().count() <= codewhale_hooks::OUTBOX_DETAIL_MAX_CHARS,
1418 "stall message must be bounded"
1419 );
1420 }
1421
1422 /// A disabled outbox (config without a path) must make stall recovery
1423 /// behave exactly as before: the toast still lands, no file is written.
1424 #[tokio::test]
1425 async fn stalled_turn_without_outbox_config_writes_nothing() {
1426 let _lock = crate::test_support::lock_test_env();
1427 let dir = tempfile::tempdir().expect("tempdir");
1428 let options = TuiOptions {
1429 start_in_agent_mode: true,
1430 ..crate::test_support::test_tui_options(dir.path())
1431 };
1432 let mut app = App::new(options, &Config::default());
1433 assert!(!app.lifecycle_outbox.is_enabled());
1434
1435 app.runtime_turn_id = Some("turn-1".to_string());
1436 app.runtime_turn_status = Some("in_progress".to_string());
1437 app.is_loading = true;
1438 recover_stalled_runtime_turn(
1439 &mut app,
1440 "Turn stalled — no completion signal received",
1441 StatusToastLevel::Error,
1442 );
1443
1444 // Recovery still clears the wedged turn state and posts the toast.
1445 assert!(app.runtime_turn_id.is_none());
1446 assert!(!app.is_loading);
1447 assert!(!app.status_toasts.is_empty());
1448 assert!(
1449 !dir.path().join("outbox.jsonl").exists(),
1450 "no outbox file must be created when the feature is off"
1451 );
1452 }
1453 }
1454
1455 #[cfg(test)]
1456 mod launch_resume_tests {
1457 use super::*;
1458
1459 /// A recent-work row that vanished behind the card must leave the card
1460 /// up with a status — never strand the user on an empty stage.
1461 #[test]
1462 fn resume_missing_session_leaves_the_card_up_with_a_status() {
1463 let dir = tempfile::tempdir().unwrap();
1464 let mut app = App::new(
1465 crate::test_support::test_tui_options(dir.path()),
1466 &Config::default(),
1467 );
1468 app.launch.visible = true;
1469 let result = resume_launch_session(&mut app, "no-such-session-000000");
1470 assert!(result.action.is_none(), "nothing to load");
1471 assert!(app.launch.visible, "the card stays up");
1472 let status = app.launch.status.as_deref().expect("a status");
1473 assert!(
1474 status.contains("Resume failed"),
1475 "the status says why: {status}"
1476 );
1477 }
1478
1479 /// U1 / #6566: a keyless first message goes back into the composer, leaves
1480 /// one durable transcript line, opens the provider picker, and a later
1481 /// routine acknowledgement ("Auto-compaction enabled") does not wipe the
1482 /// error from the footer.
1483 #[test]
1484 fn keyless_submit_leaves_a_durable_recovery_that_config_acks_cannot_erase() {
1485 let dir = tempfile::tempdir().unwrap();
1486 let mut app = App::new(
1487 crate::test_support::test_tui_options(dir.path()),
1488 &Config::default(),
1489 );
1490 app.onboarding_had_provider_step = true;
1491 let cells_before = app.history.len();
1492 keep_unsent_message_for_connect(
1493 &mut app,
1494 crate::tui::app::QueuedMessage::new("hello".to_string(), None),
1495 "DeepSeek API key not found",
1496 );
1497 assert_eq!(app.input, "hello", "the unsent message is not lost");
1498 assert_eq!(app.history.len(), cells_before + 1);
1499 let Some(HistoryCell::System { content }) = app.history.last() else {
1500 panic!("keyless submit must leave a transcript line");
1501 };
1502 assert!(content.starts_with("No model is connected"), "{content}");
1503 // A new user never chose DeepSeek; the line must not blame its key.
1504 assert!(!content.contains("DeepSeek"), "{content}");
1505 assert_eq!(app.onboarding, OnboardingState::Provider);
1506 assert!(app.onboarding_needs_api_key);
1507
1508 app.status_message = Some("Make room automatically: on".to_string());
1509 let shown = app
1510 .active_status_toast(crate::tui::underwater::ShellPhase::Idle)
1511 .expect("footer notice");
1512 assert_eq!(shown.level, StatusToastLevel::Error);
1513 assert!(shown.text.contains("Message not sent"), "{}", shown.text);
1514 assert!(!shown.text.contains("DeepSeek"), "{}", shown.text);
1515 }
1516
1517 /// A returning user's line names the command that saves the missing key,
1518 /// and Esc from the picker it opens returns to the composer with the
1519 /// message still there, not to the welcome screen.
1520 #[test]
1521 fn keyless_submit_names_the_key_and_esc_returns_to_the_composer() {
1522 let dir = tempfile::tempdir().unwrap();
1523 let mut app = App::new(
1524 crate::test_support::test_tui_options(dir.path()),
1525 &Config::default(),
1526 );
1527 app.onboarding = OnboardingState::None;
1528 app.onboarding_missing_key_recovery = false;
1529 app.onboarding_had_provider_step = false;
1530 keep_unsent_message_for_connect(
1531 &mut app,
1532 crate::tui::app::QueuedMessage::new("hello".to_string(), None),
1533 "DeepSeek API key not found.\n\n 1. Get a key: https://platform.deepseek.com/api_keys\n 2. Save it (works in every folder, no OS prompts):\n codewhale auth set --provider deepseek\n\n Alternatives:\n • export DEEPSEEK_API_KEY=<your-key>. Failed to configure provider route deepseek / deepseek-flash.",
1534 );
1535 let Some(HistoryCell::System { content }) = app.history.last() else {
1536 panic!("keyless submit must leave a transcript line");
1537 };
1538 assert!(
1539 content.contains("codewhale auth set --provider deepseek"),
1540 "{content}"
1541 );
1542 assert!(content.contains("F3"), "{content}");
1543 // The footer keeps the full help page; the transcript keeps two facts.
1544 assert!(!content.contains("Alternatives"), "{content}");
1545 assert!(!content.contains("Failed to configure"), "{content}");
1546 assert!(app.onboarding_missing_key_recovery);
1547 assert!(app.onboarding_recovers_configured_route());
1548
1549 back_from_provider_onboarding(&mut app);
1550 assert_eq!(app.onboarding, OnboardingState::None);
1551 assert!(app.onboarding_needs_api_key);
1552 assert_eq!(app.input, "hello");
1553 }
1554
1555 /// The prominent new-session entry begins a fresh session in place.
1556 #[test]
1557 fn new_session_begins_a_fresh_session_and_leaves_the_card() {
1558 let dir = tempfile::tempdir().unwrap();
1559 let mut app = App::new(
1560 crate::test_support::test_tui_options(dir.path()),
1561 &Config::default(),
1562 );
1563 app.launch.visible = true;
1564 let result = begin_launch_session(&mut app, None);
1565 assert!(!app.launch.visible, "the session began");
1566 assert!(
1567 app.current_session_id.is_some(),
1568 "a fresh session id was minted"
1569 );
1570 assert!(
1571 matches!(result.action, Some(AppAction::SyncSession { .. })),
1572 "the engine syncs the fresh session"
1573 );
1574 }
1575 }
1576
1576 lines RUST