-
Notifications
You must be signed in to change notification settings - Fork 7.5k
feat: wire fork to codex cli #8994
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -117,6 +117,9 @@ enum Subcommand { | |
| /// Resume a previous interactive session (picker by default; use --last to continue the most recent). | ||
| Resume(ResumeCommand), | ||
|
|
||
| /// Fork a previous interactive session (picker by default; use --last to fork the most recent). | ||
| Fork(ForkCommand), | ||
|
|
||
| /// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally. | ||
| #[clap(name = "cloud", alias = "cloud-tasks")] | ||
| Cloud(CloudTasksCli), | ||
|
|
@@ -159,6 +162,25 @@ struct ResumeCommand { | |
| config_overrides: TuiCli, | ||
| } | ||
|
|
||
| #[derive(Debug, Parser)] | ||
| struct ForkCommand { | ||
| /// Conversation/session id (UUID). When provided, forks this session. | ||
| /// If omitted, use --last to pick the most recent recorded session. | ||
| #[arg(value_name = "SESSION_ID")] | ||
| session_id: Option<String>, | ||
|
|
||
| /// Fork the most recent session without showing the picker. | ||
| #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] | ||
| last: bool, | ||
|
|
||
| /// Show all sessions (disables cwd filtering and shows CWD column). | ||
| #[arg(long = "all", default_value_t = false)] | ||
| all: bool, | ||
|
|
||
| #[clap(flatten)] | ||
| config_overrides: TuiCli, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is cool but it seems to add quite some complexity for low value
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here, I followed |
||
| } | ||
|
|
||
| #[derive(Debug, Parser)] | ||
| struct SandboxArgs { | ||
| #[command(subcommand)] | ||
|
|
@@ -514,6 +536,23 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<() | |
| let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; | ||
| handle_app_exit(exit_info)?; | ||
| } | ||
| Some(Subcommand::Fork(ForkCommand { | ||
| session_id, | ||
| last, | ||
| all, | ||
| config_overrides, | ||
| })) => { | ||
| interactive = finalize_fork_interactive( | ||
| interactive, | ||
| root_config_overrides.clone(), | ||
| session_id, | ||
| last, | ||
| all, | ||
| config_overrides, | ||
| ); | ||
| let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; | ||
| handle_app_exit(exit_info)?; | ||
| } | ||
| Some(Subcommand::Login(mut login_cli)) => { | ||
| prepend_config_flags( | ||
| &mut login_cli.config_overrides, | ||
|
|
@@ -724,59 +763,85 @@ fn finalize_resume_interactive( | |
| interactive.resume_show_all = show_all; | ||
|
|
||
| // Merge resume-scoped flags and overrides with highest precedence. | ||
| merge_resume_cli_flags(&mut interactive, resume_cli); | ||
| merge_interactive_cli_flags(&mut interactive, resume_cli); | ||
|
|
||
| // Propagate any root-level config overrides (e.g. `-c key=value`). | ||
| prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); | ||
|
|
||
| interactive | ||
| } | ||
|
|
||
| /// Build the final `TuiCli` for a `codex fork` invocation. | ||
| fn finalize_fork_interactive( | ||
| mut interactive: TuiCli, | ||
| root_config_overrides: CliConfigOverrides, | ||
| session_id: Option<String>, | ||
| last: bool, | ||
| show_all: bool, | ||
| fork_cli: TuiCli, | ||
| ) -> TuiCli { | ||
| // Start with the parsed interactive CLI so fork shares the same | ||
| // configuration surface area as `codex` without additional flags. | ||
| let fork_session_id = session_id; | ||
| interactive.fork_picker = fork_session_id.is_none() && !last; | ||
| interactive.fork_last = last; | ||
| interactive.fork_session_id = fork_session_id; | ||
| interactive.fork_show_all = show_all; | ||
|
|
||
| // Merge fork-scoped flags and overrides with highest precedence. | ||
| merge_interactive_cli_flags(&mut interactive, fork_cli); | ||
|
|
||
| // Propagate any root-level config overrides (e.g. `-c key=value`). | ||
| prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); | ||
|
|
||
| interactive | ||
| } | ||
|
|
||
| /// Merge flags provided to `codex resume` so they take precedence over any | ||
| /// root-level flags. Only overrides fields explicitly set on the resume-scoped | ||
| /// Merge flags provided to `codex resume`/`codex fork` so they take precedence over any | ||
| /// root-level flags. Only overrides fields explicitly set on the subcommand-scoped | ||
| /// CLI. Also appends `-c key=value` overrides with highest precedence. | ||
| fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) { | ||
| if let Some(model) = resume_cli.model { | ||
| fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) { | ||
| if let Some(model) = subcommand_cli.model { | ||
| interactive.model = Some(model); | ||
| } | ||
| if resume_cli.oss { | ||
| if subcommand_cli.oss { | ||
| interactive.oss = true; | ||
| } | ||
| if let Some(profile) = resume_cli.config_profile { | ||
| if let Some(profile) = subcommand_cli.config_profile { | ||
| interactive.config_profile = Some(profile); | ||
| } | ||
| if let Some(sandbox) = resume_cli.sandbox_mode { | ||
| if let Some(sandbox) = subcommand_cli.sandbox_mode { | ||
| interactive.sandbox_mode = Some(sandbox); | ||
| } | ||
| if let Some(approval) = resume_cli.approval_policy { | ||
| if let Some(approval) = subcommand_cli.approval_policy { | ||
| interactive.approval_policy = Some(approval); | ||
| } | ||
| if resume_cli.full_auto { | ||
| if subcommand_cli.full_auto { | ||
| interactive.full_auto = true; | ||
| } | ||
| if resume_cli.dangerously_bypass_approvals_and_sandbox { | ||
| if subcommand_cli.dangerously_bypass_approvals_and_sandbox { | ||
| interactive.dangerously_bypass_approvals_and_sandbox = true; | ||
| } | ||
| if let Some(cwd) = resume_cli.cwd { | ||
| if let Some(cwd) = subcommand_cli.cwd { | ||
| interactive.cwd = Some(cwd); | ||
| } | ||
| if resume_cli.web_search { | ||
| if subcommand_cli.web_search { | ||
| interactive.web_search = true; | ||
| } | ||
| if !resume_cli.images.is_empty() { | ||
| interactive.images = resume_cli.images; | ||
| if !subcommand_cli.images.is_empty() { | ||
| interactive.images = subcommand_cli.images; | ||
| } | ||
| if !resume_cli.add_dir.is_empty() { | ||
| interactive.add_dir.extend(resume_cli.add_dir); | ||
| if !subcommand_cli.add_dir.is_empty() { | ||
| interactive.add_dir.extend(subcommand_cli.add_dir); | ||
| } | ||
| if let Some(prompt) = resume_cli.prompt { | ||
| if let Some(prompt) = subcommand_cli.prompt { | ||
| interactive.prompt = Some(prompt); | ||
| } | ||
|
|
||
| interactive | ||
| .config_overrides | ||
| .raw_overrides | ||
| .extend(resume_cli.config_overrides.raw_overrides); | ||
| .extend(subcommand_cli.config_overrides.raw_overrides); | ||
| } | ||
|
|
||
| fn print_completion(cmd: CompletionCommand) { | ||
|
|
@@ -793,7 +858,7 @@ mod tests { | |
| use codex_protocol::ThreadId; | ||
| use pretty_assertions::assert_eq; | ||
|
|
||
| fn finalize_from_args(args: &[&str]) -> TuiCli { | ||
| fn finalize_resume_from_args(args: &[&str]) -> TuiCli { | ||
| let cli = MultitoolCli::try_parse_from(args).expect("parse"); | ||
| let MultitoolCli { | ||
| interactive, | ||
|
|
@@ -822,6 +887,28 @@ mod tests { | |
| ) | ||
| } | ||
|
|
||
| fn finalize_fork_from_args(args: &[&str]) -> TuiCli { | ||
| let cli = MultitoolCli::try_parse_from(args).expect("parse"); | ||
| let MultitoolCli { | ||
| interactive, | ||
| config_overrides: root_overrides, | ||
| subcommand, | ||
| feature_toggles: _, | ||
| } = cli; | ||
|
|
||
| let Subcommand::Fork(ForkCommand { | ||
| session_id, | ||
| last, | ||
| all, | ||
| config_overrides: fork_cli, | ||
| }) = subcommand.expect("fork present") | ||
| else { | ||
| unreachable!() | ||
| }; | ||
|
|
||
| finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli) | ||
| } | ||
|
|
||
| fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo { | ||
| let token_usage = TokenUsage { | ||
| output_tokens: 2, | ||
|
|
@@ -870,7 +957,8 @@ mod tests { | |
|
|
||
| #[test] | ||
| fn resume_model_flag_applies_when_no_root_flags() { | ||
| let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref()); | ||
| let interactive = | ||
| finalize_resume_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref()); | ||
|
|
||
| assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); | ||
| assert!(interactive.resume_picker); | ||
|
|
@@ -880,7 +968,7 @@ mod tests { | |
|
|
||
| #[test] | ||
| fn resume_picker_logic_none_and_not_last() { | ||
| let interactive = finalize_from_args(["codex", "resume"].as_ref()); | ||
| let interactive = finalize_resume_from_args(["codex", "resume"].as_ref()); | ||
| assert!(interactive.resume_picker); | ||
| assert!(!interactive.resume_last); | ||
| assert_eq!(interactive.resume_session_id, None); | ||
|
|
@@ -889,7 +977,7 @@ mod tests { | |
|
|
||
| #[test] | ||
| fn resume_picker_logic_last() { | ||
| let interactive = finalize_from_args(["codex", "resume", "--last"].as_ref()); | ||
| let interactive = finalize_resume_from_args(["codex", "resume", "--last"].as_ref()); | ||
| assert!(!interactive.resume_picker); | ||
| assert!(interactive.resume_last); | ||
| assert_eq!(interactive.resume_session_id, None); | ||
|
|
@@ -898,7 +986,7 @@ mod tests { | |
|
|
||
| #[test] | ||
| fn resume_picker_logic_with_session_id() { | ||
| let interactive = finalize_from_args(["codex", "resume", "1234"].as_ref()); | ||
| let interactive = finalize_resume_from_args(["codex", "resume", "1234"].as_ref()); | ||
| assert!(!interactive.resume_picker); | ||
| assert!(!interactive.resume_last); | ||
| assert_eq!(interactive.resume_session_id.as_deref(), Some("1234")); | ||
|
|
@@ -907,14 +995,14 @@ mod tests { | |
|
|
||
| #[test] | ||
| fn resume_all_flag_sets_show_all() { | ||
| let interactive = finalize_from_args(["codex", "resume", "--all"].as_ref()); | ||
| let interactive = finalize_resume_from_args(["codex", "resume", "--all"].as_ref()); | ||
| assert!(interactive.resume_picker); | ||
| assert!(interactive.resume_show_all); | ||
| } | ||
|
|
||
| #[test] | ||
| fn resume_merges_option_flags_and_full_auto() { | ||
| let interactive = finalize_from_args( | ||
| let interactive = finalize_resume_from_args( | ||
| [ | ||
| "codex", | ||
| "resume", | ||
|
|
@@ -971,7 +1059,7 @@ mod tests { | |
|
|
||
| #[test] | ||
| fn resume_merges_dangerously_bypass_flag() { | ||
| let interactive = finalize_from_args( | ||
| let interactive = finalize_resume_from_args( | ||
| [ | ||
| "codex", | ||
| "resume", | ||
|
|
@@ -985,6 +1073,40 @@ mod tests { | |
| assert_eq!(interactive.resume_session_id, None); | ||
| } | ||
|
|
||
| #[test] | ||
| fn fork_picker_logic_none_and_not_last() { | ||
| let interactive = finalize_fork_from_args(["codex", "fork"].as_ref()); | ||
| assert!(interactive.fork_picker); | ||
| assert!(!interactive.fork_last); | ||
| assert_eq!(interactive.fork_session_id, None); | ||
| assert!(!interactive.fork_show_all); | ||
| } | ||
|
|
||
| #[test] | ||
| fn fork_picker_logic_last() { | ||
| let interactive = finalize_fork_from_args(["codex", "fork", "--last"].as_ref()); | ||
| assert!(!interactive.fork_picker); | ||
| assert!(interactive.fork_last); | ||
| assert_eq!(interactive.fork_session_id, None); | ||
| assert!(!interactive.fork_show_all); | ||
| } | ||
|
|
||
| #[test] | ||
| fn fork_picker_logic_with_session_id() { | ||
| let interactive = finalize_fork_from_args(["codex", "fork", "1234"].as_ref()); | ||
| assert!(!interactive.fork_picker); | ||
| assert!(!interactive.fork_last); | ||
| assert_eq!(interactive.fork_session_id.as_deref(), Some("1234")); | ||
| assert!(!interactive.fork_show_all); | ||
| } | ||
|
|
||
| #[test] | ||
| fn fork_all_flag_sets_show_all() { | ||
| let interactive = finalize_fork_from_args(["codex", "fork", "--all"].as_ref()); | ||
| assert!(interactive.fork_picker); | ||
| assert!(interactive.fork_show_all); | ||
| } | ||
|
|
||
| #[test] | ||
| fn feature_toggles_known_features_generate_overrides() { | ||
| let toggles = FeatureToggles { | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really want this? How is the last session defined? Last one closed? Last turn started? Last turn finished?
I guess this will create more confusion
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It basically mirrors resume and use same logic.
we will need to clean up it at some point or define better.