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
46 changes: 45 additions & 1 deletion codex-rs/app-server-protocol/src/protocol/v2/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,18 @@ fn thread_path_params_deserialize_empty_path_as_none() {
}))
.expect("thread/fork params deserialize");
assert_eq!(fork.path, None);
let resume_with_backtrack: ThreadResumeParams = serde_json::from_value(json!({
"threadId": "thread-1",
"historyBacktrack": 2,
}))
.expect("thread/resume params deserialize");
assert_eq!(resume_with_backtrack.history_backtrack, Some(2));
let fork_with_backtrack: ThreadForkParams = serde_json::from_value(json!({
"threadId": "thread-1",
"historyBacktrack": 3,
}))
.expect("thread/fork params deserialize");
assert_eq!(fork_with_backtrack.history_backtrack, Some(3));

let resume_with_path: ThreadResumeParams = serde_json::from_value(json!({
"threadId": "thread-1",
Expand Down Expand Up @@ -1998,11 +2010,43 @@ fn client_request_thread_fork_granular_approval_policy_is_marked_experimental()
assert_eq!(reason, Some("askForApproval.granular"));
}

#[test]
fn client_request_thread_resume_history_backtrack_is_marked_experimental() {
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(
&crate::ClientRequest::ThreadResume {
request_id: crate::RequestId::Integer(4),
params: ThreadResumeParams {
thread_id: "thr_123".to_string(),
history_backtrack: Some(1),
..Default::default()
},
},
);

assert_eq!(reason, Some("thread/resume.historyBacktrack"));
}

#[test]
fn client_request_thread_fork_history_backtrack_is_marked_experimental() {
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(
&crate::ClientRequest::ThreadFork {
request_id: crate::RequestId::Integer(5),
params: ThreadForkParams {
thread_id: "thr_456".to_string(),
history_backtrack: Some(1),
..Default::default()
},
},
);

assert_eq!(reason, Some("thread/fork.historyBacktrack"));
}

#[test]
fn client_request_turn_start_granular_approval_policy_is_marked_experimental() {
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(
&crate::ClientRequest::TurnStart {
request_id: crate::RequestId::Integer(4),
request_id: crate::RequestId::Integer(6),
params: TurnStartParams {
thread_id: "thr_123".to_string(),
client_user_message_id: None,
Expand Down
14 changes: 14 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2/thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,13 @@ pub struct ThreadResumeParams {
#[ts(optional = nullable)]
pub history: Option<Vec<ResponseItem>>,

/// [UNSTABLE] Resume from a prefix ending before the Nth message or
/// tool-call boundary counted backward from the source history tail.
/// This is non-destructive and does not mutate the source rollout.
#[experimental("thread/resume.historyBacktrack")]
#[ts(optional = nullable)]
pub history_backtrack: Option<u32>,

/// [UNSTABLE] Specify the rollout path to resume from.
/// If specified for a non-running thread, the thread_id param will be ignored.
/// If thread_id identifies a running thread, the path must match the active
Expand Down Expand Up @@ -654,6 +661,13 @@ pub struct ThreadForkParams {
#[ts(optional = nullable)]
pub path: Option<PathBuf>,

/// [UNSTABLE] Fork from a prefix ending before the Nth message or
/// tool-call boundary counted backward from the source history tail.
/// This is non-destructive and does not mutate the source rollout.
#[experimental("thread/fork.historyBacktrack")]
#[ts(optional = nullable)]
pub history_backtrack: Option<u32>,

/// Configuration overrides for the forked thread, if any.
#[ts(optional = nullable)]
pub model: Option<String>,
Expand Down
8 changes: 6 additions & 2 deletions codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ Example with notification opt-out:
## API Overview

- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. To create a fresh child agent thread, pass `threadSource: "subagent"` with `parentThreadId`; the returned thread and `thread/started` notification include the subagent source and parent id. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Experimental clients can pass `historyBacktrack` to resume from a non-destructive prefix before the Nth message/tool-call boundary counted backward from the stored history tail. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `historyBacktrack` to fork from a non-destructive prefix, or `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate control/spawn parent is known.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
Expand Down Expand Up @@ -397,6 +397,8 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev

By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage.

Experimental clients can pass `historyBacktrack: N` to resume from the prefix ending before the Nth message or tool-call boundary counted backward from the source history tail. This is a replay selector, not a rollback: the source rollout is not mutated and no rollback marker is persisted.

Experimental clients that want the live resume subscription plus a turns page in one round trip can pass `initialTurnsPage`. It accepts the same `limit`, `sortDirection`, and `itemsView` controls as `thread/turns/list`; omitted `itemsView` defaults to `summary`. Pass `itemsView: "full"` explicitly when the bootstrap page needs code changes and tool calls. The response includes `initialTurnsPage` with `nextCursor` and `backwardsCursor` for follow-up pagination.

By default, resume uses the latest persisted `model` and `reasoningEffort` values associated with the thread. Supplying any of `model`, `modelProvider`, `config.model`, or `config.model_reasoning_effort` disables that persisted fallback and uses the explicit overrides plus normal config resolution instead.
Expand Down Expand Up @@ -445,6 +447,8 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c

Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thread/fork` to return only thread metadata in `thread.turns` and page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage.

Experimental clients can also pass `historyBacktrack: N` to copy a prefix ending before the Nth message or tool-call boundary counted backward from the source history tail. The source rollout remains unchanged.

### Example: List threads (with pagination & filters)

`thread/list` lets you render a history UI. Results default to `createdAt` (newest first) descending. Pass any combination of:
Expand Down
69 changes: 68 additions & 1 deletion codex-rs/app-server/src/request_processors/thread_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ fn collect_resume_override_mismatches(
config_snapshot.active_permission_profile
));
}
if let Some(history_backtrack) = request.history_backtrack {
mismatch_details.push(format!(
"historyBacktrack requested={history_backtrack} cannot be applied to an already loaded thread"
));
}
if let Some(requested_personality) = request.personality.as_ref()
&& config_snapshot.personality.as_ref() != Some(requested_personality)
{
Expand All @@ -148,6 +153,31 @@ fn collect_resume_override_mismatches(
mismatch_details
}

fn maybe_backtrack_initial_history(
initial_history: InitialHistory,
history_backtrack: Option<u32>,
) -> Result<InitialHistory, JSONRPCErrorError> {
let Some(history_backtrack) = history_backtrack else {
return Ok(initial_history);
};

codex_core::backtrack_initial_history_by_message_or_tool_calls(
initial_history,
history_backtrack,
)
.map_err(|err| match err {
CodexErr::InvalidRequest(message) => invalid_request(message),
err => internal_error(format!("failed to backtrack thread history: {err}")),
})
}

fn validate_history_backtrack(history_backtrack: Option<u32>) -> Result<(), JSONRPCErrorError> {
if history_backtrack == Some(0) {
return Err(invalid_request("historyBacktrack must be >= 1"));
}
Ok(())
}

fn merge_persisted_resume_metadata(
request_overrides: &mut Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: &mut ConfigOverrides,
Expand Down Expand Up @@ -2887,6 +2917,10 @@ impl ThreadRequestProcessor {
.await;
return Ok(());
}
if let Err(error) = validate_history_backtrack(params.history_backtrack) {
self.outgoing.send_error(request_id, error).await;
return Ok(());
}
let redact_resume_payloads =
should_redact_thread_resume_payloads(app_server_client_name.as_deref());

Expand Down Expand Up @@ -2917,6 +2951,7 @@ impl ThreadRequestProcessor {
let ThreadResumeParams {
thread_id,
history,
history_backtrack,
path,
model,
model_provider,
Expand Down Expand Up @@ -2952,6 +2987,22 @@ impl ThreadRequestProcessor {
return Ok(());
}
};
let thread_history =
match maybe_backtrack_initial_history(thread_history, history_backtrack) {
Ok(thread_history) => thread_history,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return Ok(());
}
};
let mut resume_source_thread = resume_source_thread;
if history_backtrack.is_some()
&& let Some(stored_thread) = resume_source_thread.as_mut()
&& let Some(history) = stored_thread.history.as_mut()
{
history.items = thread_history.get_rollout_items();
stored_thread.preview = preview_from_rollout_items(&history.items);
}

let history_cwd = thread_history.session_cwd();
let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots);
Expand Down Expand Up @@ -3048,6 +3099,7 @@ impl ThreadRequestProcessor {
rollout_path.as_path(),
resume_source_thread,
include_turns,
history_backtrack.is_some(),
)
.await
{
Expand Down Expand Up @@ -3293,6 +3345,12 @@ impl ThreadRequestProcessor {
}
}

if params.history_backtrack.is_some() {
return Err(invalid_request(format!(
"cannot resume loaded thread {existing_thread_id} with historyBacktrack"
)));
}

// Preserve rejoin semantics when another client can still observe
// the loaded thread or shutdown did not complete.
tracing::warn!(
Expand Down Expand Up @@ -3511,6 +3569,7 @@ impl ThreadRequestProcessor {
rollout_path: &Path,
resume_source_thread: Option<StoredThread>,
include_turns: bool,
use_history_preview: bool,
) -> std::result::Result<Thread, String> {
let config_snapshot = thread.config_snapshot().await;
let session_id = thread.session_configured().session_id.to_string();
Expand Down Expand Up @@ -3590,6 +3649,9 @@ impl ThreadRequestProcessor {
thread.id = thread_id.to_string();
thread.session_id = session_id;
thread.path = Some(rollout_path.to_path_buf());
if use_history_preview {
thread.preview = preview_from_rollout_items(&thread_history.get_rollout_items());
}
if include_turns {
let history_items = thread_history.get_rollout_items();
populate_thread_turns_from_history(
Expand Down Expand Up @@ -3629,6 +3691,7 @@ impl ThreadRequestProcessor {
let ThreadForkParams {
thread_id,
path,
history_backtrack,
model,
model_provider,
service_tier,
Expand All @@ -3652,6 +3715,7 @@ impl ThreadRequestProcessor {
"`permissions` cannot be combined with `sandbox`",
));
}
validate_history_backtrack(history_backtrack)?;
let source_thread = self
.read_stored_thread_for_resume(&thread_id, path.as_ref(), /*include_history*/ true)
.await?;
Expand Down Expand Up @@ -3711,9 +3775,12 @@ impl ThreadRequestProcessor {
);
let source_initial_history = InitialHistory::Resumed(ResumedHistory {
conversation_id: source_thread_id,
history: history_items.clone(),
history: history_items,
rollout_path: source_thread.rollout_path.clone(),
});
let source_initial_history =
maybe_backtrack_initial_history(source_initial_history, history_backtrack)?;
let history_items = source_initial_history.get_rollout_items();
merge_persisted_auth_profile_from_history(&mut typesafe_overrides, &source_initial_history);
typesafe_overrides.ephemeral = ephemeral.then_some(true);
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,7 @@ mod thread_processor_behavior_tests {
let request = ThreadResumeParams {
thread_id: "thread-1".to_string(),
history: None,
history_backtrack: None,
path: None,
model: None,
model_provider: None,
Expand Down Expand Up @@ -852,6 +853,7 @@ mod thread_processor_behavior_tests {
let mut request = ThreadResumeParams {
thread_id: "thread-1".to_string(),
history: None,
history_backtrack: None,
path: None,
model: None,
model_provider: None,
Expand Down Expand Up @@ -915,6 +917,17 @@ mod thread_processor_behavior_tests {
collect_resume_override_mismatches(&request, &config_snapshot),
vec!["auth_profile requested=None active=Some(\"work\")".to_string()]
);

request.auth_profile = None;
request.history_backtrack = Some(2);

assert_eq!(
collect_resume_override_mismatches(&request, &config_snapshot),
vec![
"historyBacktrack requested=2 cannot be applied to an already loaded thread"
.to_string()
]
);
}

fn test_thread_metadata(
Expand Down
Loading
Loading