mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Avoid holding metadata permit during cold resume config load (#42773)
## Why Cold `thread/resume` configuration loading can call back into the host. Holding the global metadata permit during that work blocks unrelated thread metadata updates and MCP requests. ## What changed - Release the metadata permit while loading configuration for a cold resume. - Reacquire the permit and reload the resolved thread history before startup. - Reload configuration when persisted metadata, settings, or the history working directory changed during the unlocked interval. - Rejoin a thread that another request resumed concurrently, and avoid reopening threads that were archived or deleted. ## Testing Add coverage for unrelated metadata and MCP requests during a blocked config load, concurrent resume mutations, and the extra history read required for non-local thread stores. GitOrigin-RevId: 8cbd3868eeff84968765b1e3dbc7590ebe5fa892
This commit is contained in:
@@ -461,6 +461,8 @@ Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `
|
||||
|
||||
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`. On cold resume, approval policy and the active permission-profile ID select a source in this order: request override, latest persisted thread setting, current configured default. The persisted profile ID is resolved through the same config and requirements path as a `permissions` override. Threads without an active profile ID use current config instead of restoring their concrete historical permissions.
|
||||
|
||||
Cold resume loads configuration without holding the global metadata permit, allowing unrelated thread metadata updates and MCP requests to proceed during configuration loading. Before startup, it rechecks the resolved thread and reloads its history under the permit; if persisted configuration inputs changed, it reloads configuration as well. Requests serialized on the same thread retain their existing order.
|
||||
|
||||
Parent-owned Multi-Agent V2 children are an exception: `thread/resume` ignores configuration overrides and reattaches to the existing child. An unloaded child is reloaded through its actual, currently loaded parent using parent-derived configuration. If that owner-controlled reload cannot be performed, the request returns JSON-RPC error `-32600`; resume the parent first, or use `thread/read` or `thread/turns/list` to inspect the child's stored history without loading it. This policy follows the child's multi-agent runtime, including leaf workers whose models cannot delegate further.
|
||||
|
||||
By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Full-history hydration is deprecated for paginated threads and emits `deprecationNotice`; clients should pass `excludeTurns: true` to return only thread metadata and live resume state, then page with `thread/turns/list` and `thread/items/list`. A cold paginated resume can still replay persisted `thread/tokenUsage/updated` when it can identify the corresponding stored turn; resuming an already-loaded thread waits for the next live update.
|
||||
|
||||
@@ -23,6 +23,7 @@ use codex_protocol::error::CodexErrorDetails;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_thread_store::PersistContext;
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
pub(super) const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
|
||||
pub(super) const THREAD_LIST_MAX_LIMIT: usize = 100;
|
||||
@@ -85,6 +86,19 @@ struct ThreadListFilters {
|
||||
relation_filter: Option<StoreThreadRelationFilter>,
|
||||
}
|
||||
|
||||
// Persisted inputs that can change while loading configuration without the metadata permit.
|
||||
#[derive(PartialEq)]
|
||||
struct ResumeConfigState {
|
||||
history_cwd: Option<PathBuf>,
|
||||
persisted_metadata: Option<ThreadMetadata>,
|
||||
persisted_settings: Option<PersistedResumeSettings>,
|
||||
}
|
||||
|
||||
struct PreparedResumeConfig {
|
||||
state: ResumeConfigState,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
struct ThreadRevertRuntimeSnapshot {
|
||||
config: Config,
|
||||
settings: CodexThreadSettingsOverrides,
|
||||
@@ -541,15 +555,20 @@ impl ThreadRequestProcessor {
|
||||
app_server_client_version: Option<String>,
|
||||
client_mcp_extensions: ClientMcpExtensions,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.thread_resume_inner(
|
||||
request_id,
|
||||
params,
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
client_mcp_extensions,
|
||||
)
|
||||
.await
|
||||
.map(|()| None)
|
||||
let mut prepared_config = None;
|
||||
while self
|
||||
.thread_resume_inner(
|
||||
request_id.clone(),
|
||||
¶ms,
|
||||
app_server_client_name.clone(),
|
||||
app_server_client_version.clone(),
|
||||
client_mcp_extensions.clone(),
|
||||
&mut prepared_config,
|
||||
)
|
||||
.await?
|
||||
.is_continue()
|
||||
{}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_fork(
|
||||
@@ -3587,11 +3606,12 @@ impl ThreadRequestProcessor {
|
||||
async fn thread_resume_inner(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ThreadResumeParams,
|
||||
params: &ThreadResumeParams,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
client_mcp_extensions: ClientMcpExtensions,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
prepared_config: &mut Option<PreparedResumeConfig>,
|
||||
) -> Result<ControlFlow<()>, JSONRPCErrorError> {
|
||||
if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id)
|
||||
&& self
|
||||
.pending_thread_unloads
|
||||
@@ -3607,7 +3627,7 @@ impl ThreadRequestProcessor {
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
|
||||
if params.sandbox.is_some() && params.permissions.is_some() {
|
||||
@@ -3617,7 +3637,7 @@ impl ThreadRequestProcessor {
|
||||
invalid_request("`permissions` cannot be combined with `sandbox`"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
let redact_resume_payloads =
|
||||
should_redact_thread_resume_payloads(app_server_client_name.as_deref());
|
||||
@@ -3626,24 +3646,24 @@ impl ThreadRequestProcessor {
|
||||
Ok(permit) => permit,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
};
|
||||
let stored_thread_from_running_probe = match self
|
||||
.resume_running_thread(
|
||||
&request_id,
|
||||
¶ms,
|
||||
params,
|
||||
app_server_client_name.clone(),
|
||||
app_server_client_version.clone(),
|
||||
/*cold_resume_history*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(RunningThreadResumeResult::Handled) => return Ok(()),
|
||||
Ok(RunningThreadResumeResult::Handled) => return Ok(ControlFlow::Break(())),
|
||||
Ok(RunningThreadResumeResult::NotRunning(stored_thread)) => stored_thread,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3666,7 +3686,7 @@ impl ThreadRequestProcessor {
|
||||
personality,
|
||||
exclude_turns,
|
||||
initial_turns_page,
|
||||
} = params;
|
||||
} = params.clone();
|
||||
let include_turns = !exclude_turns;
|
||||
|
||||
let resume_result = if let Some(history) = history {
|
||||
@@ -3697,14 +3717,26 @@ impl ThreadRequestProcessor {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
};
|
||||
if let InitialHistory::Resumed(resumed) = &thread_history
|
||||
&& self
|
||||
.pending_thread_unloads
|
||||
.lock()
|
||||
.await
|
||||
.contains(&resumed.conversation_id)
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"thread {} is closing; retry thread/resume after the thread is closed",
|
||||
resumed.conversation_id
|
||||
)));
|
||||
}
|
||||
let paginated_thread_id = resume_source_thread.as_ref().and_then(|thread| {
|
||||
matches!(thread.history_mode, ThreadHistoryMode::Paginated).then_some(thread.thread_id)
|
||||
});
|
||||
let paginated_resume = paginated_thread_id.is_some();
|
||||
if paginated_resume && include_turns {
|
||||
if paginated_resume && include_turns && prepared_config.is_none() {
|
||||
self.send_deprecation_notice(
|
||||
request_id.connection_id,
|
||||
PAGINATED_FULL_HISTORY_DEPRECATION_SUMMARY,
|
||||
@@ -3750,7 +3782,7 @@ impl ThreadRequestProcessor {
|
||||
)
|
||||
.await?
|
||||
{
|
||||
RunningThreadResumeResult::Handled => Ok(()),
|
||||
RunningThreadResumeResult::Handled => Ok(ControlFlow::Break(())),
|
||||
RunningThreadResumeResult::NotRunning(_) => Err(invalid_request(
|
||||
"cannot resume an unloaded multi-agent v2 sub-agent through its parent; resume the parent first, or use thread/read to inspect it",
|
||||
)),
|
||||
@@ -3803,7 +3835,7 @@ impl ThreadRequestProcessor {
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
};
|
||||
typesafe_overrides.approval_policy = Some(approval_policy);
|
||||
@@ -3818,24 +3850,47 @@ impl ThreadRequestProcessor {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
|
||||
let mut config = match self
|
||||
.config_manager
|
||||
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
let error = config_load_error(&err);
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if !has_explicit_model_resume_override
|
||||
let clear_reasoning_effort = !has_explicit_model_resume_override
|
||||
&& persisted_metadata
|
||||
.as_ref()
|
||||
.is_some_and(|metadata| metadata.reasoning_effort.is_none())
|
||||
{
|
||||
.is_some_and(|metadata| metadata.reasoning_effort.is_none());
|
||||
let config_state = ResumeConfigState {
|
||||
history_cwd: history_cwd.clone(),
|
||||
persisted_metadata,
|
||||
persisted_settings: match &thread_history {
|
||||
InitialHistory::Resumed(resumed) => {
|
||||
latest_persisted_resume_settings(&resumed.history)
|
||||
}
|
||||
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
|
||||
},
|
||||
};
|
||||
let mut config = match prepared_config.take() {
|
||||
Some(prepared) if prepared.state == config_state => prepared.config,
|
||||
_ => {
|
||||
// Config loading can call back into Desktop; release the permit during host work.
|
||||
drop(_thread_list_state_permit);
|
||||
let config = match self
|
||||
.config_manager
|
||||
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.outgoing
|
||||
.send_error(request_id, config_load_error(&err))
|
||||
.await;
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
};
|
||||
*prepared_config = Some(PreparedResumeConfig {
|
||||
state: config_state,
|
||||
config,
|
||||
});
|
||||
// Recheck the thread and history after a possible archive, delete, or aliased resume.
|
||||
return Ok(ControlFlow::Continue(()));
|
||||
}
|
||||
};
|
||||
if clear_reasoning_effort {
|
||||
config.model_reasoning_effort = None;
|
||||
}
|
||||
|
||||
@@ -3866,7 +3921,7 @@ impl ThreadRequestProcessor {
|
||||
.await
|
||||
{
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
let instruction_sources = codex_thread.legacy_instruction_sources().await;
|
||||
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
|
||||
@@ -3874,7 +3929,7 @@ impl ThreadRequestProcessor {
|
||||
let error =
|
||||
internal_error(format!("rollout path missing for thread {thread_id}"));
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
};
|
||||
// Paginated JSONL is canonical, but its SQLite projection can lag after a
|
||||
// previous write failure. Persist after reopening the live writer so legacy
|
||||
@@ -3887,14 +3942,14 @@ impl ThreadRequestProcessor {
|
||||
.map_err(thread_store_resume_read_error)
|
||||
{
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
let materialized_turns = if paginated_resume && include_turns {
|
||||
match self.paginated_thread_full_turns(thread_id).await {
|
||||
Ok(turns) => Some(turns),
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -3929,7 +3984,7 @@ impl ThreadRequestProcessor {
|
||||
self.outgoing
|
||||
.send_error(request_id, internal_error(message))
|
||||
.await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
};
|
||||
thread.thread_source = codex_thread
|
||||
@@ -3965,7 +4020,7 @@ impl ThreadRequestProcessor {
|
||||
Ok(cursors) => cursors,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -3992,7 +4047,7 @@ impl ThreadRequestProcessor {
|
||||
Ok(page) => Some(page),
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return Ok(());
|
||||
return Ok(ControlFlow::Break(()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -4071,7 +4126,7 @@ impl ThreadRequestProcessor {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(ControlFlow::Break(()))
|
||||
}
|
||||
|
||||
async fn load_and_apply_persisted_resume_metadata(
|
||||
|
||||
@@ -195,7 +195,15 @@ impl TestAppServer {
|
||||
/// Closes stdio and waits for app-server's graceful thread teardown to finish.
|
||||
pub async fn shutdown_gracefully(&mut self) -> std::io::Result<ExitStatus> {
|
||||
drop(self.stdin.take());
|
||||
self.process.wait().await
|
||||
// Drain final notifications so a full stdout pipe cannot block runtime shutdown.
|
||||
let mut sink = tokio::io::sink();
|
||||
tokio::select! {
|
||||
status = self.process.wait() => status,
|
||||
drained = tokio::io::copy(&mut self.stdout, &mut sink) => {
|
||||
drained?;
|
||||
self.process.wait().await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the automatically selected test environment retained by this server.
|
||||
|
||||
@@ -1,39 +1,83 @@
|
||||
use std::path::Path;
|
||||
use std::pin::pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::task::Poll;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::MockResponsesConfig;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::create_fake_rollout;
|
||||
use app_test_support::create_mock_responses_server_repeating_assistant;
|
||||
use app_test_support::rollout_path;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use axum::Router;
|
||||
use codex_app_server::in_process;
|
||||
use codex_app_server::in_process::InProcessServerEvent;
|
||||
use codex_app_server::in_process::InProcessStartArgs;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::GitInfo;
|
||||
use codex_app_server_protocol::InitializeCapabilities;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::McpResourceContent;
|
||||
use codex_app_server_protocol::McpResourceReadParams;
|
||||
use codex_app_server_protocol::McpResourceReadResponse;
|
||||
use codex_app_server_protocol::McpServerToolCallParams;
|
||||
use codex_app_server_protocol::McpServerToolCallResponse;
|
||||
use codex_app_server_protocol::ProjectCreateParams;
|
||||
use codex_app_server_protocol::ProjectCreateResponse;
|
||||
use codex_app_server_protocol::ProjectDeleteParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ThreadArchiveParams;
|
||||
use codex_app_server_protocol::ThreadDeleteParams;
|
||||
use codex_app_server_protocol::ThreadLoadedListParams;
|
||||
use codex_app_server_protocol::ThreadLoadedListResponse;
|
||||
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
|
||||
use codex_app_server_protocol::ThreadMetadataUpdateParams;
|
||||
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
|
||||
use codex_app_server_protocol::ThreadReadParams;
|
||||
use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadSection;
|
||||
use codex_app_server_protocol::ThreadSectionMoveParams;
|
||||
use codex_app_server_protocol::ThreadSectionMoveResponse;
|
||||
use codex_app_server_protocol::ThreadSetNameParams;
|
||||
use codex_app_server_protocol::ThreadSetNameResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::ThreadConfigContext;
|
||||
use codex_config::ThreadConfigLoader;
|
||||
use codex_config::ThreadConfigLoaderFuture;
|
||||
use codex_config::ThreadConfigSource;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_features::Feature;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_state::PINNED_THREAD_SECTION_ID;
|
||||
use codex_state::PINNED_THREAD_SECTION_NAME;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::responses;
|
||||
use futures::poll;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::CallToolRequestParams;
|
||||
@@ -62,6 +106,7 @@ use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -713,40 +758,12 @@ apps = true
|
||||
#[tokio::test]
|
||||
async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.loader_overrides(loader_overrides.clone())
|
||||
.build()
|
||||
.await?;
|
||||
// This negative-path test does not need the stdio subprocess; keeping it
|
||||
// in-process avoids child-process teardown timing in nextest leak detection.
|
||||
let client = in_process::start(InProcessStartArgs {
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config: Arc::new(config),
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
state_db: None,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
config_warnings: Vec::new(),
|
||||
session_source: SessionSource::Cli,
|
||||
enable_codex_api_key_env: false,
|
||||
initialize: InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: "codex-app-server-tests".to_string(),
|
||||
title: None,
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: None,
|
||||
},
|
||||
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
})
|
||||
let client = start_resource_in_process_client(
|
||||
codex_home.path(),
|
||||
Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response = client
|
||||
@@ -775,6 +792,580 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn metadata_and_mcp_requests_complete_while_unrelated_resume_loads_config() -> Result<()> {
|
||||
let responses_server = responses::start_mock_server().await;
|
||||
let (mcp_server_url, calls, mcp_server_handle) = start_resource_apps_mcp_server().await?;
|
||||
calls.tools_enabled.store(/*val*/ true, Ordering::Relaxed);
|
||||
let codex_home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&responses_server.uri())
|
||||
.with_extra_config(&format!(
|
||||
"[mcp_servers.resource_server]\nurl = \"{mcp_server_url}/api/codex/ps/mcp\""
|
||||
))
|
||||
.write(codex_home.path())?;
|
||||
let thread_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved user message",
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
let resume_cwd = TempDir::new()?;
|
||||
let blocked_resume = Arc::new(BlockedResumeConfig {
|
||||
cwd: AbsolutePathBuf::from_absolute_path(resume_cwd.path())?,
|
||||
matching_loads: AtomicUsize::new(0),
|
||||
entered: Notify::new(),
|
||||
release: Notify::new(),
|
||||
});
|
||||
let client =
|
||||
start_resource_in_process_client(codex_home.path(), blocked_resume.clone()).await?;
|
||||
let sender = client.sender();
|
||||
let ThreadResumeResponse { thread, .. } = serde_json::from_value(
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
sender.request(ClientRequest::ThreadResume {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: ThreadResumeParams {
|
||||
thread_id,
|
||||
cwd: Some(codex_home.path().to_string_lossy().into_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await??
|
||||
.expect("resume persisted target thread"),
|
||||
)?;
|
||||
|
||||
let mut resume = pin!(sender.request(ClientRequest::ThreadResume {
|
||||
request_id: RequestId::Integer(3),
|
||||
params: ThreadResumeParams {
|
||||
thread_id: "00000000-0000-4000-8000-000000000000".to_string(),
|
||||
cwd: Some(resume_cwd.path().to_string_lossy().into_owned()),
|
||||
history: Some(vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Resume this unrelated thread.".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
}));
|
||||
// Submit the request, retaining immediate errors until after cleanup.
|
||||
let mut early_resume = poll!(&mut resume);
|
||||
let request_results = async {
|
||||
// Wait for the slow config load before submitting metadata or MCP work.
|
||||
timeout(DEFAULT_READ_TIMEOUT, blocked_resume.entered.notified())
|
||||
.await
|
||||
.context("unrelated resume did not enter config loading")?;
|
||||
let results = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
futures::try_join!(
|
||||
sender.request(ClientRequest::ThreadSetName {
|
||||
request_id: RequestId::Integer(4),
|
||||
params: ThreadSetNameParams {
|
||||
thread_id: thread.id.clone(),
|
||||
name: "Viewer thread".to_string(),
|
||||
},
|
||||
}),
|
||||
sender.request(ClientRequest::ThreadMetadataUpdate {
|
||||
request_id: RequestId::Integer(5),
|
||||
params: ThreadMetadataUpdateParams {
|
||||
thread_id: thread.id.clone(),
|
||||
project_id: None,
|
||||
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha: None,
|
||||
branch: Some(Some("feature/viewer".to_string())),
|
||||
origin_url: None,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
sender.request(ClientRequest::ThreadSectionMove {
|
||||
request_id: RequestId::Integer(6),
|
||||
params: ThreadSectionMoveParams {
|
||||
thread_id: thread.id.clone(),
|
||||
section_id: Some(PINNED_THREAD_SECTION_ID.to_string()),
|
||||
before_thread_id: None,
|
||||
},
|
||||
}),
|
||||
sender.request(ClientRequest::McpResourceRead {
|
||||
request_id: RequestId::Integer(7),
|
||||
params: McpResourceReadParams {
|
||||
thread_id: Some(thread.id.clone()),
|
||||
origin_call_id: None,
|
||||
server: "resource_server".to_string(),
|
||||
uri: TEST_RESOURCE_URI.to_string(),
|
||||
connector_id: None,
|
||||
},
|
||||
}),
|
||||
sender.request(ClientRequest::McpServerToolCall {
|
||||
request_id: RequestId::Integer(8),
|
||||
params: McpServerToolCallParams {
|
||||
thread_id: thread.id.clone(),
|
||||
server: "resource_server".to_string(),
|
||||
tool: "best_buy_product_search".to_string(),
|
||||
arguments: Some(json!({ "query": "lamps" })),
|
||||
meta: None,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.context("metadata and MCP requests did not finish while unrelated resume was blocked")??;
|
||||
anyhow::Ok(results)
|
||||
}
|
||||
.await;
|
||||
if early_resume.is_pending() {
|
||||
early_resume = poll!(&mut resume);
|
||||
}
|
||||
let resume_pending = early_resume.is_pending();
|
||||
// Always release and drain the blocked resume, including on regression.
|
||||
blocked_resume.release.notify_one();
|
||||
let resume_result = match early_resume {
|
||||
Poll::Pending => timeout(DEFAULT_READ_TIMEOUT, &mut resume).await,
|
||||
Poll::Ready(result) => Ok(result),
|
||||
};
|
||||
let read_result = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
sender.request(ClientRequest::ThreadRead {
|
||||
request_id: RequestId::Integer(9),
|
||||
params: ThreadReadParams {
|
||||
thread_id: thread.id,
|
||||
include_turns: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
client.shutdown().await?;
|
||||
mcp_server_handle.abort();
|
||||
let _ = mcp_server_handle.await;
|
||||
|
||||
let (rename, metadata, section, resource, tool) = request_results?;
|
||||
assert!(resume_pending, "resume completed early");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ThreadSetNameResponse>(rename.expect("set thread name"))?,
|
||||
ThreadSetNameResponse {},
|
||||
);
|
||||
let updated: ThreadMetadataUpdateResponse =
|
||||
serde_json::from_value(metadata.expect("update thread metadata"))?;
|
||||
let expected_git_info = Some(GitInfo {
|
||||
sha: None,
|
||||
branch: Some("feature/viewer".to_string()),
|
||||
origin_url: None,
|
||||
});
|
||||
assert_eq!(updated.thread.git_info, expected_git_info);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ThreadSectionMoveResponse>(section.expect("pin thread"))?,
|
||||
ThreadSectionMoveResponse {},
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<McpResourceReadResponse>(resource.expect("read resource"))?,
|
||||
expected_resource_read_response(),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<McpServerToolCallResponse>(tool.expect("call tool"))?,
|
||||
serde_json::from_value::<McpServerToolCallResponse>(serde_json::to_value(
|
||||
CallToolResult::structured(json!({ "products": [] })),
|
||||
)?)?,
|
||||
);
|
||||
resume_result??.expect("finish unrelated resume");
|
||||
let read: ThreadReadResponse = serde_json::from_value(read_result??.expect("read thread"))?;
|
||||
assert_eq!(
|
||||
(read.thread.name, read.thread.git_info, read.thread.section),
|
||||
(
|
||||
Some("Viewer thread".to_string()),
|
||||
expected_git_info,
|
||||
Some(ThreadSection {
|
||||
id: PINNED_THREAD_SECTION_ID.to_string(),
|
||||
name: PINNED_THREAD_SECTION_NAME.to_string(),
|
||||
appearance: None,
|
||||
}),
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ResumeMutation {
|
||||
GitMetadata,
|
||||
ProjectDeletion,
|
||||
Archive,
|
||||
Delete,
|
||||
DuplicateResume,
|
||||
}
|
||||
|
||||
#[test_case(ResumeMutation::GitMetadata; "git_metadata_and_project_assignment")]
|
||||
#[test_case(ResumeMutation::ProjectDeletion; "project_deletion")]
|
||||
#[test_case(ResumeMutation::Archive; "archive")]
|
||||
#[test_case(ResumeMutation::Delete; "delete")]
|
||||
#[test_case(ResumeMutation::DuplicateResume; "duplicate_resume")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn resume_revalidates_persisted_thread_after_config_load(
|
||||
mutation: ResumeMutation,
|
||||
) -> Result<()> {
|
||||
let responses_server = create_mock_responses_server_repeating_assistant("Ready").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&responses_server.uri()).write(codex_home.path())?;
|
||||
let filename_timestamp = "2025-01-05T12-00-00";
|
||||
let thread_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
filename_timestamp,
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved user message",
|
||||
Some("mock_provider"),
|
||||
matches!(mutation, ResumeMutation::GitMetadata).then(|| {
|
||||
codex_protocol::protocol::GitInfo {
|
||||
commit_hash: None,
|
||||
branch: Some("feature/before-resume".to_string()),
|
||||
repository_url: None,
|
||||
}
|
||||
}),
|
||||
)?;
|
||||
let path = rollout_path(codex_home.path(), filename_timestamp, &thread_id);
|
||||
let resume_cwd = TempDir::new()?;
|
||||
let blocked_resume = Arc::new(BlockedResumeConfig {
|
||||
cwd: AbsolutePathBuf::from_absolute_path(resume_cwd.path())?,
|
||||
matching_loads: AtomicUsize::new(0),
|
||||
entered: Notify::new(),
|
||||
release: Notify::new(),
|
||||
});
|
||||
let mut client =
|
||||
start_resource_in_process_client(codex_home.path(), blocked_resume.clone()).await?;
|
||||
let sender = client.sender();
|
||||
let project_id = if matches!(
|
||||
mutation,
|
||||
ResumeMutation::GitMetadata | ResumeMutation::ProjectDeletion
|
||||
) {
|
||||
let created: ProjectCreateResponse = serde_json::from_value(
|
||||
sender
|
||||
.request(ClientRequest::ProjectCreate {
|
||||
request_id: RequestId::Integer(100),
|
||||
params: ProjectCreateParams {
|
||||
name: "Resume project".to_string(),
|
||||
roots: Vec::new(),
|
||||
metadata: None,
|
||||
idempotency_key: "resume-project".to_string(),
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect("create project"),
|
||||
)?;
|
||||
Some(created.project.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if matches!(mutation, ResumeMutation::ProjectDeletion) {
|
||||
sender
|
||||
.request(ClientRequest::ThreadMetadataUpdate {
|
||||
request_id: RequestId::Integer(101),
|
||||
params: ThreadMetadataUpdateParams {
|
||||
thread_id: thread_id.clone(),
|
||||
project_id: project_id.clone(),
|
||||
git_info: None,
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect("assign project before resume");
|
||||
}
|
||||
let mut resume = pin!(sender.request(ClientRequest::ThreadResume {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: ThreadResumeParams {
|
||||
// A path alias reaches the same persisted thread through a different
|
||||
// request queue, so its canonical-ID mutation can run during config loading.
|
||||
thread_id: "resume-by-path".to_string(),
|
||||
path: Some(path.canonicalize()?),
|
||||
cwd: Some(resume_cwd.path().to_string_lossy().into_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
}));
|
||||
let mut early_resume = poll!(&mut resume);
|
||||
let mutation_request = match mutation {
|
||||
ResumeMutation::GitMetadata => ClientRequest::ThreadMetadataUpdate {
|
||||
request_id: RequestId::Integer(2),
|
||||
params: ThreadMetadataUpdateParams {
|
||||
thread_id: thread_id.clone(),
|
||||
project_id: project_id.clone(),
|
||||
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
||||
sha: None,
|
||||
branch: Some(Some("feature/updated-during-resume".to_string())),
|
||||
origin_url: None,
|
||||
}),
|
||||
},
|
||||
},
|
||||
ResumeMutation::ProjectDeletion => ClientRequest::ProjectDelete {
|
||||
request_id: RequestId::Integer(2),
|
||||
params: ProjectDeleteParams {
|
||||
project_id: project_id.clone().expect("project to delete"),
|
||||
},
|
||||
},
|
||||
ResumeMutation::Archive => ClientRequest::ThreadArchive {
|
||||
request_id: RequestId::Integer(2),
|
||||
params: ThreadArchiveParams {
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
},
|
||||
ResumeMutation::Delete => ClientRequest::ThreadDelete {
|
||||
request_id: RequestId::Integer(2),
|
||||
params: ThreadDeleteParams {
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
},
|
||||
ResumeMutation::DuplicateResume => ClientRequest::ThreadResume {
|
||||
request_id: RequestId::Integer(2),
|
||||
params: ThreadResumeParams {
|
||||
thread_id: thread_id.clone(),
|
||||
cwd: Some(resume_cwd.path().to_string_lossy().into_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
};
|
||||
let mutation_result = async {
|
||||
timeout(DEFAULT_READ_TIMEOUT, blocked_resume.entered.notified())
|
||||
.await
|
||||
.context("resume did not enter config loading")?;
|
||||
let result = timeout(DEFAULT_READ_TIMEOUT, sender.request(mutation_request))
|
||||
.await
|
||||
.context("mutation must complete while resume config loading is blocked")??;
|
||||
anyhow::Ok(result)
|
||||
}
|
||||
.await;
|
||||
if early_resume.is_pending() {
|
||||
early_resume = poll!(&mut resume);
|
||||
}
|
||||
let resume_pending = early_resume.is_pending();
|
||||
blocked_resume.release.notify_one();
|
||||
let resume_result = match early_resume {
|
||||
Poll::Pending => timeout(DEFAULT_READ_TIMEOUT, &mut resume).await,
|
||||
Poll::Ready(result) => Ok(result),
|
||||
};
|
||||
let resume_config_loads = blocked_resume.matching_loads.load(Ordering::Relaxed);
|
||||
let turn_result = match mutation {
|
||||
ResumeMutation::GitMetadata | ResumeMutation::ProjectDeletion => {
|
||||
complete_in_process_turn(&mut client, RequestId::Integer(3), &thread_id).await
|
||||
}
|
||||
ResumeMutation::Archive | ResumeMutation::Delete | ResumeMutation::DuplicateResume => {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let loaded_result = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
sender.request(ClientRequest::ThreadLoadedList {
|
||||
request_id: RequestId::Integer(4),
|
||||
params: ThreadLoadedListParams::default(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
client.shutdown().await?;
|
||||
|
||||
let mutation_response = mutation_result?.expect("mutate persisted thread during resume");
|
||||
assert!(resume_pending, "resume must still be loading config");
|
||||
let resumed = resume_result??;
|
||||
let loaded: ThreadLoadedListResponse =
|
||||
serde_json::from_value(loaded_result??.expect("list loaded threads"))?;
|
||||
assert_eq!(
|
||||
loaded,
|
||||
ThreadLoadedListResponse {
|
||||
data: match mutation {
|
||||
ResumeMutation::Archive | ResumeMutation::Delete => Vec::new(),
|
||||
ResumeMutation::GitMetadata
|
||||
| ResumeMutation::ProjectDeletion
|
||||
| ResumeMutation::DuplicateResume => vec![thread_id.clone()],
|
||||
},
|
||||
next_cursor: None,
|
||||
}
|
||||
);
|
||||
match mutation {
|
||||
ResumeMutation::GitMetadata | ResumeMutation::ProjectDeletion => {
|
||||
let resumed: ThreadResumeResponse =
|
||||
serde_json::from_value(resumed.expect("resume updated thread"))?;
|
||||
let expected_git_info =
|
||||
matches!(mutation, ResumeMutation::GitMetadata).then(|| GitInfo {
|
||||
sha: None,
|
||||
branch: Some("feature/updated-during-resume".to_string()),
|
||||
origin_url: None,
|
||||
});
|
||||
let expected_project_id = if matches!(mutation, ResumeMutation::GitMetadata) {
|
||||
project_id
|
||||
} else {
|
||||
None
|
||||
};
|
||||
assert_eq!(
|
||||
(resumed.thread.git_info, resumed.thread.project_id),
|
||||
(expected_git_info.clone(), expected_project_id.clone())
|
||||
);
|
||||
assert!(
|
||||
resume_config_loads >= 2,
|
||||
"changed persisted metadata must reload config"
|
||||
);
|
||||
turn_result?;
|
||||
|
||||
// Check persisted metadata after the resumed writer appends a turn
|
||||
// and shuts down; an in-memory response alone can hide stale writes.
|
||||
let reader = start_resource_in_process_client(
|
||||
codex_home.path(),
|
||||
Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
)
|
||||
.await?;
|
||||
let read_result = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
reader.request(ClientRequest::ThreadRead {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: ThreadReadParams {
|
||||
thread_id,
|
||||
include_turns: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
reader.shutdown().await?;
|
||||
let read: ThreadReadResponse =
|
||||
serde_json::from_value(read_result??.expect("read persisted thread metadata"))?;
|
||||
assert_eq!(
|
||||
(read.thread.git_info, read.thread.project_id),
|
||||
(expected_git_info, expected_project_id)
|
||||
);
|
||||
}
|
||||
ResumeMutation::Archive | ResumeMutation::Delete => {
|
||||
assert!(resumed.is_err(), "resume must not reopen a removed rollout");
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"resume must not recreate the active rollout"
|
||||
);
|
||||
let archived_path = codex_home
|
||||
.path()
|
||||
.join(ARCHIVED_SESSIONS_SUBDIR)
|
||||
.join(path.file_name().expect("rollout filename"));
|
||||
assert_eq!(
|
||||
archived_path.exists(),
|
||||
matches!(mutation, ResumeMutation::Archive)
|
||||
);
|
||||
}
|
||||
ResumeMutation::DuplicateResume => {
|
||||
let resumed: ThreadResumeResponse =
|
||||
serde_json::from_value(resumed.expect("rejoin thread resumed during config load"))?;
|
||||
let concurrent: ThreadResumeResponse = serde_json::from_value(mutation_response)?;
|
||||
assert_eq!(
|
||||
(resumed.thread.id, concurrent.thread.id),
|
||||
(thread_id.clone(), thread_id)
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn complete_in_process_turn(
|
||||
client: &mut in_process::InProcessClientHandle,
|
||||
request_id: RequestId,
|
||||
thread_id: &str,
|
||||
) -> Result<()> {
|
||||
timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
client
|
||||
.request(ClientRequest::TurnStart {
|
||||
request_id,
|
||||
params: TurnStartParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "Persist the thread.".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect("start persistence turn");
|
||||
loop {
|
||||
let event = client
|
||||
.next_event()
|
||||
.await
|
||||
.context("app-server stopped before turn/completed")?;
|
||||
if let InProcessServerEvent::ServerNotification(notification) = event
|
||||
&& let ServerNotification::TurnCompleted(completed) = notification.as_ref()
|
||||
&& completed.thread_id == thread_id
|
||||
{
|
||||
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
struct BlockedResumeConfig {
|
||||
cwd: AbsolutePathBuf,
|
||||
matching_loads: AtomicUsize,
|
||||
entered: Notify,
|
||||
release: Notify,
|
||||
}
|
||||
|
||||
impl ThreadConfigLoader for BlockedResumeConfig {
|
||||
fn load(
|
||||
&self,
|
||||
context: ThreadConfigContext,
|
||||
) -> ThreadConfigLoaderFuture<'_, Vec<ThreadConfigSource>> {
|
||||
Box::pin(async move {
|
||||
if context.cwd.as_ref() == Some(&self.cwd)
|
||||
&& self.matching_loads.fetch_add(/*val*/ 1, Ordering::Relaxed) == 0
|
||||
{
|
||||
self.entered.notify_one();
|
||||
self.release.notified().await;
|
||||
}
|
||||
Ok(Vec::new())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_resource_in_process_client(
|
||||
codex_home: &Path,
|
||||
thread_config_loader: Arc<dyn ThreadConfigLoader>,
|
||||
) -> Result<in_process::InProcessClientHandle> {
|
||||
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
// Keep unrelated plugin repository syncs out of these in-process RPC tests.
|
||||
let cli_overrides = vec![("features.plugins".to_string(), toml::Value::Boolean(false))];
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.to_path_buf()))
|
||||
.cli_overrides(cli_overrides.clone())
|
||||
.loader_overrides(loader_overrides.clone())
|
||||
.build()
|
||||
.await?;
|
||||
let state_db = codex_rollout::state_db::try_init(&config).await?;
|
||||
// Metadata, persistence, and HTTP MCP do not need local execution or shell startup.
|
||||
let environment_manager = Arc::new(EnvironmentManager::without_environments(
|
||||
config.http_client_factory(),
|
||||
));
|
||||
Ok(in_process::start(InProcessStartArgs {
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config: Arc::new(config),
|
||||
cli_overrides,
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader,
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
state_db: Some(state_db),
|
||||
environment_manager,
|
||||
config_warnings: Vec::new(),
|
||||
session_source: SessionSource::Cli,
|
||||
enable_codex_api_key_env: false,
|
||||
initialize: InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: "codex-app-server-tests".to_string(),
|
||||
title: None,
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub(super) async fn start_resource_test_app_server(
|
||||
apps_server_url: &str,
|
||||
responses_server_uri: &str,
|
||||
|
||||
@@ -370,7 +370,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_thread_resume_reuses_non_local_history_probe() -> Result<()> {
|
||||
async fn cold_thread_resume_rechecks_non_local_history_after_config_load() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let store_id = Uuid::new_v4().to_string();
|
||||
@@ -432,7 +432,8 @@ async fn cold_thread_resume_reuses_non_local_history_probe() -> Result<()> {
|
||||
let client = start_in_process_client(config, loader_overrides).await?;
|
||||
let reads_before_resume = thread_store.calls().await.read_thread_with_history;
|
||||
// The in-memory store is pathless, so resume currently fails later while
|
||||
// assembling the response. The history-bearing probe must still be reused.
|
||||
// assembling the response. Reuse the probe within each attempt, but read it
|
||||
// again after loading configuration without the metadata permit.
|
||||
let _resume_result = client
|
||||
.request(ClientRequest::ThreadResume {
|
||||
request_id: RequestId::Integer(3),
|
||||
@@ -443,12 +444,9 @@ async fn cold_thread_resume_reuses_non_local_history_probe() -> Result<()> {
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
thread_store.calls().await.read_thread_with_history,
|
||||
reads_before_resume + 1
|
||||
);
|
||||
|
||||
let reads_after_resume = thread_store.calls().await.read_thread_with_history;
|
||||
client.shutdown().await?;
|
||||
assert_eq!(reads_after_resume, reads_before_resume + 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user