fix(session): recover interrupted user turns
Summary: - Skip empty auto-created sessions when continuing a workspace. - Recover a saved session ending in an unanswered user prompt as an editable draft instead of auto-resubmitting it. - Preserved and repaired the local Qthresh poisoned session records by moving them to ~/.deepseek/sessions/recovery-backups/qthresh-2026-05-07-auto-poison. Test plan: - cargo test -p deepseek-tui latest_session_for_workspace_skips_empty_auto_created_session --locked - cargo test -p deepseek-tui apply_loaded_session_restores_dangling_user_tail_as_retry_draft --locked - cargo test -p deepseek-tui --locked - cargo fmt --all -- --check - git diff --check - git diff --check origin/main...HEAD - cargo clippy -p deepseek-tui --all-targets --all-features --locked -- -D warnings Closes #988
This commit is contained in:
@@ -442,9 +442,10 @@ impl SessionManager {
|
||||
workspace: &Path,
|
||||
) -> std::io::Result<Option<SessionMetadata>> {
|
||||
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<Utc>,
|
||||
) {
|
||||
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");
|
||||
|
||||
+70
-41
@@ -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<Message>, Option<QueuedMessage>) {
|
||||
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<String> {
|
||||
let text = message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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 {
|
||||
|
||||
@@ -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<Message>) -> 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();
|
||||
|
||||
Reference in New Issue
Block a user