feat: added a .fork command to fork a new session from a running conversation
This commit is contained in:
@@ -633,6 +633,43 @@ impl RequestContext {
|
|||||||
Ok(())
|
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<()> {
|
pub fn empty_session(&mut self) -> Result<()> {
|
||||||
if let Some(session) = self.session.as_mut() {
|
if let Some(session) = self.session.as_mut() {
|
||||||
if let Some(agent) = self.agent.as_ref() {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::super::mcp_factory::McpFactory;
|
use super::super::mcp_factory::McpFactory;
|
||||||
@@ -5456,6 +5504,32 @@ mod tests {
|
|||||||
assert!(!ctx.working_mode.is_cmd());
|
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]
|
#[test]
|
||||||
fn session_file_returns_yaml_path() {
|
fn session_file_returns_yaml_path() {
|
||||||
let ctx = create_test_ctx();
|
let ctx = create_test_ctx();
|
||||||
|
|||||||
@@ -177,6 +177,14 @@ impl Session {
|
|||||||
&self.name
|
&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> {
|
pub fn role_name(&self) -> Option<&str> {
|
||||||
self.role_name.as_deref()
|
self.role_name.as_deref()
|
||||||
}
|
}
|
||||||
@@ -1046,4 +1054,13 @@ mod tests {
|
|||||||
session.set_autonaming(true);
|
session.set_autonaming(true);
|
||||||
assert!(!session.need_autoname());
|
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
@@ -52,7 +52,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
|||||||
4. Continue with the next pending item now. Call tools immediately."
|
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(".help", "Show this help guide", AssertState::pass()),
|
||||||
ReplCommand::new(".info", "Show system info", 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",
|
"Exit active session",
|
||||||
AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::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(".agent", "Use an agent", AssertState::bare()),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".starter",
|
".starter",
|
||||||
@@ -1018,6 +1023,9 @@ pub async fn run_repl_command(
|
|||||||
ctx.app.config.print_markdown(&banner)?;
|
ctx.app.config.print_markdown(&banner)?;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
".fork" => {
|
||||||
|
ctx.fork_session(args)?;
|
||||||
|
}
|
||||||
".save" => match split_first_arg(args) {
|
".save" => match split_first_arg(args) {
|
||||||
Some(("role", name)) => {
|
Some(("role", name)) => {
|
||||||
ctx.save_role(name)?;
|
ctx.save_role(name)?;
|
||||||
@@ -1744,8 +1752,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_commands_has_57_entries() {
|
fn repl_commands_has_58_entries() {
|
||||||
assert_eq!(REPL_COMMANDS.len(), 57);
|
assert_eq!(REPL_COMMANDS.len(), 58);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user