feat: added a .fork command to fork a new session from a running conversation
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-07-21 14:54:55 -06:00
parent d8ae9e25d9
commit 9606d7f8aa
3 changed files with 102 additions and 3 deletions
+74
View File
@@ -633,6 +633,43 @@ impl RequestContext {
Ok(())
}
pub fn fork_session(&mut self, fork_name: Option<&str>) -> Result<()> {
let current_name = match &self.session {
Some(s) => s.name().to_string(),
None => bail!("No active session to fork"),
};
let fork_name: String = match fork_name {
Some(name) => name.to_string(),
None => {
let base = fork_base_name(&current_name);
let sessions_dir = self.sessions_dir();
(1_u32..)
.map(|n| format!("{base}-fork-{n}"))
.find(|name| !sessions_dir.join(format!("{name}.yaml")).exists())
.unwrap()
}
};
let fork_path = self.session_file(&fork_name);
if fork_path.exists() {
bail!("Session '{}' already exists", fork_name);
}
self.save_session(None)?;
let session = self.session.as_ref().unwrap();
let mut fork = session.clone();
fork.set_name(fork_name.clone());
fork.clear_autoname();
fork.save(&fork_name, &fork_path, self.working_mode.is_repl())?;
self.session = Some(fork);
println!("Forked '{current_name}' → '{fork_name}'");
Ok(())
}
pub fn empty_session(&mut self) -> Result<()> {
if let Some(session) = self.session.as_mut() {
if let Some(agent) = self.agent.as_ref() {
@@ -4053,6 +4090,17 @@ impl RequestContext {
}
}
fn fork_base_name(name: &str) -> &str {
if let Some(pos) = name.rfind("-fork-") {
let suffix = &name[pos + 6..];
if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
return &name[..pos];
}
}
name
}
#[cfg(test)]
mod tests {
use super::super::mcp_factory::McpFactory;
@@ -5456,6 +5504,32 @@ mod tests {
assert!(!ctx.working_mode.is_cmd());
}
#[test]
fn fork_base_name_strips_fork_suffix() {
assert_eq!(fork_base_name("my-session-fork-1"), "my-session");
assert_eq!(fork_base_name("my-session-fork-42"), "my-session");
}
#[test]
fn fork_base_name_leaves_plain_names_unchanged() {
assert_eq!(fork_base_name("my-session"), "my-session");
assert_eq!(fork_base_name("research"), "research");
}
#[test]
fn fork_base_name_ignores_non_numeric_suffix() {
assert_eq!(fork_base_name("my-session-fork-abc"), "my-session-fork-abc");
assert_eq!(fork_base_name("my-session-fork-"), "my-session-fork-");
}
#[test]
fn fork_base_name_flattens_nested_forks() {
assert_eq!(
fork_base_name("my-session-fork-1-fork-2"),
"my-session-fork-1"
);
}
#[test]
fn session_file_returns_yaml_path() {
let ctx = create_test_ctx();
+17
View File
@@ -177,6 +177,14 @@ impl Session {
&self.name
}
pub fn set_name(&mut self, name: String) {
self.name = name;
}
pub fn clear_autoname(&mut self) {
self.autoname = None;
}
pub fn role_name(&self) -> Option<&str> {
self.role_name.as_deref()
}
@@ -1046,4 +1054,13 @@ mod tests {
session.set_autonaming(true);
assert!(!session.need_autoname());
}
#[test]
fn session_set_name_updates_name() {
let mut session = Session::default();
session.set_name("my-fork".to_string());
assert_eq!(session.name(), "my-fork");
}
}
+11 -3
View File
@@ -52,7 +52,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
4. Continue with the next pending item now. Call tools immediately."
};
static REPL_COMMANDS: LazyLock<[ReplCommand; 57]> = LazyLock::new(|| {
static REPL_COMMANDS: LazyLock<[ReplCommand; 58]> = LazyLock::new(|| {
[
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
ReplCommand::new(".info", "Show system info", AssertState::pass()),
@@ -175,6 +175,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 57]> = LazyLock::new(|| {
"Exit active session",
AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION),
),
ReplCommand::new(
".fork",
"Fork the active session into a new named copy",
AssertState::True(StateFlags::SESSION),
),
ReplCommand::new(".agent", "Use an agent", AssertState::bare()),
ReplCommand::new(
".starter",
@@ -1018,6 +1023,9 @@ pub async fn run_repl_command(
ctx.app.config.print_markdown(&banner)?;
}
},
".fork" => {
ctx.fork_session(args)?;
}
".save" => match split_first_arg(args) {
Some(("role", name)) => {
ctx.save_role(name)?;
@@ -1744,8 +1752,8 @@ mod tests {
}
#[test]
fn repl_commands_has_57_entries() {
assert_eq!(REPL_COMMANDS.len(), 57);
fn repl_commands_has_58_entries() {
assert_eq!(REPL_COMMANDS.len(), 58);
}
#[test]