返回 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 pub(crate) fn reconcile_turn_liveness(
161 app: &mut App,
162 now: Instant,
163 has_running_agents: bool,
164 ) -> bool {
165 if app.is_loading
166 && app.runtime_turn_status.is_none()
167 && !has_running_agents
168 && !app.is_compacting
169 && !app.is_purging
170 && app.dispatch_started_at.is_some_and(|started| {
171 now.saturating_duration_since(started) > DISPATCH_WATCHDOG_TIMEOUT
172 })
173 {
174 // #2739: the user's prompt was already appended to api_messages
175 // before dispatch, but the turn never reached `in_progress`. Persist
176 // it before clearing turn state so `--continue` keeps the prompt
177 // instead of loading the previous save.
178 persist_recovery_snapshot(app);
179 app.is_loading = false;
180 app.dispatch_started_at = None;
181 app.turn_started_at = None;
182 app.turn_last_activity_at = None;
183 app.pending_turn_route = None;
184 app.pending_auto_route_receipt = None;
185 app.active_turn = None;
186 app.suppress_stream_events_until_turn_complete = false;
187 app.push_status_toast(
188 "Turn dispatch timed out; the engine may have stopped. Please try again.",
189 StatusToastLevel::Error,
190 None,
191 );
192 return true;
193 }
194
195 if app.is_loading
196 && matches!(
197 app.runtime_turn_status.as_deref(),
198 Some("completed" | "interrupted" | "failed")
199 )
200 && !has_running_agents
201 && !app.is_compacting
202 && !app.is_purging
203 {
204 app.is_loading = false;
205 app.dispatch_started_at = None;
206 app.turn_started_at = None;
207 app.turn_last_activity_at = None;
208 app.pending_turn_route = None;
209 app.pending_auto_route_receipt = None;
210 app.active_turn = None;
211 app.suppress_stream_events_until_turn_complete = false;
212 app.push_status_toast(
213 "Recovered from an inconsistent busy state.",
214 StatusToastLevel::Warning,
215 None,
216 );
217 return true;
218 }
219
220 // Branch 3: turn started but never completed — engine may have
221 // panicked, sub-agent may be stuck, or the completion event was lost.
222 if app.is_loading
223 && matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
224 && !has_running_agents
225 && !app.is_compacting
226 && !active_turn_has_running_tool(app)
227 && app
228 .turn_last_activity_at
229 .or(app.turn_started_at)
230 .is_some_and(|last_activity| {
231 now.saturating_duration_since(last_activity) > turn_stall_watchdog_timeout(app)
232 })
233 {
234 recover_stalled_runtime_turn(
235 app,
236 "Turn stalled — no completion signal received. Please try again.",
237 StatusToastLevel::Error,
238 );
239 return true;
240 }
241
242 if app.is_loading
243 && matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
244 && !has_running_agents
245 && !app.is_compacting
246 && !app.is_purging
247 && active_turn_has_running_tool(app)
248 && app
249 .turn_last_activity_at
250 .or(app.turn_started_at)
251 .is_some_and(|last_activity| {
252 now.saturating_duration_since(last_activity) > TOOL_HANG_WATCHDOG_TIMEOUT
253 })
254 {
255 recover_stalled_runtime_turn(
256 app,
257 "Tool stalled with no progress for 10m — recovered; the command may still be running in the background. Use exec_shell_cancel or retry.",
258 StatusToastLevel::Error,
259 );
260 return true;
261 }
262
263 false
264 }
265
266 /// #2739: persist the current in-memory session state before a recovery or
267 /// cancellation path clears turn bookkeeping. Without this snapshot, the
268 /// just-finalised partial turn lives only in `app.api_messages` and is never
269 /// written to disk, so `--continue` loads the *previous* save — effectively
270 /// losing the entire in-progress turn.
271 pub(crate) fn persist_recovery_snapshot(app: &mut App) {
272 if let Ok(manager) = SessionManager::default_location()
273 && let Ok(session) = build_session_snapshot(app, &manager)
274 {
275 if app.current_session_id.is_none() {
276 app.current_session_id = Some(session.metadata.id.clone());
277 }
278 if let Err(err) =
279 persist_with_pending_work_boundary(app, PersistRequest::SaveCheckpoint { session })
280 {
281 app.status_message = Some(format!(
282 "To-do list update pending: recovery snapshot could not be queued ({err})"
283 ));
284 }
285 }
286 }
287
288 pub(crate) fn persist_full_reset_snapshot(app: &mut App) {
289 if let Ok(manager) = SessionManager::default_location()
290 && let Ok(session) = build_session_snapshot(app, &manager)
291 {
292 app.current_session_id = Some(session.metadata.id.clone());
293 if let Err(err) =
294 persist_with_pending_work_boundary(app, PersistRequest::SessionSnapshot(session))
295 {
296 app.status_message = Some(format!(
297 "To-do list update pending: reset snapshot could not be queued ({err})"
298 ));
299 }
300 }
301 // `/clear` and `/new` are explicit boundaries. Never let an older
302 // in-flight checkpoint resurrect the session the user just discarded,
303 // even if the replacement snapshot could not be constructed.
304 // `build_session_snapshot` reuses `current_session_id`, so this id is the
305 // discarded session's id whether or not the snapshot above succeeded.
306 if let Some(session_id) = app.current_session_id.clone() {
307 persistence_actor::persist(PersistRequest::ClearCheckpoint { session_id });
308 }
309 }
310
311 pub(crate) fn maybe_throttled_recovery_snapshot(
312 app: &mut App,
313 now: Instant,
314 last_snapshot_at: &mut Option<Instant>,
315 ) {
316 if !app.is_loading && !matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
317 return;
318 }
319 if last_snapshot_at
320 .is_some_and(|last| now.saturating_duration_since(last) < RECOVERY_SNAPSHOT_INTERVAL)
321 {
322 return;
323 }
324 persist_recovery_snapshot(app);
325 *last_snapshot_at = Some(now);
326 }
327
328 pub(crate) fn recover_stalled_runtime_turn(app: &mut App, message: &str, level: StatusToastLevel) {
329 // Capture the turn identity before the reset below clears it; the
330 // outbox event must name the turn that stalled.
331 let stalled_turn_id = app.runtime_turn_id.clone();
332 let stalled_session_id = app.hooks.session_id().to_string();
333 // Finalize in-flight thinking / assistant / tool cells so the
334 // transcript doesn't show permanent spinners after recovery.
335 streaming_thinking::finalize_current(app);
336 app.finalize_streaming_assistant_as_interrupted();
337 app.finalize_active_cell_as_interrupted();
338 app.streaming_state.reset();
339 app.streaming_message_index = None;
340 app.streaming_thinking_active_entry = None;
341
342 // #2739: persist the partial turn's api_messages before clearing
343 // turn state. Without this snapshot the stalled/cancelled turn's
344 // messages are held only in memory and --continue sees the
345 // *previous* save, losing the entire in-progress turn.
346 persist_recovery_snapshot(app);
347
348 app.is_loading = false;
349 app.turn_started_at = None;
350 app.turn_last_activity_at = None;
351 app.runtime_turn_status = None;
352 app.runtime_turn_id = None;
353 app.dispatch_started_at = None;
354 app.pending_turn_route = None;
355 app.pending_auto_route_receipt = None;
356 app.active_turn = None;
357 app.suppress_stream_events_until_turn_complete = false;
358 // Per-turn scroll lock — clear so the next turn auto-scrolls.
359 app.user_scrolled_during_stream = false;
360 app.push_status_toast(message, level, None);
361 // Lifecycle outbox (`[lifecycle_outbox]`): the first scriptable stall
362 // signal. Until now a wedged turn was only visible as this toast; with
363 // the outbox enabled a supervisor can react to the same moment.
364 // No-op when the feature is disabled.
365 app.lifecycle_outbox.emit(codewhale_hooks::LifecycleEvent {
366 event: "turn_stalled".to_string(),
367 kind: "turn.stalled".to_string(),
368 thread_id: stalled_session_id,
369 turn_id: stalled_turn_id,
370 item_id: None,
371 payload: serde_json::json!({
372 "message": codewhale_hooks::bounded_text(
373 message,
374 codewhale_hooks::OUTBOX_DETAIL_MAX_CHARS,
375 ),
376 "workspace": app.workspace.display().to_string(),
377 }),
378 });
379 }
380
381 pub(crate) fn recover_engine_event_disconnect(app: &mut App) -> bool {
382 let had_live_work = app.is_loading
383 || app.is_compacting
384 || app.manual_compaction_queued
385 || app.is_purging
386 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
387 || app.pending_turn_route.is_some()
388 || app.active_turn.is_some()
389 || app.suppress_stream_events_until_turn_complete
390 || app.streaming_message_index.is_some()
391 || app.streaming_thinking_active_entry.is_some()
392 || app
393 .active_cell
394 .as_ref()
395 .is_some_and(|cell| !cell.is_empty());
396
397 if !had_live_work {
398 return false;
399 }
400
401 streaming_thinking::finalize_current(app);
402 app.finalize_streaming_assistant_as_interrupted();
403 app.finalize_active_cell_as_interrupted();
404 app.streaming_state.reset();
405 app.streaming_message_index = None;
406 app.streaming_thinking_active_entry = None;
407
408 // #2739: persist partial turn before clearing state.
409 persist_recovery_snapshot(app);
410
411 app.is_loading = false;
412 app.is_compacting = false;
413 app.active_compaction = None;
414 app.manual_compaction_queued = false;
415 app.deferred_manual_compaction = None;
416 app.is_purging = false;
417 app.turn_started_at = None;
418 app.turn_last_activity_at = None;
419 app.runtime_turn_status = None;
420 app.runtime_turn_id = None;
421 app.dispatch_started_at = None;
422 app.pending_turn_route = None;
423 app.pending_auto_route_receipt = None;
424 app.active_turn = None;
425 app.suppress_stream_events_until_turn_complete = false;
426 app.user_scrolled_during_stream = false;
427
428 for msg in app.drain_pending_steers() {
429 app.queue_message(msg);
430 }
431
432 app.add_message(HistoryCell::Error {
433 message: "Engine stopped before completing the turn. Check ~/.codewhale/crashes and retry."
434 .to_string(),
435 severity: crate::error_taxonomy::ErrorSeverity::Error,
436 });
437 app.push_status_toast(
438 "Engine stopped before completing the turn.",
439 StatusToastLevel::Error,
440 None,
441 );
442 true
443 }
444
445 pub(crate) fn capture_turn_started_metadata(app: &mut App, event: &EngineEvent) {
446 match event {
447 EngineEvent::TurnStarted {
448 turn_id,
449 created_at,
450 route,
451 } => {
452 app.ocean_completion_started_at = None;
453 let auto_route_receipt = if route.as_ref().is_some_and(|route| route.auto_model) {
454 app.pending_auto_route_receipt.take()
455 } else if route.is_some() {
456 app.pending_auto_route_receipt = None;
457 None
458 } else {
459 None
460 };
461 // Bind the prompt-suggestion authority to the receipt the engine minted
462 // from the client it installed for this turn. Deliberately not read
463 // from `config`: web config events are drained ahead of engine events,
464 // so config here may already describe a different key or endpoint than
465 // the one this turn is actually running on.
466 let suggestion_authority = route
467 .as_ref()
468 .and_then(crate::tui::prompt_suggestion::capture_route_authority);
469 app.active_turn = Some(ActiveTurnMetadata {
470 turn_id: turn_id.clone(),
471 created_at: *created_at,
472 route: route.clone(),
473 auto_route_receipt,
474 suggestion_authority,
475 });
476 app.pending_turn_route = None;
477 }
478 // The dispatch boundary is the billing truth: refresh the active turn's
479 // route with the envelope that was actually put on the wire. Receipts
480 // already taken at `TurnStarted` are preserved — this event narrows the
481 // route, it never re-opens an authority decision.
482 EngineEvent::RouteDispatched { turn_id, route } => {
483 if let Some(active) = app
484 .active_turn
485 .as_mut()
486 .filter(|active| active.turn_id == *turn_id)
487 {
488 if route.auto_model && active.auto_route_receipt.is_none() {
489 active.auto_route_receipt = app.pending_auto_route_receipt.take();
490 } else if !route.auto_model {
491 app.pending_auto_route_receipt = None;
492 active.auto_route_receipt = None;
493 }
494 if active.suggestion_authority.is_none() {
495 active.suggestion_authority =
496 crate::tui::prompt_suggestion::capture_route_authority(route);
497 }
498 active.route = Some(route.clone());
499 }
500 }
501 _ => {}
502 }
503 }
504
505 pub(crate) fn record_turn_activity(app: &mut App, event: &EngineEvent, now: Instant) {
506 if matches!(event, EngineEvent::TurnStarted { .. }) {
507 app.turn_last_activity_at = Some(now);
508 return;
509 }
510
511 if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
512 app.turn_last_activity_at = Some(now);
513 }
514 }
515
516 pub(crate) fn persist_offline_queue_state(app: &App) {
517 let Some(lease) = app
518 .offline_queue_lease
519 .as_ref()
520 .filter(|lease| app.current_session_id.as_deref() == Some(lease.session_id()))
521 else {
522 return;
523 };
524 if app.queued_messages.is_empty() && app.queued_draft.is_none() {
525 persistence_actor::persist(PersistRequest::ClearOfflineQueue {
526 lease: Arc::clone(lease),
527 });
528 return;
529 }
530 let (messages, draft) = offline_queue_projection(app);
531 let state = OfflineQueueState {
532 messages: messages.iter().map(queued_ui_to_session).collect(),
533 draft: draft.as_ref().map(queued_ui_to_session),
534 ..OfflineQueueState::default()
535 };
536 persistence_actor::persist(PersistRequest::OfflineQueue {
537 state,
538 lease: Arc::clone(lease),
539 });
540 }
541
542 pub(crate) fn restore_queued_message(app: &mut App, index: Option<usize>, message: QueuedMessage) {
543 if let Some(index) = index
544 && index <= app.queued_messages.len()
545 {
546 app.queued_messages.insert(index, message);
547 } else {
548 app.queue_message(message);
549 }
550 }
551
552 pub(crate) fn restore_queued_or_draft_message(
553 app: &mut App,
554 recovery: DispatchRecovery,
555 message: QueuedMessage,
556 ) {
557 match recovery {
558 DispatchRecovery::Draft => {
559 app.input.clone_from(&message.display);
560 app.cursor_position = app.input.chars().count();
561 app.active_skill = message.skill_instruction.clone();
562 app.active_skill_provenance = message.skill_provenance.clone();
563 app.queued_draft = Some(message);
564 app.needs_redraw = true;
565 }
566 DispatchRecovery::Queued { restore_index } => {
567 restore_queued_message(app, restore_index, message);
568 }
569 DispatchRecovery::Immediate | DispatchRecovery::Initial => app.queue_message(message),
570 }
571 }
572
573 pub(crate) fn recover_unstarted_external_message(
574 app: &mut App,
575 message: QueuedMessage,
576 recovery: DispatchRecovery,
577 error: &str,
578 ) {
579 app.dispatch_in_flight = false;
580 match recovery {
581 DispatchRecovery::Immediate | DispatchRecovery::Initial => {
582 restore_failed_immediate_submit(app, message, &anyhow::Error::msg(error.to_string()));
583 }
584 DispatchRecovery::Draft => {
585 restore_queued_or_draft_message(app, recovery, message);
586 app.status_message = Some(format!("{error}; queued draft restored"));
587 }
588 DispatchRecovery::Queued { restore_index } => {
589 restore_queued_message(app, restore_index, message);
590 app.status_message = Some(format!(
591 "{error}; {} queued follow-up(s) restored",
592 app.queued_message_count()
593 ));
594 }
595 }
596 app.push_status_toast(
597 error.to_string(),
598 StatusToastLevel::Error,
599 Some(App::STICKY_ERROR_TTL_MS),
600 );
601 app.needs_redraw = true;
602 }
603
604 pub(crate) fn restore_message_submit_denial(
605 app: &mut App,
606 message: QueuedMessage,
607 recovery: DispatchRecovery,
608 ) {
609 let denial = app
610 .status_message
611 .clone()
612 .unwrap_or_else(|| "message_submit hook blocked submission".to_string());
613 app.dispatch_in_flight = false;
614 match recovery {
615 DispatchRecovery::Immediate | DispatchRecovery::Initial => {
616 app.input.clone_from(&message.display);
617 app.cursor_position = app.input.chars().count();
618 app.active_skill = message.skill_instruction;
619 app.active_skill_provenance = message.skill_provenance;
620 }
621 DispatchRecovery::Draft => {
622 restore_queued_or_draft_message(app, recovery, message);
623 }
624 DispatchRecovery::Queued { restore_index } => {
625 restore_queued_message(app, restore_index, message);
626 }
627 }
628 app.status_message = Some(denial.clone());
629 app.push_status_toast(denial, StatusToastLevel::Warning, Some(6_000));
630 app.needs_redraw = true;
631 }
632
633 /// Resume one recent-work row from the startup card by session id. Mirrors
634 /// `/resume <id>`: the card dissolves and the saved session loads through
635 /// the normal `LoadSession` path; a session that vanished behind the card
636 /// leaves the card up with a status saying why instead of stranding the
637 /// user on an empty stage.
638 pub(crate) fn resume_launch_session(app: &mut App, session_id: &str) -> commands::CommandResult {
639 let failed = |app: &mut App, err: &str| {
640 app.launch.status = Some(
641 app.tr(MessageId::LaunchResumeFailed)
642 .replace("{error}", err),
643 );
644 commands::CommandResult::ok()
645 };
646 let manager = match crate::session_manager::SessionManager::default_location() {
647 Ok(manager) => manager,
648 Err(err) => return failed(app, &err.to_string()),
649 };
650 let saved = match manager.load_session_snapshot(session_id) {
651 Ok(saved) => saved,
652 Err(err) => return failed(app, &err.to_string()),
653 };
654 let path = manager
655 .sessions_dir()
656 .join(format!("{}.json", saved.metadata.id));
657 if !path.exists() {
658 return failed(app, "saved session file is gone");
659 }
660 app.launch.dissolve_card(app.ambient_clock_ms);
661 commands::CommandResult::action(AppAction::LoadSession(path))
662 }
663
664 /// `LaunchAction::McpRemedy` (#6085): type the remedy the problems row
665 /// prints into the composer — `/mcp login <name>` or `/mcp`. Typing beats
666 /// copying (no clipboard dependency over SSH), and the user reads the
667 /// command before a second Enter sends it.
668 pub(crate) fn type_launch_mcp_remedy(app: &mut App) {
669 let Some(command) = crate::tui::underwater::mcp_remedy_command(app) else {
670 return;
671 };
672 // Home can be revisited with an unsent draft. The manager exposes the
673 // same remedy without replacing user-authored composer content.
674 if !app.input.is_empty() {
675 app.launch.dissolve_card(app.ambient_clock_ms);
676 open_mcp_extensions(app);
677 return;
678 }
679 app.input = command;
680 app.cursor_position = app.input.chars().count();
681 app.launch.menu_selected = None;
682 app.launch.status = None;
683 }
684
685 pub(crate) fn begin_launch_session(
686 app: &mut App,
687 workspace: Option<PathBuf>,
688 ) -> commands::CommandResult {
689 let session_id = uuid::Uuid::new_v4().to_string();
690 let transition = match prepare_offline_queue_transition(app, &session_id) {
691 Ok(transition) => transition,
692 Err(error) => return commands::CommandResult::error(error),
693 };
694 install_offline_queue_transition(app, transition);
695 if let Some(workspace) = workspace {
696 app.workspace = workspace;
697 }
698 app.current_session_id = Some(session_id.clone());
699 app.current_session_metadata = None;
700 app.session_title = Some(app.tr(MessageId::SessionsNewSessionTitle).into_owned());
701 app.launch.dismiss();
702 app.launch.status = None;
703 app.status_message = None;
704 commands::CommandResult::action(AppAction::SyncSession {
705 session_id: Some(session_id),
706 messages: Vec::new(),
707 system_prompt: None,
708 model: app.model.clone(),
709 workspace: app.workspace.clone(),
710 mode: app.mode,
711 })
712 }
713
714 pub(crate) async fn sync_runtime_workspace_state(
715 task_manager: &SharedTaskManager,
716 workspace: PathBuf,
717 ) {
718 task_manager.set_default_workspace(workspace).await;
719 }
720
721 pub(crate) async fn switch_workspace(
722 app: &mut App,
723 engine_handle: &mut EngineHandle,
724 task_manager: &SharedTaskManager,
725 config: &Config,
726 workspace: PathBuf,
727 ) {
728 if app.is_loading {
729 app.status_message =
730 Some("Cannot switch workspace while a request is running.".to_string());
731 app.add_message(HistoryCell::System {
732 content: "Cannot switch workspace while a request is running.".to_string(),
733 });
734 return;
735 }
736
737 if app.workspace == workspace {
738 app.status_message = Some(format!("Workspace unchanged: {}", workspace.display()));
739 return;
740 }
741
742 apply_workspace_runtime_state(app, config, workspace.clone());
743 sync_runtime_workspace_state(task_manager, workspace.clone()).await;
744
745 let _ = engine_handle.send(Op::Shutdown).await;
746 let engine_config = build_engine_config(app, config);
747 *engine_handle = spawn_tui_engine(engine_config, config);
748 if !app.api_messages.is_empty() {
749 let _ = engine_handle
750 .send(Op::SyncSession {
751 session_id: app.current_session_id.clone(),
752 messages: app.api_messages.as_ref().clone(),
753 system_prompt: app.system_prompt.clone(),
754 system_prompt_override: false,
755 model: app.model.clone(),
756 workspace: workspace.clone(),
757 mode: app.mode,
758 })
759 .await;
760 }
761
762 app.add_message(HistoryCell::System {
763 content: format!("Switched workspace to {}", workspace.display()),
764 });
765 app.status_message = Some(format!("Workspace: {}", workspace.display()));
766 }
767
768 /// Auth / missing-key failures: keep the transcript user bubble and clear the
769 /// composer (the turn was submitted). Surface the error without "restored to
770 /// composer" — the echo already owns the text.
771 pub(crate) fn keep_failed_immediate_submit_echo(
772 app: &mut App,
773 message: QueuedMessage,
774 error: &str,
775 ) {
776 tracing::warn!(
777 error = %error,
778 "immediate user message dispatch failed auth; keeping transcript echo"
779 );
780 // Composer stays empty — HistoryCell::User already holds the turn.
781 let _ = message;
782 let status = format!("Message not sent ({error})");
783 app.status_message = Some(status.clone());
784 app.set_sticky_status(
785 status,
786 StatusToastLevel::Error,
787 Some(App::STICKY_ERROR_TTL_MS),
788 );
789 app.needs_redraw = true;
790 }
791
792 pub(crate) fn restore_failed_immediate_submit(
793 app: &mut App,
794 message: QueuedMessage,
795 error: &anyhow::Error,
796 ) {
797 tracing::warn!(
798 error = %error,
799 "immediate user message dispatch failed; restored composer"
800 );
801 app.input = message.display;
802 app.cursor_position = app.input.chars().count();
803 app.active_skill = message.skill_instruction;
804 app.active_skill_provenance = message.skill_provenance;
805 let status = tr(app.ui_locale, MessageId::ComposerDispatchFailedRestored)
806 .replace("{error}", &error.to_string());
807 app.status_message = Some(status.clone());
808 app.set_sticky_status(
809 status,
810 StatusToastLevel::Error,
811 Some(App::STICKY_ERROR_TTL_MS),
812 );
813 app.needs_redraw = true;
814 }
815
816 /// Show the default recommended Hotbar slots. Since #3807 an absent `hotbar`
817 /// key means "hidden", so `/hotbar on` persists the explicit default bindings
818 /// rather than deleting the key. This is an explicit reset, so any custom
819 /// bindings are replaced with the recommended set.
820 pub(crate) fn restore_hotbar_defaults(app: &mut App, config: &mut Config) {
821 let defaults = codewhale_config::default_hotbar_bindings_toml();
822 match crate::config_persistence::persist_hotbar_bindings(app.config_path.as_deref(), &defaults)
823 {
824 Ok(path) => {
825 config.hotbar = Some(defaults);
826 app.status_message = Some(format!(
827 "Hotbar enabled with the default slots ({}). Customize with `/hotbar`.",
828 path.display()
829 ));
830 }
831 Err(err) => {
832 app.status_message = Some(format!("Failed to enable the Hotbar: {err}"));
833 app.add_message(HistoryCell::System {
834 content: format!("Failed to enable the Hotbar: {err}"),
835 });
836 }
837 }
838 app.needs_redraw = true;
839 }
840
841 pub(crate) fn persist_rules_from_approval(
842 app: &mut App,
843 config: &mut Config,
844 rules: &[codewhale_config::ToolAskRule],
845 ) {
846 let action = rules.first().map(|rule| rule.action);
847 match codewhale_config::ConfigStore::load(app.config_path.clone()).and_then(|mut store| {
848 let added = match action {
849 Some(codewhale_execpolicy::PermissionAction::Ask) => store.append_ask_rules(rules)?,
850 Some(codewhale_execpolicy::PermissionAction::Allow) => {
851 store.append_allow_rules(rules)?
852 }
853 Some(codewhale_execpolicy::PermissionAction::Deny) => {
854 anyhow::bail!("the approval UI cannot persist deny rules")
855 }
856 None => 0,
857 };
858 let permissions_path = store.permissions_path();
859 config
860 .exec_policy_engine
861 .set_ruleset(store.permissions().ruleset());
862 Ok((added, permissions_path))
863 }) {
864 Ok((added, path)) if added > 0 => {
865 let action = match action {
866 Some(codewhale_execpolicy::PermissionAction::Allow) => "allow",
867 _ => "ask",
868 };
869 app.status_message = Some(format!(
870 "Saved {added} {action} permission rule(s) to {}",
871 path.display()
872 ));
873 }
874 Ok((_added, path)) => {
875 let action = match action {
876 Some(codewhale_execpolicy::PermissionAction::Allow) => "Allow",
877 _ => "Ask",
878 };
879 app.status_message = Some(format!(
880 "{action} permission rule already saved in {}",
881 path.display()
882 ));
883 }
884 Err(err) => {
885 app.status_message = Some(format!("Failed to save permission rule: {err:#}"));
886 }
887 }
888 }
889
890 pub(crate) fn mirror_saved_model_in_config(
891 config: &mut Config,
892 provider: ApiProvider,
893 model: String,
894 ) {
895 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
896 config.default_text_model = Some(model);
897 return;
898 }
899 config.set_provider_model_override(provider, Some(model));
900 }
901
902 pub(crate) fn mirror_saved_context_window_in_config(
903 config: &mut Config,
904 provider: ApiProvider,
905 context_window: u32,
906 ) {
907 let providers = config
908 .providers
909 .get_or_insert_with(ProvidersConfig::default);
910 let entry = match provider {
911 ApiProvider::Moonshot => &mut providers.moonshot,
912 _ => return,
913 };
914 entry.context_window = Some(context_window);
915 }
916
917 pub(crate) fn mirror_saved_api_key_in_config(
918 config: &mut Config,
919 provider: ApiProvider,
920 api_key: String,
921 ) {
922 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
923 config.api_key = Some(api_key);
924 config.auth_mode = Some("api_key".to_string());
925 return;
926 }
927 if provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route() {
928 config.api_key = Some(api_key);
929 config.auth_mode = Some("api_key".to_string());
930 return;
931 }
932 let pin_kimi_code_base_url = provider == ApiProvider::Moonshot
933 && config.provider_config_for(provider).is_some_and(|entry| {
934 crate::config::provider_config_uses_kimi_imported_token(entry)
935 && entry
936 .base_url
937 .as_deref()
938 .is_none_or(|base_url| base_url.trim().is_empty())
939 });
940 let custom_key = (provider == ApiProvider::Custom).then(|| {
941 config
942 .provider
943 .clone()
944 .unwrap_or_else(|| "__custom__".to_string())
945 });
946 let providers = config
947 .providers
948 .get_or_insert_with(ProvidersConfig::default);
949 let entry: &mut ProviderConfig = match provider {
950 ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,
951 ApiProvider::Custom => providers
952 .custom
953 .entry(custom_key.expect("custom key captured for custom provider"))
954 .or_default(),
955 ApiProvider::DeepseekAnthropic => &mut providers.deepseek_anthropic,
956 ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
957 ApiProvider::Openai => &mut providers.openai,
958 ApiProvider::Atlascloud => &mut providers.atlascloud,
959 ApiProvider::WanjieArk => &mut providers.wanjie_ark,
960 ApiProvider::Volcengine => &mut providers.volcengine,
961 ApiProvider::Openrouter => &mut providers.openrouter,
962 ApiProvider::Orcarouter => &mut providers.orcarouter,
963 ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
964 ApiProvider::Novita => &mut providers.novita,
965 ApiProvider::Fireworks => &mut providers.fireworks,
966 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &mut providers.siliconflow,
967 ApiProvider::Arcee => &mut providers.arcee,
968 ApiProvider::Moonshot => &mut providers.moonshot,
969 ApiProvider::Sglang => &mut providers.sglang,
970 ApiProvider::Vllm => &mut providers.vllm,
971 ApiProvider::Ollama => &mut providers.ollama,
972 ApiProvider::OllamaCloud => &mut providers.ollama_cloud,
973 ApiProvider::Huggingface => &mut providers.huggingface,
974 ApiProvider::Modelscope => &mut providers.modelscope,
975 ApiProvider::Deepinfra => &mut providers.deepinfra,
976 ApiProvider::Together => &mut providers.together,
977 ApiProvider::Qianfan => &mut providers.qianfan,
978 ApiProvider::OpenaiCodex => &mut providers.openai_codex,
979 ApiProvider::Anthropic => &mut providers.anthropic,
980 ApiProvider::Openmodel => &mut providers.openmodel,
981 ApiProvider::Zai => &mut providers.zai,
982 ApiProvider::Stepfun => &mut providers.stepfun,
983 ApiProvider::Minimax => &mut providers.minimax,
984 ApiProvider::MinimaxAnthropic => &mut providers.minimax_anthropic,
985 ApiProvider::Sakana => &mut providers.sakana,
986 ApiProvider::LongCat => &mut providers.longcat,
987 ApiProvider::OpencodeGo => &mut providers.opencode_go,
988 ApiProvider::OpencodeZen => &mut providers.opencode_zen,
989 ApiProvider::Meta => &mut providers.meta,
990 ApiProvider::Xai => &mut providers.xai,
991 ApiProvider::Mistral => &mut providers.mistral,
992 ApiProvider::Google => &mut providers.google,
993 ApiProvider::Antigravity => &mut providers.antigravity,
994 ApiProvider::Telecomjs => &mut providers.telecomjs,
995 ApiProvider::Edenai => &mut providers.edenai,
996 ApiProvider::Zenmux => &mut providers.zenmux,
997 ApiProvider::Csdn => &mut providers.csdn,
998 ApiProvider::Concentrate => &mut providers.concentrate,
999 ApiProvider::Codewhale => &mut providers.codewhale,
1000 ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan,
1001 ApiProvider::ModelstudioTokenPlanAnthropic => {
1002 &mut providers.modelstudio_token_plan_anthropic
1003 }
1004 ApiProvider::ModelstudioCodingPlan => &mut providers.modelstudio_coding_plan,
1005 ApiProvider::ModelstudioCodingPlanAnthropic => {
1006 &mut providers.modelstudio_coding_plan_anthropic
1007 }
1008 };
1009 if pin_kimi_code_base_url {
1010 entry.base_url = Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string());
1011 }
1012 entry.auth_mode = Some("api_key".to_string());
1013 entry.api_key = Some(api_key);
1014 entry.external_credentials = None;
1015 if provider == ApiProvider::Xai {
1016 entry.oauth_credential_generation = None;
1017 }
1018 }
1019
1020 pub(crate) fn loaded_session_requires_engine_respawn(
1021 app: &App,
1022 previous_provider: ApiProvider,
1023 previous_provider_identity: &str,
1024 previous_workspace: &Path,
1025 ) -> bool {
1026 app.api_provider != previous_provider
1027 || app.provider_identity_for_persistence() != previous_provider_identity
1028 || app.workspace != previous_workspace
1029 }
1030
1031 pub(crate) fn restore_loaded_session_provider(
1032 app: &mut App,
1033 config: &mut Config,
1034 identity: ProviderIdentity,
1035 ) {
1036 let provider = identity.provider;
1037 config.scope_to_provider_identity(&identity);
1038 app.set_provider_identity_record(identity);
1039 app.billing_presentation = crate::route_billing::for_route(config, provider);
1040 app.max_subagents = config
1041 .max_subagents_for_provider(provider)
1042 .clamp(1, crate::config::MAX_SUBAGENTS);
1043 app.provider_chain = provider
1044 .kind()
1045 .map(|kind| codewhale_config::ProviderChain::new(kind, &config.fallback_providers))
1046 .filter(|chain| chain.providers().len() > 1);
1047 app.last_fallback_reason = None;
1048 app.model_ids_passthrough = config.model_ids_pass_through();
1049 if !app.auto_model {
1050 let requested = app
1051 .reasoning_effort_preference
1052 .unwrap_or(app.reasoning_effort);
1053 app.reasoning_effort =
1054 requested.normalize_for_route(provider, &config.active_route_base_url(), &app.model);
1055 }
1056 app.set_active_context_window_override(config, provider);
1057 app.active_route_limits = app.context_window_override_limits();
1058 app.active_route_base_url = config.active_route_base_url();
1059 app.active_context_window_source = app
1060 .configured_context_window_for(&app.model)
1061 .map(|resolution| resolution.source)
1062 .unwrap_or(crate::route_runtime::ContextWindowSource::Fallback);
1063 }
1064
1065 pub(crate) fn resolve_loaded_session_route(app: &mut App, config: &Config) {
1066 app.set_active_context_window_override(config, app.api_provider);
1067 if app.auto_model {
1068 app.active_route_limits = app.context_window_override_limits();
1069 app.active_route_base_url = config.active_route_base_url();
1070 app.active_context_window_source = app
1071 .configured_context_window_for(&app.model)
1072 .map(|resolution| resolution.source)
1073 .unwrap_or(crate::route_runtime::ContextWindowSource::Fallback);
1074 return;
1075 }
1076
1077 match crate::route_runtime::resolve_runtime_route(config, app.api_provider, Some(&app.model)) {
1078 Ok(resolution) => {
1079 app.set_active_route_resolution(
1080 resolution.candidate.endpoint().base_url.clone(),
1081 resolution.candidate.limits(),
1082 resolution.context_window.source,
1083 );
1084 }
1085 Err(_) => {
1086 app.active_route_limits = app.context_window_override_limits();
1087 app.active_route_base_url = config.active_route_base_url();
1088 app.active_context_window_source = app
1089 .configured_context_window_for(&app.model)
1090 .map(|resolution| resolution.source)
1091 .unwrap_or(crate::route_runtime::ContextWindowSource::Fallback);
1092 }
1093 }
1094 }
1095
1096 /// Derive a short display title from the API message list.
1097 ///
1098 /// Tries several strategies in order:
1099 /// 1. If the first user message starts with a known slash command (`/goal`,
1100 /// `/fleet`, `/workflow`, etc.), use the command + first argument.
1101 /// 2. Otherwise, take the first meaningful line and cut it at a natural
1102 /// phrase boundary (period, comma, colon, or word boundary) within
1103 /// `SESSION_TITLE_MAX_CHARS`, never splitting mid-word.
1104 ///
1105 /// Never leaks raw prompt text — the result is always a concise label.
1106 pub(crate) fn derive_session_title(messages: &[Message]) -> Option<String> {
1107 let text = crate::session_manager::conversation_title_prompt(messages)?;
1108
1109 let first_line =
1110 crate::session_manager::sanitize_session_title(text.lines().next().unwrap_or("").trim());
1111 let first_line = first_line.trim();
1112 if first_line.is_empty() {
1113 return None;
1114 }
1115
1116 // Slash command: extract command name + first reasonable argument.
1117 if let Some(rest) = first_line.strip_prefix('/') {
1118 let parts: Vec<&str> = rest.split_whitespace().collect();
1119 return match parts.as_slice() {
1120 [] => None,
1121 [cmd] => Some(format!("/{cmd}")),
1122 [cmd, arg, ..] => {
1123 let arg_short = short_title_truncate(arg, 24);
1124 Some(format!("/{cmd} {arg_short}"))
1125 }
1126 };
1127 }
1128
1129 Some(short_title_truncate(first_line, SESSION_TITLE_MAX_CHARS))
1130 }
1131
1132 #[cfg(test)]
1133 mod derived_title_tests {
1134 use super::*;
1135 use codewhale_models::Role;
1136
1137 fn user(text: &str) -> Message {
1138 Message {
1139 role: Role::User,
1140 content: vec![ContentBlock::Text {
1141 text: text.to_string(),
1142 cache_control: None,
1143 }],
1144 }
1145 }
1146
1147 #[test]
1148 fn derived_titles_drop_terminal_controls_and_bidi_format_chars() {
1149 // The first user message can carry pasted escape sequences; the
1150 // derived session name must never persist them.
1151 let msgs = [user("Fix \u{1b}]0;PWNED\u{7}the\u{202e} build 会議")];
1152 assert_eq!(
1153 derive_session_title(&msgs).as_deref(),
1154 Some("Fix ]0;PWNEDthe build 会議")
1155 );
1156 // Controls alone leave no title to derive.
1157 assert_eq!(derive_session_title(&[user("\u{1b}\u{7}\u{200b}")]), None);
1158 }
1159
1160 #[test]
1161 fn live_title_uses_the_same_user_prompt_after_runtime_handoffs() {
1162 let handoff = crate::runtime_handoff::operate_contract_runtime_message();
1163 assert_eq!(derive_session_title(std::slice::from_ref(&handoff)), None);
1164 let messages = [handoff, user("/goal Fix the diagnostic display")];
1165 assert_eq!(
1166 derive_session_title(&messages).as_deref(),
1167 Some("/goal Fix")
1168 );
1169 assert_eq!(
1170 crate::session_manager::conversation_title_prompt(&messages),
1171 Some("/goal Fix the diagnostic display")
1172 );
1173 }
1174 }
1175
1176 #[cfg(test)]
1177 mod stall_outbox_tests {
1178 use super::*;
1179 use crate::tui::app::TuiOptions;
1180
1181 /// `recover_stalled_runtime_turn` must emit a `turn_stalled` lifecycle
1182 /// outbox event naming the wedged turn — the first scriptable stall
1183 /// signal. The outbox is opt-in, so the test enables it through config.
1184 #[tokio::test]
1185 async fn stalled_turn_emits_turn_stalled_outbox_event() {
1186 let _lock = crate::test_support::lock_test_env();
1187 let dir = tempfile::tempdir().expect("tempdir");
1188 let outbox_path = dir.path().join("outbox.jsonl");
1189
1190 let config = Config {
1191 lifecycle_outbox: Some(codewhale_config::LifecycleOutboxToml {
1192 path: Some(outbox_path.clone()),
1193 webhook_url: None,
1194 webhook_token: None,
1195 }),
1196 ..Default::default()
1197 };
1198 let options = TuiOptions {
1199 start_in_agent_mode: true,
1200 ..crate::test_support::test_tui_options(dir.path())
1201 };
1202 let mut app = App::new(options, &config);
1203 assert!(app.lifecycle_outbox.is_enabled());
1204 let expected_workspace = app.workspace.display().to_string();
1205
1206 app.runtime_turn_id = Some("turn-1".to_string());
1207 app.runtime_turn_status = Some("in_progress".to_string());
1208 app.is_loading = true;
1209 recover_stalled_runtime_turn(
1210 &mut app,
1211 "Turn stalled — no completion signal received",
1212 StatusToastLevel::Error,
1213 );
1214
1215 // The outbox writer task drains asynchronously; wait for the line.
1216 let mut lines = Vec::new();
1217 for _ in 0..200 {
1218 if let Ok(text) = tokio::fs::read_to_string(&outbox_path).await {
1219 lines = text
1220 .lines()
1221 .map(|line| serde_json::from_str::<serde_json::Value>(line).expect("json"))
1222 .collect();
1223 if !lines.is_empty() {
1224 break;
1225 }
1226 }
1227 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1228 }
1229
1230 assert_eq!(lines.len(), 1, "expected one turn_stalled outbox line");
1231 let line = &lines[0];
1232 assert_eq!(line["event"], "turn_stalled");
1233 assert_eq!(line["kind"], "turn.stalled");
1234 assert_eq!(line["turn_id"], "turn-1");
1235 assert_eq!(line["schema_version"], 1);
1236 assert_eq!(line["seq"], 1);
1237 // Every payload carries the workspace for consumer-side routing.
1238 assert_eq!(
1239 line["payload"]["workspace"],
1240 serde_json::json!(expected_workspace)
1241 );
1242 // The stall message is engine-authored and safe, but still bounded
1243 // and never raw tool/environment content.
1244 let message = line["payload"]["message"].as_str().expect("message");
1245 assert!(message.contains("stalled"));
1246 assert!(
1247 message.chars().count() <= codewhale_hooks::OUTBOX_DETAIL_MAX_CHARS,
1248 "stall message must be bounded"
1249 );
1250 }
1251
1252 /// A disabled outbox (config without a path) must make stall recovery
1253 /// behave exactly as before: the toast still lands, no file is written.
1254 #[tokio::test]
1255 async fn stalled_turn_without_outbox_config_writes_nothing() {
1256 let _lock = crate::test_support::lock_test_env();
1257 let dir = tempfile::tempdir().expect("tempdir");
1258 let options = TuiOptions {
1259 start_in_agent_mode: true,
1260 ..crate::test_support::test_tui_options(dir.path())
1261 };
1262 let mut app = App::new(options, &Config::default());
1263 assert!(!app.lifecycle_outbox.is_enabled());
1264
1265 app.runtime_turn_id = Some("turn-1".to_string());
1266 app.runtime_turn_status = Some("in_progress".to_string());
1267 app.is_loading = true;
1268 recover_stalled_runtime_turn(
1269 &mut app,
1270 "Turn stalled — no completion signal received",
1271 StatusToastLevel::Error,
1272 );
1273
1274 // Recovery still clears the wedged turn state and posts the toast.
1275 assert!(app.runtime_turn_id.is_none());
1276 assert!(!app.is_loading);
1277 assert!(!app.status_toasts.is_empty());
1278 assert!(
1279 !dir.path().join("outbox.jsonl").exists(),
1280 "no outbox file must be created when the feature is off"
1281 );
1282 }
1283 }
1284
1285 #[cfg(test)]
1286 mod launch_resume_tests {
1287 use super::*;
1288
1289 /// A recent-work row that vanished behind the card must leave the card
1290 /// up with a status — never strand the user on an empty stage.
1291 #[test]
1292 fn resume_missing_session_leaves_the_card_up_with_a_status() {
1293 let dir = tempfile::tempdir().unwrap();
1294 let mut app = App::new(
1295 crate::test_support::test_tui_options(dir.path()),
1296 &Config::default(),
1297 );
1298 app.launch.visible = true;
1299 let result = resume_launch_session(&mut app, "no-such-session-000000");
1300 assert!(result.action.is_none(), "nothing to load");
1301 assert!(app.launch.visible, "the card stays up");
1302 let status = app.launch.status.as_deref().expect("a status");
1303 assert!(
1304 status.contains("Resume failed"),
1305 "the status says why: {status}"
1306 );
1307 }
1308
1309 /// The prominent new-session entry begins a fresh session in place.
1310 #[test]
1311 fn new_session_begins_a_fresh_session_and_leaves_the_card() {
1312 let dir = tempfile::tempdir().unwrap();
1313 let mut app = App::new(
1314 crate::test_support::test_tui_options(dir.path()),
1315 &Config::default(),
1316 );
1317 app.launch.visible = true;
1318 let result = begin_launch_session(&mut app, None);
1319 assert!(!app.launch.visible, "the session began");
1320 assert!(
1321 app.current_session_id.is_some(),
1322 "a fresh session id was minted"
1323 );
1324 assert!(
1325 matches!(result.action, Some(AppAction::SyncSession { .. })),
1326 "the engine syncs the fresh session"
1327 );
1328 }
1329 }
1330
1330 lines RUST