fix(session): scope latest resume to workspace (#830)

This commit is contained in:
Hunter Bown
2026-05-06 03:16:44 -05:00
committed by GitHub
parent 3e93143079
commit ab92892cc4
5 changed files with 168 additions and 46 deletions
+1 -1
View File
@@ -223,7 +223,7 @@ deepseek setup --status # read-only setup status
deepseek setup --tools --plugins # scaffold tool/plugin dirs
deepseek models # list live API models
deepseek sessions # list saved sessions
deepseek resume --last # resume the most recent session
deepseek resume --last # resume the most recent session in this workspace
deepseek resume <SESSION_ID> # resume a specific session by UUID
deepseek fork <SESSION_ID> # fork a session at a chosen turn
deepseek serve --http # HTTP/SSE API server
+33 -21
View File
@@ -126,7 +126,7 @@ struct Cli {
#[arg(short, long)]
resume: Option<String>,
/// Continue the most recent session
/// Continue the most recent session in this workspace
#[arg(short = 'c', long = "continue")]
continue_session: bool,
@@ -231,7 +231,7 @@ enum Commands {
/// Conversation/session id (UUID or prefix)
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,
/// Continue the most recent session without a picker
/// Continue the most recent session in this workspace without a picker
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
last: bool,
},
@@ -240,7 +240,7 @@ enum Commands {
/// Conversation/session id (UUID or prefix)
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,
/// Fork the most recent session without a picker
/// Fork the most recent session in this workspace without a picker
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
last: bool,
},
@@ -727,12 +727,14 @@ async fn main() -> Result<()> {
}
Commands::Resume { session_id, last } => {
let config = load_config_from_cli(&cli)?;
let resume_id = resolve_session_id(session_id, last)?;
let workspace = resolve_workspace(&cli);
let resume_id = resolve_session_id(session_id, last, &workspace)?;
run_interactive(&cli, &config, Some(resume_id), None).await
}
Commands::Fork { session_id, last } => {
let config = load_config_from_cli(&cli)?;
let new_session_id = fork_session(session_id, last)?;
let workspace = resolve_workspace(&cli);
let new_session_id = fork_session(session_id, last, &workspace)?;
run_interactive(&cli, &config, Some(new_session_id), None).await
}
};
@@ -747,11 +749,8 @@ async fn main() -> Result<()> {
// Handle session resume
let resume_session_id = if cli.continue_session {
// Get most recent session
match session_manager::SessionManager::default_location() {
Ok(manager) => manager.get_latest_session().ok().flatten().map(|m| m.id),
Err(_) => None,
}
let workspace = resolve_workspace(&cli);
latest_session_id_for_workspace(&workspace).ok().flatten()
} else if let Some(id) = cli.resume.clone() {
Some(id)
} else if !cli.fresh {
@@ -2351,7 +2350,7 @@ fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
"<session-id>".dimmed()
);
println!(
"Continue latest: {}",
"Continue latest in this workspace: {}",
"deepseek --continue".truecolor(blue_r, blue_g, blue_b)
);
@@ -2449,9 +2448,14 @@ fn run_logout() -> Result<()> {
Ok(())
}
fn resolve_session_id(session_id: Option<String>, last: bool) -> Result<String> {
fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
if last {
return Ok("latest".to_string());
return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
anyhow!(
"No saved sessions found for workspace {}. Use `deepseek sessions` to list all sessions, or `deepseek resume <SESSION_ID>` to resume one explicitly.",
workspace.display()
)
});
}
if let Some(id) = session_id {
return Ok(id);
@@ -2459,15 +2463,25 @@ fn resolve_session_id(session_id: Option<String>, last: bool) -> Result<String>
pick_session_id()
}
fn fork_session(session_id: Option<String>, last: bool) -> Result<String> {
fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
let manager = SessionManager::default_location()?;
Ok(manager
.get_latest_session_for_workspace(workspace)?
.map(|session| session.id))
}
fn fork_session(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
let manager = SessionManager::default_location()?;
let saved = if last {
let Some(meta) = manager.get_latest_session()? else {
bail!("No saved sessions found.");
let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
bail!(
"No saved sessions found for workspace {}.",
workspace.display()
);
};
manager.load_session(&meta.id)?
} else {
let id = resolve_session_id(session_id, false)?;
let id = resolve_session_id(session_id, false, workspace)?;
manager.load_session_by_prefix(&id)?
};
@@ -2618,10 +2632,8 @@ async fn run_pr(
let prompt = format_pr_prompt(number, &view, &diff);
let resume_session_id = if cli.continue_session {
match session_manager::SessionManager::default_location() {
Ok(manager) => manager.get_latest_session().ok().flatten().map(|m| m.id),
Err(_) => None,
}
let workspace = resolve_workspace(cli);
latest_session_id_for_workspace(&workspace).ok().flatten()
} else {
cli.resume.clone()
};
+131 -21
View File
@@ -436,10 +436,15 @@ impl SessionManager {
Ok(pruned)
}
/// Get the most recent session
pub fn get_latest_session(&self) -> std::io::Result<Option<SessionMetadata>> {
/// Get the most recent session scoped to the current workspace.
pub fn get_latest_session_for_workspace(
&self,
workspace: &Path,
) -> std::io::Result<Option<SessionMetadata>> {
let sessions = self.list_sessions()?;
Ok(sessions.into_iter().next())
Ok(sessions
.into_iter()
.find(|session| workspace_scope_matches(&session.workspace, workspace)))
}
/// Search sessions by title
@@ -454,6 +459,42 @@ impl SessionManager {
}
}
fn workspace_scope_matches(saved_workspace: &Path, current_workspace: &Path) -> bool {
if paths_equivalent(saved_workspace, current_workspace) {
return true;
}
match (
find_git_root(saved_workspace),
find_git_root(current_workspace),
) {
(Some(saved_root), Some(current_root)) => paths_equivalent(&saved_root, &current_root),
_ => false,
}
}
fn paths_equivalent(lhs: &Path, rhs: &Path) -> bool {
let lhs_canonical = fs::canonicalize(lhs).ok();
let rhs_canonical = fs::canonicalize(rhs).ok();
match (lhs_canonical, rhs_canonical) {
(Some(lhs), Some(rhs)) => lhs == rhs,
_ => lhs == rhs,
}
}
fn find_git_root(path: &Path) -> Option<PathBuf> {
let mut current = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
loop {
if current.join(".git").exists() {
return Some(current);
}
match current.parent() {
Some(parent) if parent != current => current = parent.to_path_buf(),
_ => return None,
}
}
}
/// Resolve the default session directory path (`~/.deepseek/sessions`).
pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| {
@@ -782,6 +823,32 @@ mod tests {
}
}
fn write_session_record(
manager: &SessionManager,
id: &str,
workspace: &Path,
updated_at: DateTime<Utc>,
) {
let session = SavedSession {
schema_version: CURRENT_SESSION_SCHEMA_VERSION,
messages: vec![make_test_message("user", "hi")],
metadata: SessionMetadata {
id: id.to_string(),
title: format!("session-{id}"),
created_at: updated_at,
updated_at,
message_count: 1,
total_tokens: 0,
model: "deepseek-v4-flash".to_string(),
workspace: workspace.to_path_buf(),
mode: None,
},
system_prompt: None,
context_references: Vec::new(),
};
manager.save_session(&session).expect("save");
}
#[test]
fn test_session_manager_new() {
let tmp = tempdir().expect("tempdir");
@@ -826,6 +893,66 @@ mod tests {
assert_eq!(sessions.len(), 3);
}
#[test]
fn latest_session_for_workspace_ignores_newer_other_directory() {
let tmp = tempdir().expect("tempdir");
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
let workspace_a = tmp.path().join("aa").join("aaa");
let workspace_b = tmp.path().join("bb").join("bbb");
fs::create_dir_all(&workspace_a).expect("mkdir workspace a");
fs::create_dir_all(&workspace_b).expect("mkdir workspace b");
write_session_record(
&manager,
"current-workspace",
&workspace_a,
Utc::now() - chrono::Duration::minutes(10),
);
write_session_record(&manager, "other-workspace", &workspace_b, Utc::now());
let global = manager
.list_sessions()
.expect("list")
.into_iter()
.next()
.expect("global latest");
assert_eq!(global.id, "other-workspace");
let scoped = manager
.get_latest_session_for_workspace(&workspace_a)
.expect("latest for workspace")
.expect("scoped latest");
assert_eq!(scoped.id, "current-workspace");
}
#[test]
fn latest_session_for_workspace_matches_same_git_repository() {
let tmp = tempdir().expect("tempdir");
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
let repo = tmp.path().join("repo");
let repo_app = repo.join("apps").join("client");
let repo_crate = repo.join("crates").join("server");
let other_repo = tmp.path().join("other").join("project");
fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
fs::create_dir_all(&repo_app).expect("mkdir repo app");
fs::create_dir_all(&repo_crate).expect("mkdir repo crate");
fs::create_dir_all(&other_repo).expect("mkdir other repo");
write_session_record(
&manager,
"same-repo",
&repo_app,
Utc::now() - chrono::Duration::minutes(5),
);
write_session_record(&manager, "other-repo", &other_repo, Utc::now());
let scoped = manager
.get_latest_session_for_workspace(&repo_crate)
.expect("latest for workspace")
.expect("same repo latest");
assert_eq!(scoped.id, "same-repo");
}
#[test]
fn test_load_by_prefix() {
let tmp = tempdir().expect("tempdir");
@@ -1207,24 +1334,7 @@ mod tests {
// to whatever the helper functions emit; we just need a
// metadata block whose `updated_at` matches the requested
// value.
let session = SavedSession {
schema_version: CURRENT_SESSION_SCHEMA_VERSION,
messages: vec![make_test_message("user", "hi")],
metadata: SessionMetadata {
id: id.to_string(),
title: format!("session-{id}"),
created_at: updated_at,
updated_at,
message_count: 1,
total_tokens: 0,
model: "deepseek-v4-flash".to_string(),
workspace: PathBuf::from("/tmp"),
mode: None,
},
system_prompt: None,
context_references: Vec::new(),
};
manager.save_session(&session).expect("save");
write_session_record(manager, id, Path::new("/tmp"), updated_at);
}
#[test]
+2 -2
View File
@@ -252,8 +252,8 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
// Try to load by prefix or full ID
let load_result: std::io::Result<Option<crate::session_manager::SavedSession>> =
if session_id == "latest" {
// Special case: resume the most recent session
match manager.get_latest_session() {
// Special case: resume the most recent session in this workspace.
match manager.get_latest_session_for_workspace(&options.workspace) {
Ok(Some(meta)) => manager.load_session(&meta.id).map(Some),
Ok(None) => Ok(None),
Err(e) => Err(e),
+1 -1
View File
@@ -81,7 +81,7 @@ Run `deepseek --help` for the canonical list. Common flags:
- `--workspace <DIR>`: workspace root for file tools
- `--yolo`: start in YOLO mode
- `-r, --resume <ID|PREFIX|latest>`: resume a saved session
- `-c, --continue`: resume the most recent session
- `-c, --continue`: resume the most recent session in this workspace
- `--max-subagents <N>`: clamp to `1..=20`
- `--no-alt-screen`: run inline without the alternate screen buffer
- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, and right-click context actions. Mouse capture is enabled by default on non-Windows terminals so drag selection copies only user/assistant transcript text; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. On Windows it defaults off to avoid CMD/terminal mouse escape sequences being inserted into the prompt; use `--mouse-capture` to opt in.