//! Session, resume, fork, and subagent selection lifecycle for the TUI app.//!//! This module owns the high-level transitions between app-server threads: starting fresh sessions,//! resuming/forking saved sessions, replacing ChatWidget instances, and maintaining the agent picker//! cache used for multi-agent navigation.use super::*;impl App { pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) { self.backfill_loaded_subagent_threads(app_server).await; // V2 subagents are identified by canonical paths observed from activity events or loaded // thread metadata. Prefer local buffered turn state for liveness, and fall back to // thread/read only when no local event channel exists. let path_backed_thread_ids: Vec<_> = self .agent_navigation .ordered_path_backed_subagent_threads(self.primary_thread_id) .into_iter() .map(|(thread_id, _)| thread_id) .collect(); for thread_id in path_backed_thread_ids { if let Some(channel) = self.thread_event_channels.get(&thread_id) && channel.attachment() == ThreadEventAttachment::Live { let is_running = channel.store.lock().await.active_turn_id().is_some(); self.agent_navigation.set_running(thread_id, is_running); } else { self.refresh_agent_picker_thread_liveness(app_server, thread_id) .await; } } let path_backed_threads = self .agent_navigation .ordered_path_backed_subagent_threads(self.primary_thread_id); if !path_backed_threads.is_empty() { let running_threads: Vec<_> = path_backed_threads .into_iter() .filter_map(|(thread_id, entry)| { if !entry.is_running || entry.is_closed { return None; } Some((thread_id, entry.agent_path.as_deref()?.trim().to_string())) }) .collect(); let mut entries = Vec::new(); for (thread_id, agent_path) in running_threads { let preview = if let Some(channel) = self.thread_event_channels.get(&thread_id) { let store = channel.store.lock().await; super::agent_status_feed::AgentStatusThreadPreview::from_store( agent_path, &store, ) } else { super::agent_status_feed::AgentStatusThreadPreview::empty(agent_path) }; entries.push(preview); } self.chat_widget .add_to_history(super::agent_status_feed::AgentStatusHistoryCell::new( entries, )); return; } let mut thread_ids = self.agent_navigation.tracked_thread_ids(); for thread_id in self.thread_event_channels.keys().copied() { if !thread_ids.contains(&thread_id) { thread_ids.push(thread_id); } } for thread_id in thread_ids { if self.side_threads.contains_key(&thread_id) { continue; } if !self .refresh_agent_picker_thread_liveness(app_server, thread_id) .await { continue; } } let has_non_primary_agent_thread = self .agent_navigation .has_non_primary_thread(self.primary_thread_id); if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread { self.chat_widget.open_multi_agent_enable_prompt(); return; } if self.agent_navigation.is_empty() { self.chat_widget .add_info_message("No agents available yet.".to_string(), /*hint*/ None); return; } let mut initial_selected_idx = None; let items: Vec<SelectionItem> = self .agent_navigation .ordered_threads() .into_iter() .enumerate() .map(|(idx, (thread_id, entry))| { if self.active_thread_id == Some(thread_id) { initial_selected_idx = Some(idx); } let id = thread_id; let is_primary = self.primary_thread_id == Some(thread_id); let name = format_agent_picker_item_name( entry.agent_nickname.as_deref(), entry.agent_role.as_deref(), is_primary, ); let uuid = thread_id.to_string(); SelectionItem { name: name.clone(), name_prefix_spans: agent_picker_status_dot_spans(entry.is_closed), description: Some(uuid.clone()), is_current: self.active_thread_id == Some(thread_id), actions: vec![Box::new(move |tx| { tx.send(AppEvent::SelectAgentThread(id)); })], dismiss_on_select: true, search_value: Some(format!("{name} {uuid}")), ..Default::default() } }) .collect(); self.chat_widget.show_selection_view(SelectionViewParams { title: Some("Subagents".to_string()), subtitle: Some(AgentNavigationState::picker_subtitle()), footer_hint: Some(standard_popup_hint_line()), items, initial_selected_idx, ..Default::default() }); } pub(super) fn is_terminal_thread_read_error(err: &color_eyre::Report) -> bool { err.chain() .any(|cause| cause.to_string().contains("thread not loaded:")) } pub(super) fn closed_state_for_thread_read_error( err: &color_eyre::Report, existing_is_closed: Option<bool>, ) -> bool { Self::is_terminal_thread_read_error(err) || existing_is_closed.unwrap_or(false) } pub(super) fn can_fallback_from_include_turns_error(err: &color_eyre::Report) -> bool { err.chain().any(|cause| { let message = cause.to_string(); message.contains("includeTurns is unavailable before first user message") || message.contains("ephemeral threads do not support includeTurns") }) } /// Updates cached picker metadata and then mirrors any visible-label change into the footer. /// /// These two writes stay paired so the picker rows and contextual footer continue to describe /// the same displayed thread after nickname or role updates. pub(super) fn upsert_agent_picker_thread( &mut self, thread_id: ThreadId, agent_nickname: Option<String>, agent_role: Option<String>, is_closed: bool, ) { self.chat_widget.set_collab_agent_metadata( thread_id, agent_nickname.clone(), agent_role.clone(), ); self.agent_navigation .upsert(thread_id, agent_nickname, agent_role, is_closed); self.sync_active_agent_label(); } /// Marks a cached picker thread closed and recomputes the contextual footer label. /// /// Closing a thread is not the same as removing it: users can still inspect finished agent /// transcripts, and the stable next/previous traversal order should not collapse around them. pub(super) fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) { self.agent_navigation.mark_closed(thread_id); self.sync_active_agent_label(); } pub(super) async fn refresh_agent_picker_thread_liveness( &mut self, app_server: &mut AppServerSession, thread_id: ThreadId, ) -> bool { let existing_entry = self.agent_navigation.get(&thread_id).cloned(); let has_replay_channel = self.thread_event_channels.contains_key(&thread_id); match app_server .thread_read(thread_id, /*include_turns*/ false) .await { Ok(thread) => { let is_running = matches!( thread.status, codex_app_server_protocol::ThreadStatus::Active { .. } ); let is_closed = matches!( thread.status, codex_app_server_protocol::ThreadStatus::NotLoaded ); self.upsert_agent_picker_thread( thread_id, thread.agent_nickname.or_else(|| { existing_entry .as_ref() .and_then(|entry| entry.agent_nickname.clone()) }), thread.agent_role.or_else(|| { existing_entry .as_ref() .and_then(|entry| entry.agent_role.clone()) }), is_closed, ); self.agent_navigation.set_running(thread_id, is_running); true } Err(err) => { if Self::is_terminal_thread_read_error(&err) && !has_replay_channel { self.agent_navigation.remove(thread_id); return false; } let is_closed = Self::closed_state_for_thread_read_error( &err, existing_entry.as_ref().map(|entry| entry.is_closed), ); if let Some(entry) = existing_entry { self.upsert_agent_picker_thread( thread_id, entry.agent_nickname, entry.agent_role, is_closed, ); } else { self.upsert_agent_picker_thread( thread_id, /*agent_nickname*/ None, /*agent_role*/ None, is_closed, ); } self.agent_navigation .set_running(thread_id, /*is_running*/ false); true } } } /// Materializes a live thread into local replay state when the picker knows about it but the /// TUI has not cached a local event channel yet. /// /// Resume-time backfill intentionally avoids creating empty placeholder channels, because those /// placeholders make stale `/agent` entries open blank transcripts. When a user later selects a /// still-live discovered thread, attach it on demand with a real resumed snapshot. pub(super) async fn attach_live_thread_for_selection( &mut self, app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result<bool> { if self.thread_event_channels.contains_key(&thread_id) { return Ok(true); } let (session, turns, live_attached) = match app_server .resume_thread(self.config.clone(), thread_id) .await { Ok(started) => (started.session, started.turns, true), Err(resume_err) => { tracing::warn!( thread_id = %thread_id, error = %resume_err, "failed to resume live thread for selection; falling back to thread/read" ); let (thread, turns) = match app_server .thread_read(thread_id, /*include_turns*/ true) .await { Ok(thread) => { let turns = thread.turns.clone(); (thread, turns) } Err(err) if Self::can_fallback_from_include_turns_error(&err) => { let thread = app_server .thread_read(thread_id, /*include_turns*/ false) .await?; (thread, Vec::new()) } Err(err) => return Err(err), }; if turns.is_empty() { // A `thread/read` fallback without turns would create a blank local replay // channel with no live listener attached, which blocks later real re-attach. return Err(color_eyre::eyre::eyre!( "Agent thread {thread_id} is not yet available for replay or live attach." )); } let mut session = self.session_state_for_thread_read(thread_id, &thread).await; // `thread/read` can seed replay state, but it does not attach the app-server // listener that `thread/resume` establishes, so treat this path as replay-only. session.model.clear(); (session, turns, false) } }; let channel = self.ensure_thread_channel(thread_id); if !live_attached { channel.mark_replay_only(); } let mut store = channel.store.lock().await; store.set_session(session, turns); Ok(live_attached) } /// Replaces the chat widget and re-seeds the new widget's collab metadata from the navigation /// cache. /// /// Thread switches reconstruct the `ChatWidget`, which loses the `collab_agent_metadata` map. /// This helper copies every known nickname/role from `AgentNavigationState` into the /// replacement widget so that replayed collab items render agent names immediately. pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { // Transfer the last-written terminal title to the replacement widget // so it knows what OSC title is currently displayed. Without this, the // new widget would redundantly clear and rewrite the same title, causing // a visible flicker in some terminals. let previous_terminal_title = self.chat_widget.last_terminal_title.take(); if chat_widget.last_terminal_title.is_none() { chat_widget.last_terminal_title = previous_terminal_title; } chat_widget.remote_connection = self.chat_widget.remote_connection.clone(); for (thread_id, entry) in self.agent_navigation.ordered_threads() { chat_widget.set_collab_agent_metadata( thread_id, entry.agent_nickname.clone(), entry.agent_role.clone(), ); } self.chat_widget = chat_widget; self.sync_active_agent_label(); } pub(super) async fn select_agent_thread( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result<()> { if self.active_thread_id == Some(thread_id) { return Ok(()); } if !self .refresh_agent_picker_thread_liveness(app_server, thread_id) .await { self.chat_widget .add_error_message(format!("Agent thread {thread_id} is no longer available.")); return Ok(()); } let mut is_replay_only = self .agent_navigation .get(&thread_id) .is_some_and(|entry| entry.is_closed); let mut attached_replay_only = false; if self.should_attach_live_thread_for_selection(thread_id) { match self .attach_live_thread_for_selection(app_server, thread_id) .await { Ok(live_attached) => { attached_replay_only = !live_attached; if attached_replay_only { is_replay_only = true; } } Err(err) => { self.chat_widget.add_error_message(format!( "Failed to attach to agent thread {thread_id}: {err}" )); return Ok(()); } } } else if !self.thread_event_channels.contains_key(&thread_id) && is_replay_only { self.chat_widget .add_error_message(format!("Agent thread {thread_id} is no longer available.")); return Ok(()); } let previous_thread_id = self.active_thread_id; self.store_active_thread_receiver().await; self.active_thread_id = None; let Some((receiver, mut snapshot)) = self.activate_thread_for_replay(thread_id).await else { self.chat_widget .add_error_message(format!("Agent thread {thread_id} is already active.")); if let Some(previous_thread_id) = previous_thread_id { self.activate_thread_channel(previous_thread_id).await; } return Ok(()); }; self.refresh_snapshot_session_if_needed( app_server, thread_id, is_replay_only, &mut snapshot, ) .await; self.active_thread_id = Some(thread_id); self.active_thread_rx = Some(receiver); let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, self.config.clone(), /*initial_user_message*/ None, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot, !is_replay_only); if is_replay_only { let message = if attached_replay_only { format!( "Agent thread {thread_id} could not be resumed live. Replaying saved transcript." ) } else { format!("Agent thread {thread_id} is closed. Replaying saved transcript.") }; self.chat_widget.add_info_message(message, /*hint*/ None); } self.drain_active_thread_events(tui).await?; self.refresh_pending_thread_approvals().await; Ok(()) } pub(super) fn should_attach_live_thread_for_selection(&self, thread_id: ThreadId) -> bool { !self.thread_event_channels.contains_key(&thread_id) && self .agent_navigation .get(&thread_id) .is_none_or(|entry| !entry.is_closed) } pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { self.reset_transcript_state_after_clear(); tui.clear_pending_history_lines(); Self::clear_terminal_for_thread_switch(&mut tui.terminal)?; Ok(()) } pub(super) fn clear_terminal_for_thread_switch<B>( terminal: &mut crate::custom_terminal::Terminal<B>, ) -> Result<()> where B: Backend + Write, { terminal.clear_scrollback_and_visible_screen_ansi()?; let mut area = terminal.viewport_area; if area.y > 0 { area.y = 0; terminal.set_viewport_area(area); } Ok(()) } pub(super) fn reset_thread_event_state(&mut self) { self.abort_all_thread_event_listeners(); self.thread_event_channels.clear(); self.agent_navigation.clear(); self.side_threads.clear(); self.active_thread_id = None; self.active_thread_rx = None; self.primary_thread_id = None; self.last_subagent_backfill_attempt = None; self.primary_session_configured = None; self.pending_primary_events.clear(); self.pending_app_server_requests.clear(); self.pending_startup_thread_start = false; self.chat_widget.set_pending_thread_approvals(Vec::new()); self.sync_active_agent_label(); } pub(super) async fn handle_startup_thread_started( &mut self, app_server: &mut AppServerSession, result: Result<AppServerStartedThread, String>, ) -> Result<()> { if !self.pending_startup_thread_start { if let Ok(started) = result { let thread_id = started.session.thread_id; if let Err(err) = app_server.thread_unsubscribe(thread_id).await { tracing::warn!( thread_id = %thread_id, "failed to unsubscribe stale startup thread: {err}" ); } self.discard_thread_local_state(thread_id).await; } return Ok(()); } self.pending_startup_thread_start = false; self.chat_widget .set_queue_submissions_until_session_configured(/*queue*/ false); match result { Ok(started) => { self.enqueue_primary_thread_session(started.session, started.turns) .await?; self.chat_widget.maybe_send_next_queued_input(); } Err(err) => { return Err(color_eyre::eyre::eyre!( "Failed to start a fresh session through the app server: {err}" )); } } Ok(()) } pub(super) async fn start_fresh_session_with_summary_hint( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, session_start_source: Option<ThreadStartSource>, initial_user_message: Option<crate::chatwidget::UserMessage>, ) { // Start a fresh in-memory session while preserving resumability via persisted rollout // history. If an initial message is provided, `enqueue_primary_thread_session` suppresses it // until the new session is configured and any replayed turns have been rendered. self.refresh_in_memory_config_from_disk_best_effort("starting a new thread") .await; let model = self.chat_widget.current_model().to_string(); let config = self.fresh_session_config(); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), self.chat_widget.thread_name(), self.chat_widget.rollout_path().as_deref(), ); self.shutdown_current_thread(app_server).await; let tracked_thread_ids: Vec<ThreadId> = self.thread_event_channels.keys().copied().collect(); for thread_id in tracked_thread_ids { if let Err(err) = app_server.thread_unsubscribe(thread_id).await { tracing::warn!("failed to unsubscribe tracked thread {thread_id}: {err}"); } } self.config = config.clone(); match app_server .start_thread_with_session_start_source(&config, session_start_source) .await { Ok(started) => { if let Err(err) = self .replace_chat_widget_with_app_server_thread( tui, app_server, started, initial_user_message, ) .await { self.chat_widget.add_error_message(format!( "Failed to attach to fresh app-server thread: {err}" )); } else if let Some(summary) = summary { let mut lines: Vec<Line<'static>> = Vec::new(); if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } if let Some(command) = summary.resume_hint { let spans = vec!["To continue this session, run ".into(), command.cyan()]; lines.push(spans.into()); } self.chat_widget.add_plain_history_lines(lines); } } Err(err) => { self.chat_widget.add_error_message(format!( "Failed to start a fresh session through the app server: {err}" )); self.config.model = Some(model); } } tui.frame_requester().schedule_frame(); } pub(super) async fn replace_chat_widget_with_app_server_thread( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, started: AppServerStartedThread, initial_user_message: Option<crate::chatwidget::UserMessage>, ) -> Result<()> { // Initial messages are for freshly attached primary threads only. Thread switches and // resume/fork flows pass `None` so they cannot replay old history and then auto-submit a new // user turn by accident. self.reset_thread_event_state(); let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, self.config.clone(), initial_user_message, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.enqueue_primary_thread_session(started.session, started.turns) .await?; self.backfill_loaded_subagent_threads(app_server).await; Ok(()) } /// Fetches all loaded threads from the app server and registers descendants of the primary /// thread in the navigation cache and chat widget metadata. /// /// Called after `replace_chat_widget_with_app_server_thread` during resume, fork, and new /// thread creation so that the `/agent` picker and keyboard navigation are pre-populated even /// if the TUI did not witness the original spawn events. /// /// The loaded-thread list is fetched in full (no pagination) and the spawn tree is walked /// by `find_loaded_subagent_threads_for_primary`. Each discovered subagent is registered via /// `upsert_agent_picker_thread`, which writes to both `AgentNavigationState` and the /// `ChatWidget` metadata map. pub(super) async fn backfill_loaded_subagent_threads( &mut self, app_server: &mut AppServerSession, ) -> bool { let Some(primary_thread_id) = self.primary_thread_id else { return false; }; let loaded_thread_ids = match app_server .thread_loaded_list(ThreadLoadedListParams { cursor: None, limit: None, }) .await { Ok(response) => response.data, Err(err) => { tracing::warn!(%err, "failed to list loaded threads for subagent backfill"); return false; } }; let mut threads = Vec::new(); let mut had_read_error = false; for thread_id in loaded_thread_ids { let Ok(thread_id) = ThreadId::from_string(&thread_id) else { tracing::warn!("ignoring loaded thread with invalid id during subagent backfill"); continue; }; if thread_id == primary_thread_id { continue; } match app_server .thread_read(thread_id, /*include_turns*/ false) .await { Ok(thread) => threads.push(thread), Err(err) => { had_read_error = true; tracing::warn!(thread_id = %thread_id, %err, "failed to read loaded thread"); } } } for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) { let agent_path = thread.agent_path; self.upsert_agent_picker_thread( thread.thread_id, thread.agent_nickname, thread.agent_role, /*is_closed*/ false, ); self.agent_navigation .set_agent_path(thread.thread_id, agent_path); } self.sync_active_agent_label(); !had_read_error } /// Returns the adjacent thread id for keyboard navigation, backfilling from the server if the /// local cache has no neighbor. /// /// Tries the fast path first: ask `AgentNavigationState` directly. If it returns `None` (no /// adjacent entry exists, typically because the cache was never populated with remote /// subagents), performs a full `backfill_loaded_subagent_threads` and retries. This ensures the /// first next/previous keypress in a resumed remote session discovers subagents on demand /// without requiring the user to wait for a proactive fetch. pub(super) async fn adjacent_thread_id_with_backfill( &mut self, app_server: &mut AppServerSession, direction: AgentNavigationDirection, ) -> Option<ThreadId> { let current_thread = self.current_displayed_thread_id(); if let Some(thread_id) = self .agent_navigation .adjacent_thread_id(current_thread, direction) { return Some(thread_id); } let primary_thread_id = self.primary_thread_id?; if self.last_subagent_backfill_attempt == Some(primary_thread_id) { return None; } if self.backfill_loaded_subagent_threads(app_server).await { self.last_subagent_backfill_attempt = Some(primary_thread_id); } self.agent_navigation .adjacent_thread_id(self.current_displayed_thread_id(), direction) } pub(super) fn fresh_session_config(&self) -> Config { let mut config = self.config.clone(); config.service_tier = self.chat_widget.configured_service_tier(); config } pub(super) async fn resume_target_session( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, target_session: SessionTarget, ) -> Result<AppRunControl> { if self.ignore_same_thread_resume(&target_session) { tui.frame_requester().schedule_frame(); return Ok(AppRunControl::Continue); } let current_cwd = self.config.cwd.to_path_buf(); let resume_cwd = if self.app_server_target.uses_remote_workspace() { current_cwd.clone() } else { match crate::session_resume::resolve_cwd_for_resume_or_fork( tui, self.state_db.as_deref(), ¤t_cwd, target_session.thread_id, target_session.path.as_deref(), CwdPromptAction::Resume, /*allow_prompt*/ true, ) .await? { crate::session_resume::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, crate::session_resume::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), crate::session_resume::ResolveCwdOutcome::Exit => { return Ok(AppRunControl::Exit(ExitReason::UserRequested)); } } }; let mut resume_config = match self .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) .await { Ok(cfg) => cfg, Err(err) => { self.chat_widget.add_error_message(format!( "Failed to rebuild configuration for resume: {err}" )); return Ok(AppRunControl::Continue); } }; self.apply_runtime_policy_overrides(&mut resume_config); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), self.chat_widget.thread_name(), self.chat_widget.rollout_path().as_deref(), ); match app_server .resume_thread(resume_config.clone(), target_session.thread_id) .await { Ok(resumed) => { let resumed_thread_id = resumed.session.thread_id; self.shutdown_current_thread(app_server).await; self.config = resume_config; tui.set_notification_settings( self.config.tui_notifications.method, self.config.tui_notifications.condition, ); self.file_search .update_search_dir(self.config.cwd.to_path_buf()); match self .replace_chat_widget_with_app_server_thread( tui, app_server, resumed, /*initial_user_message*/ None, ) .await { Ok(()) => { if let Some(summary) = summary { let mut lines: Vec<Line<'static>> = Vec::new(); if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } if let Some(command) = summary.resume_hint { let spans = vec!["To continue this session, run ".into(), command.cyan()]; lines.push(spans.into()); } self.chat_widget.add_plain_history_lines(lines); } self.maybe_prompt_resume_paused_goal_after_resume( app_server, resumed_thread_id, ) .await; } Err(err) => { self.chat_widget.add_error_message(format!( "Failed to attach to resumed app-server thread: {err}" )); } } } Err(err) => { let path_display = target_session.display_label(); self.chat_widget.add_error_message(format!( "Failed to resume session from {path_display}: {err}" )); } } Ok(AppRunControl::Continue) }}#[cfg(test)]mod tests { use super::*; #[test] fn terminal_thread_read_error_detection_matches_not_loaded_errors() { let err = color_eyre::eyre::eyre!( "thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123" ); assert!(App::is_terminal_thread_read_error(&err)); } #[test] fn terminal_thread_read_error_detection_ignores_transient_failures() { let err = color_eyre::eyre::eyre!( "thread/read failed during TUI session lookup: thread/read transport error: broken pipe" ); assert!(!App::is_terminal_thread_read_error(&err)); } #[test] fn closed_state_for_thread_read_error_preserves_live_state_without_cache_on_transient_error() { let err = color_eyre::eyre::eyre!( "thread/read failed during TUI session lookup: thread/read transport error: broken pipe" ); assert!(!App::closed_state_for_thread_read_error( &err, /*existing_is_closed*/ None )); } #[test] fn closed_state_for_thread_read_error_marks_terminal_uncached_threads_closed() { let err = color_eyre::eyre::eyre!( "thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123" ); assert!(App::closed_state_for_thread_read_error( &err, /*existing_is_closed*/ None )); } #[test] fn include_turns_fallback_detection_handles_unmaterialized_and_ephemeral_threads() { let unmaterialized = color_eyre::eyre::eyre!( "thread/read failed during TUI session lookup: thread/read failed: thread thr_123 is not materialized yet; includeTurns is unavailable before first user message" ); let ephemeral = color_eyre::eyre::eyre!( "thread/read failed during TUI session lookup: thread/read failed: ephemeral threads do not support includeTurns" ); assert!(App::can_fallback_from_include_turns_error(&unmaterialized)); assert!(App::can_fallback_from_include_turns_error(&ephemeral)); }}