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:
Hunter Bown
2026-05-07 03:07:30 -05:00
committed by GitHub
parent 4369410df7
commit 7f4a8f7b8d
3 changed files with 197 additions and 44 deletions
+64 -3
View File
@@ -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
View File
@@ -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 {
+63
View File
@@ -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();