diff --git a/src/config/request_context.rs b/src/config/request_context.rs index cfc44ae..476e385 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -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(¤t_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(); diff --git a/src/config/session.rs b/src/config/session.rs index 82baf5b..5800b19 100644 --- a/src/config/session.rs +++ b/src/config/session.rs @@ -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"); + } } diff --git a/src/repl/mod.rs b/src/repl/mod.rs index f90ca63..cced1ff 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -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]