diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 056bf962..d130fe8b 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -442,9 +442,10 @@ impl SessionManager { workspace: &Path, ) -> std::io::Result> { let sessions = self.list_sessions()?; - Ok(sessions - .into_iter() - .find(|session| workspace_scope_matches(&session.workspace, workspace))) + Ok(sessions.into_iter().find(|session| { + workspace_scope_matches(&session.workspace, workspace) + && !is_empty_auto_created_session(session) + })) } /// Search sessions by title @@ -473,6 +474,10 @@ fn workspace_scope_matches(saved_workspace: &Path, current_workspace: &Path) -> } } +fn is_empty_auto_created_session(session: &SessionMetadata) -> bool { + session.message_count == 0 && session.title.trim().eq_ignore_ascii_case("New Session") +} + fn paths_equivalent(lhs: &Path, rhs: &Path) -> bool { let lhs_canonical = fs::canonicalize(lhs).ok(); let rhs_canonical = fs::canonicalize(rhs).ok(); @@ -849,6 +854,32 @@ mod tests { manager.save_session(&session).expect("save"); } + fn write_empty_session_record( + manager: &SessionManager, + id: &str, + workspace: &Path, + updated_at: DateTime, + ) { + let session = SavedSession { + schema_version: CURRENT_SESSION_SCHEMA_VERSION, + messages: Vec::new(), + metadata: SessionMetadata { + id: id.to_string(), + title: "New Session".to_string(), + created_at: updated_at, + updated_at, + message_count: 0, + total_tokens: 0, + model: "deepseek-v4-pro".to_string(), + workspace: workspace.to_path_buf(), + mode: Some("yolo".to_string()), + }, + system_prompt: None, + context_references: Vec::new(), + }; + manager.save_session(&session).expect("save empty"); + } + #[test] fn test_session_manager_new() { let tmp = tempdir().expect("tempdir"); @@ -953,6 +984,36 @@ mod tests { assert_eq!(scoped.id, "same-repo"); } + #[test] + fn latest_session_for_workspace_skips_empty_auto_created_session() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); + let workspace = tmp.path().join("repo"); + fs::create_dir_all(&workspace).expect("mkdir workspace"); + + write_session_record( + &manager, + "interrupted-user-turn", + &workspace, + Utc::now() - chrono::Duration::minutes(5), + ); + write_empty_session_record(&manager, "empty-auto-shell", &workspace, Utc::now()); + + let global = manager + .list_sessions() + .expect("list") + .into_iter() + .next() + .expect("global latest"); + assert_eq!(global.id, "empty-auto-shell"); + + let scoped = manager + .get_latest_session_for_workspace(&workspace) + .expect("latest for workspace") + .expect("scoped latest"); + assert_eq!(scoped.id, "interrupted-user-turn"); + } + #[test] fn test_load_by_prefix() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 0de2de7d..be275a4e 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -270,40 +270,13 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { match load_result { Ok(Some(saved)) => { - app.api_messages.clone_from(&saved.messages); - app.model.clone_from(&saved.metadata.model); - app.update_model_compaction_budget(); - app.workspace.clone_from(&saved.metadata.workspace); - app.current_session_id = Some(saved.metadata.id.clone()); - app.session.total_tokens = - u32::try_from(saved.metadata.total_tokens).unwrap_or(u32::MAX); - app.session.total_conversation_tokens = app.session.total_tokens; - app.session.last_prompt_tokens = None; - app.session.last_completion_tokens = None; - app.session.last_prompt_cache_hit_tokens = None; - app.session.last_prompt_cache_miss_tokens = None; - app.session.last_reasoning_replay_tokens = None; - if let Some(prompt) = saved.system_prompt { - app.system_prompt = Some(SystemPrompt::Text(prompt)); + let recovered = apply_loaded_session(&mut app, &saved); + if !recovered { + app.status_message = Some(format!( + "Resumed session: {}", + crate::session_manager::truncate_id(&saved.metadata.id) + )); } - // Convert saved messages to HistoryCell format for display - app.clear_history(); - app.push_history_cell(HistoryCell::System { - content: format!( - "Resumed session: {} ({})", - saved.metadata.title, - crate::session_manager::truncate_id(&saved.metadata.id), - ), - }); - - for msg in &saved.messages { - app.extend_history(history_cells_from_message(msg)); - } - app.mark_history_updated(); - app.status_message = Some(format!( - "Resumed session: {}", - crate::session_manager::truncate_id(&saved.metadata.id) - )); } Ok(None) => { app.status_message = Some("No sessions found to resume".to_string()); @@ -330,7 +303,10 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { .into_iter() .map(queued_session_to_ui) .collect(); - app.queued_draft = state.draft.map(queued_session_to_ui); + let restored_draft = state.draft.map(queued_session_to_ui); + if restored_draft.is_some() || app.queued_draft.is_none() { + app.queued_draft = restored_draft; + } if app.status_message.is_none() && app.queued_message_count() > 0 { app.status_message = Some(format!( "Restored {} queued message(s) from previous session — ↑ to edit, Ctrl+X to discard", @@ -5526,7 +5502,7 @@ async fn handle_view_events( match manager.load_session(&session_id) { Ok(session) => { - apply_loaded_session(app, &session); + let recovered = apply_loaded_session(app, &session); let _ = engine_handle .send(Op::SyncSession { messages: app.api_messages.clone(), @@ -5540,10 +5516,12 @@ async fn handle_view_events( config: app.compaction_config(), }) .await; - app.status_message = Some(format!( - "Session loaded (ID: {})", - &session_id[..8.min(session_id.len())] - )); + if !recovered { + app.status_message = Some(format!( + "Session loaded (ID: {})", + &session_id[..8.min(session_id.len())] + )); + } } Err(err) => { app.status_message = @@ -5847,8 +5825,9 @@ async fn apply_provider_picker_api_key( switch_provider(app, engine_handle, config, provider, None).await; } -fn apply_loaded_session(app: &mut App, session: &SavedSession) { - app.api_messages.clone_from(&session.messages); +fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { + let (messages, recovered_draft) = recover_interrupted_user_tail(&session.messages); + app.api_messages = messages; app.clear_history(); app.tool_cells.clear(); app.tool_details_by_cell.clear(); @@ -5907,7 +5886,57 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) { } else { app.system_prompt = None; } + let recovered = if let Some(draft) = recovered_draft { + restore_recovered_retry_draft(app, draft); + true + } else { + false + }; app.scroll_to_bottom(); + recovered +} + +fn recover_interrupted_user_tail(messages: &[Message]) -> (Vec, Option) { + let mut recovered = messages.to_vec(); + let Some(last) = recovered.last() else { + return (recovered, None); + }; + if last.role != "user" { + return (recovered, None); + } + let Some(display) = retry_display_from_user_message(last) else { + return (recovered, None); + }; + recovered.pop(); + (recovered, Some(QueuedMessage::new(display, None))) +} + +fn retry_display_from_user_message(message: &Message) -> Option { + let text = message + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + let display = compact_user_context_display(&text).trim().to_string(); + if display.is_empty() { + None + } else { + Some(display) + } +} + +fn restore_recovered_retry_draft(app: &mut App, draft: QueuedMessage) { + app.input.clone_from(&draft.display); + app.cursor_position = app.input.chars().count(); + app.queued_draft = Some(draft); + app.status_message = Some( + "Recovered interrupted prompt as an editable draft; press Enter to retry.".to_string(), + ); + app.needs_redraw = true; } fn compact_user_context_display(content: &str) -> String { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 915b261e..fca35ca2 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -519,6 +519,69 @@ fn create_test_app() -> App { App::new(options, &Config::default()) } +fn text_message(role: &str, text: &str) -> Message { + Message { + role: role.to_string(), + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } +} + +fn saved_session_with_messages(messages: Vec) -> SavedSession { + SavedSession { + schema_version: 1, + metadata: crate::session_manager::SessionMetadata { + id: "resume-recovery-session".to_string(), + title: "resume recovery".to_string(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + message_count: messages.len(), + total_tokens: 0, + model: "deepseek-v4-pro".to_string(), + workspace: PathBuf::from("/tmp/resume-recovery"), + mode: Some("yolo".to_string()), + }, + messages, + system_prompt: None, + context_references: Vec::new(), + } +} + +#[test] +fn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() { + let mut app = create_test_app(); + let session = saved_session_with_messages(vec![text_message( + "user", + "finish the Qthresh proof bundle", + )]); + + let recovered = apply_loaded_session(&mut app, &session); + + assert!(recovered); + assert!(app.api_messages.is_empty()); + assert_eq!(app.input, "finish the Qthresh proof bundle"); + assert_eq!( + app.queued_draft + .as_ref() + .map(|draft| draft.display.as_str()), + Some("finish the Qthresh proof bundle") + ); + assert!( + app.history + .iter() + .all(|cell| !matches!(cell, HistoryCell::User { .. })) + ); + assert!( + app.status_message + .as_deref() + .is_some_and(|msg| msg.contains("Recovered interrupted prompt")), + "status was {:?}", + app.status_message + ); +} + #[tokio::test] async fn drain_web_config_events_applies_draft_without_closing_session() { let mut app = create_test_app();