mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Restore thread cwd from owned settings snapshots (#41567)
## Why Resuming a thread without an explicit `cwd` should restore that thread's latest retained setting. Forked history can contain settings copied from another thread, while compaction can move the latest setting outside the replay window. ## What changed - Record the owning thread ID on new settings snapshots and only use snapshots owned by the resumed thread when restoring `cwd`. Older snapshots without an owner remain readable but do not override the startup `cwd`. - Checkpoint the current settings after compaction and serialize checkpoints with settings updates so the retained history contains an accepted, current snapshot. - Cover resume behavior across compaction, forks, reverts, legacy histories, and concurrent settings updates. GitOrigin-RevId: 1efc9cf55472d6b88c465c4efe44672a232dbf64
This commit is contained in:
BIN
Binary file not shown.
@@ -172,7 +172,7 @@ Example with notification opt-out:
|
||||
|
||||
- `server/diagnostics` — experimental; read process-local memory measurements and registered diagnostic gauges.
|
||||
- `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. Experimental `projectId` assigns a durable thread to an existing project; ephemeral threads expose the same project identity in live responses without creating a stored/listable assignment. Experimental `historyMode` selects the persisted history contract: when omitted, durable threads use `"paginated"` if the active thread store supports `thread/turns/list` and `thread/items/list`, while ephemeral threads and stores without that support use `"legacy"`. 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"`. Experimental `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. Experimental `runtimeWorkspaceRoots` supplies the runtime workspace roots used when app-server creates default environment selections; 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`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. 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` and optional environment-native `runtimeWorkspaceRoots`. Explicit environments ignore the top-level roots; omitted per-environment roots default to that environment's `cwd`, while an empty list explicitly selects no roots. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client.
|
||||
- `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/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. When loading a saved thread, an omitted `cwd` uses the cwd from the latest retained settings snapshot explicitly owned by that thread, or its startup cwd if none exists. Older snapshots without an owner ID do not override the startup cwd. Resume does not read older history solely to recover cwd. Successful compaction checkpoints the current thread settings so they remain available within that replay window. An explicit `cwd` overrides that default. 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; pass an optional `lastTurnId` to copy history only through that turn, inclusive, and drop later turns from the fork. An in-progress `lastTurnId` boundary is rejected. Experimental `beforeTurnId` instead copies history strictly before the referenced turn, including when that turn is in progress, and cannot be combined with `lastTurnId`. If both boundaries are null while the source thread is 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. Clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Experimental `deferGoalContinuation: true` carries the source thread's current goal into the fork and runs an explicit turn before automatic continuation resumes. Deferred goal continuation is persisted until that turn starts and cannot be combined with `ephemeral: true`. Accepts the same permission override rules as `thread/start`.
|
||||
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. 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. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior.
|
||||
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `sectionId`, `cwd`, and `searchTerm` filters. Experimental `projectId` filters one project, while `null` selects unassigned threads. Set `sortKey` to `"section_position"` when listing a section in its persisted manual order. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
|
||||
|
||||
@@ -28,6 +28,7 @@ fn settings_item(
|
||||
) -> RolloutItem {
|
||||
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(
|
||||
ThreadSettingsAppliedEvent {
|
||||
thread_id: None,
|
||||
thread_settings: ThreadSettingsSnapshot {
|
||||
model: "gpt-5".to_string(),
|
||||
model_provider_id: "openai".to_string(),
|
||||
|
||||
@@ -176,7 +176,7 @@ impl ThreadGoalRequestProcessor {
|
||||
// rollout. Once materialized, normal settings updates own this event.
|
||||
let persisted_settings = thread.thread_settings_snapshot().await;
|
||||
let items = [
|
||||
thread_settings_applied_item(persisted_settings.clone()),
|
||||
thread_settings_applied_item(thread_id, persisted_settings.clone()),
|
||||
outcome.thread_goal_updated_item(),
|
||||
];
|
||||
match thread.append_rollout_items(&items).await {
|
||||
@@ -189,6 +189,7 @@ impl ThreadGoalRequestProcessor {
|
||||
} else {
|
||||
thread
|
||||
.append_rollout_items(&[thread_settings_applied_item(
|
||||
thread_id,
|
||||
current_settings,
|
||||
)])
|
||||
.await
|
||||
@@ -480,9 +481,15 @@ impl ThreadGoalRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_settings_applied_item(thread_settings: ThreadSettingsSnapshot) -> RolloutItem {
|
||||
fn thread_settings_applied_item(
|
||||
thread_id: ThreadId,
|
||||
thread_settings: ThreadSettingsSnapshot,
|
||||
) -> RolloutItem {
|
||||
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(
|
||||
ThreadSettingsAppliedEvent { thread_settings },
|
||||
ThreadSettingsAppliedEvent {
|
||||
thread_id: Some(thread_id),
|
||||
thread_settings,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -3739,7 +3739,21 @@ impl ThreadRequestProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
let history_cwd = thread_history.session_cwd();
|
||||
// Copied or referenced history can contain another thread's settings. Only snapshots
|
||||
// explicitly owned by this thread can override its startup cwd.
|
||||
let history_cwd = if let InitialHistory::Resumed(resumed) = &thread_history {
|
||||
resumed.history.iter().rev().find_map(|item| match item {
|
||||
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event))
|
||||
if event.thread_id == Some(resumed.conversation_id) =>
|
||||
{
|
||||
Some(event.thread_settings.cwd.to_path_buf())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let history_cwd = history_cwd.or_else(|| thread_history.session_cwd());
|
||||
let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots);
|
||||
let mut typesafe_overrides = self.build_thread_config_overrides(
|
||||
model,
|
||||
|
||||
@@ -18,7 +18,10 @@ use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ResponseUsageMetadata;
|
||||
use codex_app_server_protocol::ThreadCompactStartParams;
|
||||
use codex_app_server_protocol::ThreadCompactStartResponse;
|
||||
use codex_app_server_protocol::ThreadHistoryMode;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TokenUsageBreakdown;
|
||||
@@ -245,25 +248,39 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let seed = responses::sse(vec![
|
||||
responses::ev_assistant_message("seed", "FIRST_REPLY"),
|
||||
responses::ev_completed_with_tokens("seed", /*total_tokens*/ 120),
|
||||
]);
|
||||
let mut completed = responses::ev_completed_with_tokens("r1", /*total_tokens*/ 200);
|
||||
completed["response"]["usage_metadata"] = serde_json::json!({ "amount": "0.125" });
|
||||
let sse = responses::sse(vec![
|
||||
responses::ev_assistant_message("m1", "MANUAL_COMPACT_SUMMARY"),
|
||||
completed,
|
||||
]);
|
||||
responses::mount_sse_sequence(&server, vec![sse]).await;
|
||||
let followup = responses::sse(vec![
|
||||
responses::ev_assistant_message("followup", "FINAL_REPLY"),
|
||||
responses::ev_completed_with_tokens("followup", /*total_tokens*/ 120),
|
||||
]);
|
||||
let _responses = responses::mount_sse_sequence(&server, vec![seed, sse, followup]).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
compaction_config(&server.uri(), AUTO_COMPACT_LIMIT).write(codex_home.path())?;
|
||||
let initial_cwd = TempDir::new()?;
|
||||
let updated_cwd = TempDir::new()?;
|
||||
compaction_config(&server.uri(), /*auto_compact_limit*/ 1_000_000).write(codex_home.path())?;
|
||||
|
||||
// Top-level cwd restoration uses host-native paths, not a foreign executor's paths.
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
cwd: Some(initial_cwd.path().to_string_lossy().into_owned()),
|
||||
history_mode: Some(ThreadHistoryMode::Paginated),
|
||||
experimental_raw_events: true,
|
||||
..Default::default()
|
||||
})
|
||||
@@ -271,6 +288,21 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -
|
||||
let ThreadStartResponse { thread, .. } =
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(thread_req)).await??;
|
||||
let thread_id = thread.id;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.start_turn_and_wait_for_completion(TurnStartParams {
|
||||
thread_id: thread_id.clone(),
|
||||
cwd: Some(updated_cwd.path().to_path_buf()),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "seed history".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await??;
|
||||
mcp.clear_message_buffer();
|
||||
|
||||
let compact_id = mcp
|
||||
.send_thread_compact_start_request(ThreadCompactStartParams {
|
||||
thread_id: thread_id.clone(),
|
||||
@@ -286,6 +318,7 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -
|
||||
)
|
||||
.await??;
|
||||
let completed = wait_for_context_compaction_completed(&mut mcp).await?;
|
||||
wait_for_turn_completed(&mut mcp, &started.turn_id).await?;
|
||||
|
||||
let ThreadItem::ContextCompaction { id: started_id } = started.item else {
|
||||
unreachable!("started item should be context compaction");
|
||||
@@ -300,7 +333,7 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -
|
||||
assert_eq!(
|
||||
raw_completed,
|
||||
RawResponseCompletedNotification {
|
||||
thread_id,
|
||||
thread_id: thread_id.clone(),
|
||||
turn_id: started.turn_id,
|
||||
response_id: "r1".to_string(),
|
||||
usage_metadata: Some(ResponseUsageMetadata {
|
||||
@@ -317,6 +350,27 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -
|
||||
}
|
||||
);
|
||||
|
||||
// A completed turn after compaction permits bounded replay. Neither this turn nor
|
||||
// resume resends settings, so restoring the updated cwd depends on the checkpoint.
|
||||
send_turn_and_wait(&mut mcp, &thread_id, "continue").await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
let resume_id = mcp
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id,
|
||||
exclude_turns: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let ThreadResumeResponse { cwd, .. } =
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
||||
assert_eq!(cwd.as_path(), updated_cwd.path());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
@@ -102,6 +103,7 @@ use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource as RolloutSessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
|
||||
use codex_protocol::protocol::TokenCountEvent;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
@@ -112,6 +114,7 @@ use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_rollout::CompactedItem;
|
||||
use codex_rollout::RolloutItem;
|
||||
use codex_rollout::RolloutRecorder;
|
||||
use codex_rollout::append_rollout_item_to_path;
|
||||
use codex_rollout::read_session_meta_line;
|
||||
use codex_state::StateRuntime;
|
||||
@@ -159,6 +162,7 @@ const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding a
|
||||
async fn thread_resume_paginated_model_context_preserves_original_metadata() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let saved_cwd = normalized_existing_path(codex_home.path())?;
|
||||
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
||||
let conversation_id = create_fake_paginated_rollout(
|
||||
codex_home.path(),
|
||||
@@ -169,6 +173,24 @@ async fn thread_resume_paginated_model_context_preserves_original_metadata() ->
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
let path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &conversation_id);
|
||||
let startup_cwd = read_session_meta_line(&path).await?.meta.cwd;
|
||||
let settings: ThreadSettingsAppliedEvent = serde_json::from_value(json!({
|
||||
"thread_id": conversation_id,
|
||||
"thread_settings": {
|
||||
"model": "gpt-5.4",
|
||||
"model_provider_id": "mock_provider",
|
||||
"cwd": saved_cwd,
|
||||
"approval_policy": "never",
|
||||
"approvals_reviewer": "user",
|
||||
"permission_profile": PermissionProfile::read_only(),
|
||||
"collaboration_mode": { "mode": "default", "settings": { "model": "gpt-5.4" } },
|
||||
},
|
||||
}))?;
|
||||
append_rollout_item_to_path(
|
||||
&path,
|
||||
&RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(settings)),
|
||||
)
|
||||
.await?;
|
||||
append_rollout_item_to_path(
|
||||
&path,
|
||||
&RolloutItem::Compacted(CompactedItem {
|
||||
@@ -197,8 +219,11 @@ async fn thread_resume_paginated_model_context_preserves_original_metadata() ->
|
||||
})
|
||||
.await?;
|
||||
let ThreadResumeResponse {
|
||||
thread: resumed, ..
|
||||
thread: resumed,
|
||||
cwd,
|
||||
..
|
||||
} = timeout(DEFAULT_READ_TIMEOUT, primary.read_response(resume_id)).await??;
|
||||
assert_eq!(cwd.as_path(), saved_cwd);
|
||||
assert_eq!(resumed.id, conversation_id);
|
||||
assert_eq!(resumed.history_mode, ThreadHistoryMode::Paginated);
|
||||
assert_eq!(resumed.preview, "Saved user message");
|
||||
@@ -232,8 +257,13 @@ async fn thread_resume_paginated_model_context_preserves_original_metadata() ->
|
||||
})
|
||||
.await?;
|
||||
let ThreadResumeResponse {
|
||||
thread: resumed, ..
|
||||
thread: resumed,
|
||||
cwd,
|
||||
..
|
||||
} = timeout(DEFAULT_READ_TIMEOUT, secondary.read_response(resume_id)).await??;
|
||||
// The completed turn now permits a bounded replay ending at the compaction,
|
||||
// so the earlier settings snapshot is outside the normal resume window.
|
||||
assert_eq!(cwd.as_path(), startup_cwd);
|
||||
assert_eq!(resumed.preview, "Saved user message");
|
||||
assert!(resumed.turns.is_empty());
|
||||
|
||||
@@ -883,6 +913,7 @@ async fn thread_resume_preserves_goal_first_and_fork_approvals_reviewer() -> Res
|
||||
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
||||
model: Some("gpt-5.2-codex".to_string()),
|
||||
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||
history_mode: Some(ThreadHistoryMode::Legacy),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -934,6 +965,24 @@ async fn thread_resume_preserves_goal_first_and_fork_approvals_reviewer() -> Res
|
||||
..
|
||||
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
||||
assert_eq!(approvals_reviewer, ApprovalsReviewer::User);
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??;
|
||||
let (items, _, _) =
|
||||
RolloutRecorder::load_rollout_items(fork_thread.path.as_ref().expect("fork rollout"))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) =>
|
||||
event.thread_id,
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
ThreadId::from_string(&thread.id)?,
|
||||
ThreadId::from_string(&fork_thread.id)?,
|
||||
]
|
||||
);
|
||||
|
||||
(thread.id, fork_thread.id)
|
||||
};
|
||||
@@ -991,7 +1040,7 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
),
|
||||
)?;
|
||||
|
||||
let thread_id = {
|
||||
let (thread_id, rollout_path) = {
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized()
|
||||
@@ -1000,6 +1049,7 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
let start_id = mcp
|
||||
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
history_mode: Some(ThreadHistoryMode::Legacy),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -1028,6 +1078,15 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
)
|
||||
.await??;
|
||||
|
||||
let fork_id = mcp
|
||||
.send_thread_fork_request(ThreadForkParams {
|
||||
thread_id: thread.id.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let ThreadForkResponse { thread, .. } =
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
||||
|
||||
let update_id = mcp
|
||||
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
|
||||
thread_id: thread.id.clone(),
|
||||
@@ -1071,7 +1130,7 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
Some(&read.cwd)
|
||||
);
|
||||
|
||||
thread.id
|
||||
(thread.id, read.path.expect("materialized rollout path"))
|
||||
};
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
@@ -1103,6 +1162,7 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
let update_id = mcp
|
||||
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
|
||||
thread_id: thread_id.clone(),
|
||||
cwd: Some(persisted_cwd.clone()),
|
||||
collaboration_mode: Some(CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
@@ -1121,7 +1181,15 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
mcp.read_stream_until_notification_message("thread/settings/updated"),
|
||||
)
|
||||
.await??;
|
||||
drop(mcp);
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??;
|
||||
|
||||
// Older rollouts can retain a frozen turn context after an accepted settings update.
|
||||
let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path).await?;
|
||||
let frozen_context = items
|
||||
.into_iter()
|
||||
.find(|item| matches!(item, RolloutItem::TurnContext(_)))
|
||||
.expect("initial turn context");
|
||||
append_rollout_item_to_path(&rollout_path, &frozen_context).await?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
@@ -1134,10 +1202,12 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
})
|
||||
.await?;
|
||||
let ThreadResumeResponse {
|
||||
reasoning_effort, ..
|
||||
cwd,
|
||||
reasoning_effort,
|
||||
..
|
||||
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
||||
|
||||
assert_eq!(reasoning_effort, None);
|
||||
assert_eq!(cwd.as_path(), persisted_cwd);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2035,6 +2105,7 @@ fn set_session_meta_on_fake_rollout(
|
||||
async fn thread_resume_returns_rollout_history() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let saved_cwd = normalized_existing_path(codex_home.path())?;
|
||||
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
||||
|
||||
let preview = "Saved user message";
|
||||
@@ -2054,6 +2125,23 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
// Old snapshots have no owner ID: keep them readable without adopting their cwd.
|
||||
let settings: ThreadSettingsAppliedEvent = serde_json::from_value(json!({
|
||||
"thread_settings": {
|
||||
"model": "gpt-5.4",
|
||||
"model_provider_id": "mock_provider",
|
||||
"cwd": saved_cwd,
|
||||
"approval_policy": "never",
|
||||
"approvals_reviewer": "user",
|
||||
"permission_profile": PermissionProfile::read_only(),
|
||||
"collaboration_mode": { "mode": "default", "settings": { "model": "gpt-5.4" } },
|
||||
},
|
||||
}))?;
|
||||
append_rollout_item_to_path(
|
||||
&rollout_path(codex_home.path(), "2025-01-05T12-00-00", &conversation_id),
|
||||
&RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(settings)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
@@ -2066,14 +2154,15 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let ThreadResumeResponse { thread, .. } =
|
||||
let ThreadResumeResponse { thread, cwd, .. } =
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
||||
|
||||
assert_eq!(thread.id, conversation_id);
|
||||
assert_eq!(thread.preview, preview);
|
||||
assert_eq!(thread.model_provider, "mock_provider");
|
||||
assert!(thread.path.as_ref().expect("thread path").is_absolute());
|
||||
assert_eq!(thread.cwd, test_absolute_path("/"));
|
||||
assert_eq!(thread.cwd.as_path(), saved_cwd);
|
||||
assert_eq!(cwd, test_absolute_path("/"));
|
||||
assert_eq!(thread.cli_version, "0.0.0");
|
||||
assert_eq!(thread.source, SessionSource::Cli);
|
||||
assert_eq!(thread.git_info, None);
|
||||
|
||||
@@ -40,6 +40,7 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_rollout::RolloutItem;
|
||||
use codex_rollout::RolloutLine;
|
||||
use codex_rollout::read_session_meta_line;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
@@ -51,16 +52,24 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
|
||||
async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let updated_workspace = TempDir::new()?;
|
||||
let saved_cwd = AbsolutePathBuf::from_absolute_path(updated_workspace.path().canonicalize()?)?
|
||||
.into_path_buf();
|
||||
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
||||
// This fixture checks host-native cwd restoration across fork and revert.
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.build()
|
||||
.await?;
|
||||
initialize_experimental(&mut mcp).await?;
|
||||
let ThreadStartResponse { thread: parent, .. } = mcp
|
||||
.start_thread(ThreadStartParams {
|
||||
history_mode: Some(ThreadHistoryMode::Paginated),
|
||||
..Default::default()
|
||||
.request(|request_id| ClientRequest::ThreadStart {
|
||||
request_id,
|
||||
params: ThreadStartParams {
|
||||
history_mode: Some(ThreadHistoryMode::Paginated),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
let mut parent_turns = Vec::new();
|
||||
@@ -68,6 +77,7 @@ async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> Result<()> {
|
||||
let completed = mcp
|
||||
.start_turn_and_wait_for_completion(TurnStartParams {
|
||||
thread_id: parent.id.clone(),
|
||||
cwd: Some(parent.cwd.as_path().to_path_buf()),
|
||||
input: vec![UserInput::Text {
|
||||
text: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
@@ -82,6 +92,7 @@ async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> Result<()> {
|
||||
request_id,
|
||||
params: ThreadForkParams {
|
||||
thread_id: parent.id.clone(),
|
||||
cwd: Some(codex_home.path().to_string_lossy().into_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
@@ -114,6 +125,7 @@ async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> Result<()> {
|
||||
let completed = mcp
|
||||
.start_turn_and_wait_for_completion(TurnStartParams {
|
||||
thread_id: child.id.clone(),
|
||||
cwd: Some(saved_cwd.clone()),
|
||||
input: vec![UserInput::Text {
|
||||
text: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
@@ -156,10 +168,11 @@ async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> Result<()> {
|
||||
mcp.shutdown_gracefully().await?;
|
||||
mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.build()
|
||||
.await?;
|
||||
initialize_experimental(&mut mcp).await?;
|
||||
let _: ThreadResumeResponse = mcp
|
||||
let ThreadResumeResponse { cwd, .. } = mcp
|
||||
.request(|request_id| ClientRequest::ThreadResume {
|
||||
request_id,
|
||||
params: ThreadResumeParams {
|
||||
@@ -168,6 +181,12 @@ async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> Result<()> {
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
if expected_cutoff == fork_cutoff {
|
||||
assert_eq!(cwd.as_path(), saved_cwd);
|
||||
} else {
|
||||
// Only parent-owned snapshots remain after reverting into inherited history.
|
||||
assert_eq!(cwd.as_path(), child_meta.cwd);
|
||||
}
|
||||
mcp.start_turn_and_wait_for_completion(TurnStartParams {
|
||||
thread_id: child.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
|
||||
@@ -1198,6 +1198,7 @@ async fn spawn_agent_fork_from_paginated_parent_uses_model_context_prefix() {
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(
|
||||
ThreadSettingsAppliedEvent {
|
||||
thread_id: Some(parent_thread_id),
|
||||
thread_settings: ThreadSettingsSnapshot {
|
||||
model: "parent-only-model".to_string(),
|
||||
model_provider_id: "parent-only-provider".to_string(),
|
||||
|
||||
@@ -3634,6 +3634,9 @@ impl Session {
|
||||
.map(|id| id.to_string()),
|
||||
window_id: Some(metadata.window_ids.window_id.to_string()),
|
||||
};
|
||||
// Wait for accepted updates to finish persisting, then keep later updates from
|
||||
// overtaking the current settings snapshot while its checkpoint is written.
|
||||
let _settings_guard = thread_settings::acquire_persistence_lock(self).await;
|
||||
// Compaction starts a new history window, so its WorldState baseline must be full.
|
||||
let mut world_state_item = None;
|
||||
{
|
||||
@@ -3646,17 +3649,19 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)])
|
||||
.await;
|
||||
let mut rollout_items = vec![RolloutItem::Compacted(compacted_item)];
|
||||
// Persist the baseline after the replacement history that established it.
|
||||
if let Some(world_state_item) = world_state_item {
|
||||
self.persist_rollout_items(&[RolloutItem::WorldState(world_state_item)])
|
||||
.await;
|
||||
rollout_items.push(RolloutItem::WorldState(world_state_item));
|
||||
}
|
||||
if let Some(turn_context_item) = reference_context_item {
|
||||
self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item)])
|
||||
.await;
|
||||
rollout_items.push(RolloutItem::TurnContext(turn_context_item));
|
||||
}
|
||||
// The frozen turn context must not override current settings in persisted metadata.
|
||||
rollout_items.push(RolloutItem::EventMsg(
|
||||
thread_settings::applied_event(self).await,
|
||||
));
|
||||
self.persist_rollout_items(&rollout_items).await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact);
|
||||
|
||||
@@ -44,6 +44,9 @@ pub(crate) struct Session {
|
||||
pub(super) tx_event: Sender<Event>,
|
||||
pub(super) agent_status: watch::Sender<AgentStatus>,
|
||||
pub(super) state: Mutex<SessionState>,
|
||||
/// Orders accepted settings commits and their persisted events with compaction checkpoints.
|
||||
/// Keep this separate from `state` so storage I/O does not block runtime state access.
|
||||
pub(super) thread_settings_persistence: Semaphore,
|
||||
/// Serializes rebuild/apply cycles for the running proxy; each cycle
|
||||
/// rebuilds from the current SessionState while holding this lock.
|
||||
pub(super) managed_network_proxy_refresh_lock: Semaphore,
|
||||
@@ -1469,6 +1472,7 @@ impl Session {
|
||||
tx_event: tx_event.clone(),
|
||||
agent_status,
|
||||
state: Mutex::new(state),
|
||||
thread_settings_persistence: Semaphore::new(/*permits*/ 1),
|
||||
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
features: config.features.clone(),
|
||||
windows_sandbox_proxy_settings_mode,
|
||||
|
||||
@@ -5817,6 +5817,101 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_checkpoint_waits_for_accepted_settings_persistence() {
|
||||
let (mut session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
Vec::new(),
|
||||
|config| {
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::workspace_write())
|
||||
.expect("set initial permission profile");
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let rollout_path =
|
||||
attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await;
|
||||
let refresh_guard = session
|
||||
.managed_network_proxy_refresh_lock
|
||||
.acquire()
|
||||
.await
|
||||
.expect("network refresh lock");
|
||||
let mut update = Box::pin(tokio::task::unconstrained(thread_settings::apply_update(
|
||||
&session,
|
||||
"settings".to_string(),
|
||||
SessionSettingsUpdate {
|
||||
step_settings: StepSettingsUpdate {
|
||||
service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())),
|
||||
..Default::default()
|
||||
},
|
||||
permission_profile: Some(PermissionProfile::read_only()),
|
||||
..Default::default()
|
||||
},
|
||||
)));
|
||||
// Pause after committing settings but before their accepted snapshot is persisted.
|
||||
assert!(futures::poll!(update.as_mut()).is_pending());
|
||||
let committed = session.thread_settings_snapshot().await;
|
||||
let history_before = session.clone_history().await;
|
||||
let (window_number, window_ids) = session.advance_auto_compact_window().await;
|
||||
let mut checkpoint = Box::pin(tokio::task::unconstrained(
|
||||
session.replace_compacted_history(
|
||||
vec![ResponseItemEnvelope::new(user_message("compacted history"))],
|
||||
/*reference_context_item*/ None,
|
||||
/*world_state_baseline*/ None,
|
||||
CompactedHistoryMetadata {
|
||||
message: "summary".to_string(),
|
||||
window_number,
|
||||
window_ids,
|
||||
},
|
||||
),
|
||||
));
|
||||
assert!(futures::poll!(checkpoint.as_mut()).is_pending());
|
||||
assert_eq!(
|
||||
session.clone_history().await.annotated_items(),
|
||||
history_before.annotated_items()
|
||||
);
|
||||
|
||||
// Direct runtime restoration may overlap postcommit work. The checkpoint must wait
|
||||
// for the accepted event, then capture current settings rather than its older commit.
|
||||
let restored = session
|
||||
.update_settings(SessionSettingsUpdate {
|
||||
step_settings: StepSettingsUpdate {
|
||||
service_tier: Some(None),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("restore current settings")
|
||||
.snapshot;
|
||||
assert_ne!(committed, restored);
|
||||
drop(refresh_guard);
|
||||
update.await.expect("accepted settings update");
|
||||
checkpoint.await;
|
||||
|
||||
session.flush_rollout().await.expect("flush checkpoint");
|
||||
let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path)
|
||||
.await
|
||||
.expect("read persisted settings");
|
||||
let snapshots = items
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) => {
|
||||
Some((event.thread_id, event.thread_settings))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
snapshots,
|
||||
vec![
|
||||
(Some(session.thread_id), committed),
|
||||
(Some(session.thread_id), restored),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_settings_commit_keeps_snapshot_across_postcommit_wait() {
|
||||
let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
||||
@@ -6430,6 +6525,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
tx_event,
|
||||
agent_status: agent_status_tx,
|
||||
state: Mutex::new(state),
|
||||
thread_settings_persistence: Semaphore::new(/*permits*/ 1),
|
||||
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
features: config.features.clone(),
|
||||
windows_sandbox_proxy_settings_mode:
|
||||
@@ -8709,6 +8805,7 @@ where
|
||||
tx_event,
|
||||
agent_status: agent_status_tx,
|
||||
state: Mutex::new(state),
|
||||
thread_settings_persistence: Semaphore::new(/*permits*/ 1),
|
||||
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
features: config.features.clone(),
|
||||
windows_sandbox_proxy_settings_mode:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Handles persistent thread-settings updates shared by standalone settings
|
||||
//! submissions and turn-input submission.
|
||||
//! Handles persistent thread-settings updates and serializes their persistence
|
||||
//! with compaction checkpoints.
|
||||
|
||||
use super::session::Session;
|
||||
use super::session::SessionSettingsUpdate;
|
||||
@@ -13,6 +13,7 @@ use codex_protocol::protocol::ThreadSettingsAppliedEvent;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::protocol::ThreadSettingsSnapshot;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::SemaphorePermit;
|
||||
|
||||
/// Applies standalone thread settings and reports invalid overrides through the
|
||||
/// normal event stream.
|
||||
@@ -75,12 +76,22 @@ pub(super) fn prepare_update(overrides: ThreadSettingsOverrides) -> SessionSetti
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquires the shared permit before capturing or changing persistent settings.
|
||||
pub(super) async fn acquire_persistence_lock(session: &Session) -> SemaphorePermit<'_> {
|
||||
session
|
||||
.thread_settings_persistence
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap_or_else(|_| unreachable!("thread settings persistence semaphore is never closed"))
|
||||
}
|
||||
|
||||
/// Applies persistent settings and emits the resulting thread-owned snapshot.
|
||||
pub(super) async fn apply_update(
|
||||
session: &Session,
|
||||
submission_id: String,
|
||||
updates: SessionSettingsUpdate,
|
||||
) -> ConstraintResult<()> {
|
||||
let _settings_guard = acquire_persistence_lock(session).await;
|
||||
let commit = session.update_settings(updates).await?;
|
||||
emit_applied(session, submission_id, commit.snapshot).await;
|
||||
Ok(())
|
||||
@@ -93,6 +104,7 @@ pub(super) async fn emit_applied(
|
||||
snapshot: ThreadSettingsSnapshot,
|
||||
) {
|
||||
let msg = EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
|
||||
thread_id: Some(session.thread_id()),
|
||||
thread_settings: snapshot,
|
||||
});
|
||||
session
|
||||
@@ -103,9 +115,10 @@ pub(super) async fn emit_applied(
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Builds the current thread-settings event for synthesized fork history.
|
||||
/// Builds a current thread-owned snapshot for fork and compaction persistence.
|
||||
pub(super) async fn applied_event(session: &Session) -> EventMsg {
|
||||
EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
|
||||
thread_id: Some(session.thread_id()),
|
||||
thread_settings: session.thread_settings_snapshot().await,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,6 +129,11 @@ impl PreparedTurnInputSettings {
|
||||
cyber_access_program,
|
||||
} = self.start_options;
|
||||
let emit_thread_settings_applied = self.thread_settings_update.is_some();
|
||||
let _settings_guard = if emit_thread_settings_applied {
|
||||
Some(thread_settings::acquire_persistence_lock(session).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut updates = self.thread_settings_update.unwrap_or_default();
|
||||
updates.service_tier_for_turn = service_tier;
|
||||
|
||||
|
||||
@@ -3646,6 +3646,7 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let expected_settings = codex.thread_settings_snapshot().await;
|
||||
codex.submit(Op::Shutdown).await.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
@@ -3653,6 +3654,8 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
let text = std::fs::read_to_string(&rollout_path).expect("failed to read rollout file");
|
||||
|
||||
let mut turn_context_count = 0usize;
|
||||
let mut saw_compaction = false;
|
||||
let mut checkpoint = None;
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -3665,7 +3668,12 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
RolloutItem::TurnContext(_) => {
|
||||
turn_context_count += 1;
|
||||
}
|
||||
RolloutItem::Compacted(_) => {}
|
||||
RolloutItem::Compacted(_) => saw_compaction = true,
|
||||
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(applied))
|
||||
if saw_compaction && checkpoint.is_none() =>
|
||||
{
|
||||
checkpoint = Some((applied.thread_id, applied.thread_settings));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -3674,6 +3682,10 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
turn_context_count, 3,
|
||||
"rollout should contain one TurnContext entry per real user turn"
|
||||
);
|
||||
assert_eq!(
|
||||
checkpoint,
|
||||
Some((Some(session_configured.thread_id), expected_settings))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_history::InitialHistory;
|
||||
use codex_history::ResumedHistory;
|
||||
use codex_history::RolloutItem;
|
||||
use codex_history::RolloutLine;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -95,6 +96,7 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
|
||||
// Fork once with n=1 → drops the last user input and everything after.
|
||||
let NewThread {
|
||||
thread_id: fork1_thread_id,
|
||||
thread: codex_fork1,
|
||||
..
|
||||
} = thread_manager
|
||||
@@ -110,6 +112,7 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
|
||||
let fork1_path = codex_fork1.rollout_path().expect("rollout path");
|
||||
expected_after_first.push(thread_settings_applied_item(
|
||||
fork1_thread_id,
|
||||
codex_fork1.thread_settings_snapshot().await,
|
||||
));
|
||||
|
||||
@@ -122,6 +125,7 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
|
||||
// Fork again with n=0 → drops the (new) last user message, leaving only the first.
|
||||
let NewThread {
|
||||
thread_id: fork2_thread_id,
|
||||
thread: codex_fork2,
|
||||
..
|
||||
} = thread_manager
|
||||
@@ -145,6 +149,7 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
.unwrap_or(0);
|
||||
let mut expected_after_second: Vec<RolloutItem> = fork1_items[..cut_last_on_fork1].to_vec();
|
||||
expected_after_second.push(thread_settings_applied_item(
|
||||
fork2_thread_id,
|
||||
codex_fork2.thread_settings_snapshot().await,
|
||||
));
|
||||
let fork2_items = read_rollout_items(&fork2_path);
|
||||
@@ -154,9 +159,13 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
);
|
||||
}
|
||||
|
||||
fn thread_settings_applied_item(snapshot: ThreadSettingsSnapshot) -> RolloutItem {
|
||||
fn thread_settings_applied_item(
|
||||
thread_id: ThreadId,
|
||||
snapshot: ThreadSettingsSnapshot,
|
||||
) -> RolloutItem {
|
||||
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(
|
||||
ThreadSettingsAppliedEvent {
|
||||
thread_id: Some(thread_id),
|
||||
thread_settings: snapshot,
|
||||
},
|
||||
))
|
||||
|
||||
@@ -5,12 +5,19 @@ use codex_core::config::Config;
|
||||
use codex_extension_api::ConfigContributor;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_history::RolloutItem;
|
||||
use codex_history::RolloutLine;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::streaming_sse::StreamingSseChunk;
|
||||
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||
use core_test_support::submit_thread_settings;
|
||||
use core_test_support::test_codex::local_selections;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -18,6 +25,7 @@ use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time::timeout;
|
||||
@@ -133,7 +141,7 @@ async fn settings_notifications_keep_their_commit_across_postcommit_work(
|
||||
let event = test.codex.next_event().await?;
|
||||
match event.msg {
|
||||
EventMsg::ThreadSettingsApplied(applied) if event.id == submission_id => {
|
||||
return Ok::<_, anyhow::Error>(applied.thread_settings);
|
||||
return Ok::<_, anyhow::Error>((applied.thread_id, applied.thread_settings));
|
||||
}
|
||||
EventMsg::Error(error) => {
|
||||
anyhow::bail!("settings update failed: {}", error.message)
|
||||
@@ -143,7 +151,7 @@ async fn settings_notifications_keep_their_commit_across_postcommit_work(
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
assert_eq!(applied, expected);
|
||||
assert_eq!(applied, (Some(test.session_configured.thread_id), expected));
|
||||
|
||||
let expected_request_model = match operation {
|
||||
SettingsOperation::TurnStart => COMMITTED_MODEL,
|
||||
@@ -169,3 +177,85 @@ async fn settings_notifications_keep_their_commit_across_postcommit_work(
|
||||
assert_eq!(test.codex.thread_settings_snapshot().await, restored);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn compaction_checkpoints_settings_changed_during_its_model_request() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let (release_compaction, compaction_gate) = oneshot::channel();
|
||||
let (server, _completions) = start_streaming_sse_server(vec![
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: responses::sse(vec![
|
||||
responses::ev_response_created("turn"),
|
||||
responses::ev_completed("turn"),
|
||||
]),
|
||||
}],
|
||||
vec![StreamingSseChunk {
|
||||
gate: Some(compaction_gate),
|
||||
body: responses::sse(vec![
|
||||
responses::ev_response_created("compact"),
|
||||
responses::ev_assistant_message("summary", "compacted history"),
|
||||
responses::ev_completed("compact"),
|
||||
]),
|
||||
}],
|
||||
])
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_model(INITIAL_MODEL)
|
||||
.with_config(|config| {
|
||||
// Local compaction lets the SSE gate hold its response in flight.
|
||||
config.model_provider.name = "OpenAI (test)".to_string();
|
||||
})
|
||||
.build_with_streaming_server(&server)
|
||||
.await?;
|
||||
test.submit_text_turn("before compaction").await?;
|
||||
test.codex.submit(Op::Compact).await?;
|
||||
timeout(TIMEOUT, server.wait_for_request_count(/*count*/ 2)).await?;
|
||||
|
||||
let request: serde_json::Value = serde_json::from_slice(&server.requests().await[1])?;
|
||||
assert_eq!(request["model"], INITIAL_MODEL);
|
||||
let updated_cwd = TempDir::new()?;
|
||||
let updated_cwd_path = AbsolutePathBuf::try_from(updated_cwd.path())?;
|
||||
submit_thread_settings(
|
||||
&test.codex,
|
||||
ThreadSettingsOverrides {
|
||||
environments: Some(local_selections(updated_cwd_path.clone())),
|
||||
model: Some(COMMITTED_MODEL.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let expected = test.codex.thread_settings_snapshot().await;
|
||||
assert_eq!(
|
||||
(&expected.cwd, expected.model.as_str()),
|
||||
(&updated_cwd_path, COMMITTED_MODEL)
|
||||
);
|
||||
release_compaction.send(()).expect("compaction is waiting");
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
|
||||
let rollout_path = test.session_configured.rollout_path.expect("rollout path");
|
||||
let rollout: Vec<RolloutLine> = std::fs::read_to_string(rollout_path)?
|
||||
.lines()
|
||||
.map(serde_json::from_str)
|
||||
.collect::<std::result::Result<_, _>>()?;
|
||||
let checkpoint = rollout
|
||||
.iter()
|
||||
.skip_while(|line| !matches!(line.item, RolloutItem::Compacted(_)))
|
||||
.find_map(|line| match &line.item {
|
||||
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(applied)) => {
|
||||
Some((applied.thread_id, &applied.thread_settings))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
checkpoint,
|
||||
Some((Some(test.session_configured.thread_id), &expected))
|
||||
);
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2158,6 +2158,11 @@ pub struct TurnStartedEvent {
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ThreadSettingsAppliedEvent {
|
||||
/// Logical task that owns this snapshot, independent of the physical rollout file.
|
||||
/// Absent in older histories; copied snapshots retain their original owner's ID.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub thread_id: Option<ThreadId>,
|
||||
pub thread_settings: ThreadSettingsSnapshot,
|
||||
}
|
||||
|
||||
|
||||
@@ -600,6 +600,7 @@ mod tests {
|
||||
.join("updated/workspace");
|
||||
let item = RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(
|
||||
ThreadSettingsAppliedEvent {
|
||||
thread_id: None,
|
||||
thread_settings: ThreadSettingsSnapshot {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
model_provider_id: "updated-provider".to_string(),
|
||||
|
||||
@@ -638,6 +638,7 @@ mod tests {
|
||||
|
||||
let mut item = RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(
|
||||
ThreadSettingsAppliedEvent {
|
||||
thread_id: None,
|
||||
thread_settings: ThreadSettingsSnapshot {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
model_provider_id: "updated-provider".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user