Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions codex-rs/exec/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ struct ResumeArgsRaw {
)]
images: Vec<PathBuf>,

/// 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<String>,
Expand All @@ -220,6 +225,9 @@ pub struct ResumeArgs {
/// Optional image(s) to attach to the prompt sent after resuming.
pub images: Vec<PathBuf>,

/// 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<String>,
}
Expand All @@ -238,6 +246,7 @@ impl From<ResumeArgsRaw> for ResumeArgs {
last: raw.last,
all: raw.all,
images: raw.images,
continue_turn: raw.continue_turn,
prompt,
}
}
Expand Down
35 changes: 35 additions & 0 deletions codex-rs/exec/src/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
15 changes: 15 additions & 0 deletions codex-rs/exec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/exec/src/lib_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,13 +308,15 @@ 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 {
session_id: Some("named-session".to_string()),
last: false,
all: false,
images: vec![],
continue_turn: false,
prompt: None,
};

Expand Down
17 changes: 16 additions & 1 deletion sdk/typescript/src/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
27 changes: 24 additions & 3 deletions sdk/typescript/src/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Turn> {
return this.collect(this.runStreamedInternal(null, turnOptions));
}

/** Like `continueTurn`, streaming events as they are produced. */
async continueTurnStreamed(turnOptions: TurnOptions = {}): Promise<StreamedTurn> {
return { events: this.runStreamedInternal(null, turnOptions) };
}

private async *runStreamedInternal(
input: Input,
input: Input | null,
turnOptions: TurnOptions = {},
): AsyncGenerator<ThreadEvent> {
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,
Expand Down Expand Up @@ -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<Turn> {
const generator = this.runStreamedInternal(input, turnOptions);
return this.collect(this.runStreamedInternal(input, turnOptions));
}

private async collect(generator: AsyncGenerator<ThreadEvent>): Promise<Turn> {
const items: ThreadItem[] = [];
let finalResponse: string = "";
let usage: Usage | null = null;
Expand Down
Loading