fix(session): scope latest resume to workspace (#830)
This commit is contained in:
@@ -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
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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, ¤t_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]
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user