diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 1fc7bbc4be64..081413ec9d38 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -200,6 +200,11 @@ struct ResumeArgsRaw { )] images: Vec, + /// Carry on the thread's current turn instead of sending a new prompt. Use it to finish a + /// turn that stopped part way through, without asking the same thing twice. + #[arg(long = "continue", default_value_t = false, conflicts_with_all = ["prompt", "images"])] + continue_turn: bool, + /// Prompt to send after resuming the session. If `-` is used, read from stdin. #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] prompt: Option, @@ -220,6 +225,9 @@ pub struct ResumeArgs { /// Optional image(s) to attach to the prompt sent after resuming. pub images: Vec, + /// Carry on the thread's current turn instead of sending a new prompt. + pub continue_turn: bool, + /// Prompt to send after resuming the session. If `-` is used, read from stdin. pub prompt: Option, } @@ -238,6 +246,7 @@ impl From for ResumeArgs { last: raw.last, all: raw.all, images: raw.images, + continue_turn: raw.continue_turn, prompt, } } diff --git a/codex-rs/exec/src/cli_tests.rs b/codex-rs/exec/src/cli_tests.rs index 5e647eb76beb..54eff81f2d79 100644 --- a/codex-rs/exec/src/cli_tests.rs +++ b/codex-rs/exec/src/cli_tests.rs @@ -121,3 +121,38 @@ fn approve_for_me_flag_conflicts_with_other_sandbox_modes() { assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); } } + +#[test] +fn resume_continue_takes_no_prompt() { + let cli = Cli::parse_from([ + "codex-exec", + "resume", + "01a01e42-ffdd-7bc0-86fd-8a494622322b", + "--continue", + ]); + + let Some(Command::Resume(args)) = cli.command else { + panic!("expected resume command"); + }; + assert!(args.continue_turn); + assert_eq!(args.prompt, None); + assert_eq!( + args.session_id.as_deref(), + Some("01a01e42-ffdd-7bc0-86fd-8a494622322b") + ); +} + +#[test] +fn resume_continue_conflicts_with_a_prompt() { + // A turn being carried on takes no new input, so asking for both is a mistake worth + // catching at parse time rather than silently dropping one. + let result = Cli::try_parse_from([ + "codex-exec", + "resume", + "01a01e42-ffdd-7bc0-86fd-8a494622322b", + "--continue", + "keep going", + ]); + + assert!(result.is_err()); +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 68bf5cea15d4..fb9ed23f897c 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -709,6 +709,20 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let summary = codex_core::review_prompts::user_facing_hint(&review_request.target); (InitialOperation::Review { review_request }, summary) } + (Some(ExecCommand::Resume(args)), root_prompt, imgs) if args.continue_turn => { + drop((root_prompt, imgs)); + // A turn with no input runs from the history the rollout already holds, and core + // fills in a tool call whose output never landed, so a turn that stopped part way + // through can finish without the prompt being asked a second time. + let output_schema = load_output_schema(output_schema_path.clone()); + ( + InitialOperation::UserTurn { + items: Vec::new(), + output_schema, + }, + String::new(), + ) + } (Some(ExecCommand::Resume(args)), root_prompt, imgs) => { let prompt_arg = args .prompt @@ -851,6 +865,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { last: false, all: true, images: Vec::new(), + continue_turn: false, prompt: None, }; let source_thread_id = diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index abcecdde9bb3..b432f5942f31 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -308,6 +308,7 @@ async fn resume_lookup_model_providers_filters_only_last_lookup() { last: true, all: false, images: vec![], + continue_turn: false, prompt: None, }; let named_args = crate::cli::ResumeArgs { @@ -315,6 +316,7 @@ async fn resume_lookup_model_providers_filters_only_last_lookup() { last: false, all: false, images: vec![], + continue_turn: false, prompt: None, }; diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index e7120bb45e77..a6cf0d941506 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -10,6 +10,12 @@ import { SandboxMode, ModelReasoningEffort, ApprovalMode, WebSearchMode } from " export type CodexExecArgs = { input: string; + /** + * Carry on the thread's current turn instead of sending `input` as a new prompt. Requires + * `threadId`. Used to finish a turn that stopped part way through. + */ + continueTurn?: boolean; + baseUrl?: string; apiKey?: string; threadId?: string | null; @@ -157,8 +163,15 @@ export class CodexExec { commandArgs.push("--config", `approval_policy="${args.approvalPolicy}"`); } + if (args.continueTurn && !args.threadId) { + throw new Error("continueTurn requires a threadId"); + } + if (args.threadId) { commandArgs.push("resume", args.threadId); + if (args.continueTurn) { + commandArgs.push("--continue"); + } } if (args.images?.length) { @@ -199,7 +212,9 @@ export class CodexExec { child.kill(); throw new Error("Child process has no stdin"); } - child.stdin.write(args.input); + if (!args.continueTurn) { + child.stdin.write(args.input); + } child.stdin.end(); if (!child.stdout) { diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index 1db3ac59c165..d65231543a37 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -67,15 +67,33 @@ export class Thread { return { events: this.runStreamedInternal(input, turnOptions) }; } + /** + * Carries the thread's current turn on without sending a new prompt, and returns the completed + * turn. Use it to finish a turn that stopped part way through, so the same thing is not asked + * twice. The thread must already have an id. + */ + async continueTurn(turnOptions: TurnOptions = {}): Promise { + return this.collect(this.runStreamedInternal(null, turnOptions)); + } + + /** Like `continueTurn`, streaming events as they are produced. */ + async continueTurnStreamed(turnOptions: TurnOptions = {}): Promise { + return { events: this.runStreamedInternal(null, turnOptions) }; + } + private async *runStreamedInternal( - input: Input, + input: Input | null, turnOptions: TurnOptions = {}, ): AsyncGenerator { + if (input === null && !this._id) { + throw new Error("Cannot continue a turn before the thread has an id"); + } const { schemaPath, cleanup } = await createOutputSchemaFile(turnOptions.outputSchema); const options = this._threadOptions; - const { prompt, images } = normalizeInput(input); + const { prompt, images } = input === null ? { prompt: "", images: [] } : normalizeInput(input); const generator = this._exec.run({ input: prompt, + continueTurn: input === null, baseUrl: this._options.baseUrl, apiKey: this._options.apiKey, threadId: this._id, @@ -115,7 +133,10 @@ export class Thread { /** Provides the input to the agent and returns the completed turn. */ async run(input: Input, turnOptions: TurnOptions = {}): Promise { - const generator = this.runStreamedInternal(input, turnOptions); + return this.collect(this.runStreamedInternal(input, turnOptions)); + } + + private async collect(generator: AsyncGenerator): Promise { const items: ThreadItem[] = []; let finalResponse: string = ""; let usage: Usage | null = null;