mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
## Why Child startup can lose remote model capabilities such as `tool_search` when the selected catalog is uninitialized or disk data is older than the parent's in-memory snapshot. ## What changed - Initialize the final provider, auth, and originator catalog before resolving a child's startup model, then read it from memory. - Preserve loaded catalogs, including empty responses and updates that failed to persist, without overwriting concurrent refreshes. - Reuse catalogs across retry and WebSocket transport overrides while retaining routing and header distinctions. ## Testing Add regression coverage for child tool search with warm, cold, and disabled caches; failed cache writes; concurrent refreshes; empty catalogs; and Guardian reviewer catalog reuse. GitOrigin-RevId: 01d995a65b623cc7d6f26e2dc9b6e8cf380de782
4251 lines
154 KiB
Rust
4251 lines
154 KiB
Rust
#![cfg(test)]
|
|
|
|
use super::*;
|
|
use crate::ThreadStoreBuilder;
|
|
use crate::agent::types::SpawnAgentOptions;
|
|
use crate::config::RolloutBudgetConfig;
|
|
use crate::config::ThreadStoreConfig;
|
|
use crate::config::test_config;
|
|
use crate::init_state_db;
|
|
use crate::installation_id::INSTALLATION_ID_FILENAME;
|
|
use crate::mcp::McpEnvironmentScope;
|
|
use crate::mcp::McpThreadIdentity;
|
|
use crate::rollout::RolloutRecorder;
|
|
use crate::session::session::SessionSettingsUpdate;
|
|
use crate::session::step_context::StepContext;
|
|
use crate::session::tests::build_world_state_from_turn_context;
|
|
use crate::session::tests::make_session_and_context;
|
|
use crate::tasks::InterruptedTurnHistoryMarker;
|
|
use crate::tasks::interrupted_turn_history_marker;
|
|
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
|
use codex_config::ConfigLayerStack;
|
|
use codex_execpolicy::Decision;
|
|
use codex_execpolicy::NetworkRuleProtocol;
|
|
use codex_extension_api::SessionIsolation;
|
|
use codex_extension_api::empty_extension_registry;
|
|
use codex_history::InitialHistory;
|
|
use codex_history::ResumedHistory;
|
|
use codex_models_manager::manager::ModelCatalogSettings;
|
|
use codex_models_manager::manager::RefreshStrategy;
|
|
use codex_protocol::ResponseItemId;
|
|
use codex_protocol::approvals::ExecPolicyAmendment;
|
|
use codex_protocol::capabilities::CapabilityRootLocation;
|
|
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
|
use codex_protocol::error::CodexErrKind;
|
|
use codex_protocol::error::CodexErrorDetails;
|
|
use codex_protocol::mcp::ClientMcpExtensions;
|
|
use codex_protocol::mcp::MCP_APP_UI_EXTENSION_ID;
|
|
use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ContentItemKind;
|
|
use codex_protocol::models::ContentItemMetadata;
|
|
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
|
|
use codex_protocol::models::ReasoningItemReasoningSummary;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_protocol::protocol::AgentMessageEvent;
|
|
use codex_protocol::protocol::EnvironmentConfigState;
|
|
use codex_protocol::protocol::InternalSessionSource;
|
|
use codex_protocol::protocol::SessionMeta;
|
|
use codex_protocol::protocol::SessionMetaLine;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use codex_protocol::protocol::ThreadSource;
|
|
use codex_protocol::protocol::TokenUsage;
|
|
use codex_protocol::protocol::TurnStartedEvent;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
use codex_protocol::user_input::UserInput;
|
|
use codex_thread_store::InMemoryThreadStore;
|
|
use codex_utils_path_uri::PathUri;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::PathExt;
|
|
use core_test_support::responses::mount_models_once;
|
|
use core_test_support::responses::strip_response_item_ids_from_json;
|
|
use pretty_assertions::assert_eq;
|
|
use std::time::Duration;
|
|
use tempfile::tempdir;
|
|
use wiremock::MockServer;
|
|
|
|
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
|
|
|
|
struct ParentInstructionsProvider(codex_extension_api::Instructions);
|
|
|
|
impl codex_extension_api::UserInstructionsProvider for ParentInstructionsProvider {
|
|
fn load_user_instructions(&self) -> codex_extension_api::LoadInstructionsFuture<'_> {
|
|
Box::pin(async move {
|
|
codex_extension_api::LoadedUserInstructions {
|
|
instructions: Some(self.0.clone()),
|
|
warnings: Vec::new(),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl codex_extension_api::ThreadInstructionsProvider for ParentInstructionsProvider {
|
|
fn load_thread_instructions(&self) -> codex_extension_api::LoadInstructionsFuture<'_> {
|
|
codex_extension_api::UserInstructionsProvider::load_user_instructions(self)
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn live_fork_keeps_instructions_when_source_is_unloaded_during_setup() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
let global = codex_extension_api::Instructions {
|
|
text: "global instructions".to_string(),
|
|
source: None,
|
|
};
|
|
let thread = codex_extension_api::Instructions {
|
|
text: "source thread instructions".to_string(),
|
|
source: None,
|
|
};
|
|
let mut manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
Arc::get_mut(&mut manager.state)
|
|
.expect("unshared manager")
|
|
.user_instructions_provider = Arc::new(ParentInstructionsProvider(global.clone()));
|
|
let source = manager
|
|
.start_thread(StartThreadOptions {
|
|
environments: Some(Vec::new()),
|
|
thread_instructions_provider: Some(Arc::new(ParentInstructionsProvider(
|
|
thread.clone(),
|
|
))),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start source");
|
|
let history = InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
conversation_id: source.thread_id,
|
|
history: Arc::new(Vec::new()),
|
|
rollout_path: None,
|
|
});
|
|
|
|
// Queue removal between the first source lookup and subsequent startup
|
|
// lookups. Tokio's fair RwLock makes this ordering deterministic.
|
|
let fork = manager.fork_thread_from_history(
|
|
ForkSnapshot::Interrupted,
|
|
StartThreadOptions {
|
|
environments: Some(Vec::new()),
|
|
..StartThreadOptions::new(config)
|
|
},
|
|
history,
|
|
);
|
|
tokio::pin!(fork);
|
|
{
|
|
let _guard = manager.state.threads.write().await;
|
|
let mut context = std::task::Context::from_waker(std::task::Waker::noop());
|
|
assert!(std::future::Future::poll(fork.as_mut(), &mut context).is_pending());
|
|
}
|
|
let removal = manager.remove_thread_if_matches(&source.thread_id, &source.thread);
|
|
tokio::pin!(removal);
|
|
assert!(futures::poll!(&mut removal).is_pending());
|
|
assert!(futures::poll!(&mut fork).is_pending());
|
|
assert!(removal.await.is_some());
|
|
let fork = fork.await.expect("fork survives source removal");
|
|
let instructions = fork.thread.session.inherited_instructions().await;
|
|
assert_eq!(
|
|
(instructions.user, instructions.thread),
|
|
(Some(global), Some(thread))
|
|
);
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source");
|
|
fork.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown fork");
|
|
}
|
|
|
|
async fn startup_fixture() -> (tempfile::TempDir, Config, ThreadManager) {
|
|
let home = tempdir().unwrap();
|
|
let mut config = test_config().await;
|
|
config.codex_home = home.path().abs();
|
|
config.cwd = config.codex_home.abs();
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
(home, config, manager)
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "hold startup before registration while the submission loop applies settings"
|
|
)]
|
|
async fn shared_service_tier_is_published_by_registered_startup() {
|
|
let (_home, mut config, manager) = startup_fixture().await;
|
|
config.ephemeral = true;
|
|
let thread_id = ThreadId::new();
|
|
let controller = Arc::new(LocalAgentControl::default().with_session_id(
|
|
codex_protocol::SessionId::from(thread_id),
|
|
/*max_threads*/ usize::MAX,
|
|
));
|
|
let manager = manager.with_agent_control_factory({
|
|
let controller: Arc<dyn AgentControl> = controller.clone();
|
|
move |_| {
|
|
let controller = Arc::clone(&controller);
|
|
async move { Ok(controller) }
|
|
}
|
|
});
|
|
|
|
for (initial_tier, updated_tier, previous_shared_tier) in [
|
|
("flex", None, None),
|
|
("default", Some("flex".into()), Some("default")),
|
|
] {
|
|
config.service_tier = Some(initial_tier.into());
|
|
let startup = Arc::new(SessionStartup::default());
|
|
// An ephemeral startup skips persistence until the submission loop is live.
|
|
let persistence = startup.persistence.lock().await;
|
|
let mut start = std::pin::pin!(manager.start_thread_inner(
|
|
StartThreadOptions {
|
|
reserved_thread_id: Some(thread_id),
|
|
..StartThreadOptions::new(config.clone())
|
|
},
|
|
/*forked_from_thread_id*/ None,
|
|
Some(Arc::clone(&startup)),
|
|
));
|
|
let io = std::future::poll_fn(|cx| {
|
|
assert!(start.as_mut().poll(cx).is_pending());
|
|
startup
|
|
.io
|
|
.get()
|
|
.map_or(std::task::Poll::Pending, std::task::Poll::Ready)
|
|
})
|
|
.await;
|
|
let (reply, applied) = tokio::sync::oneshot::channel();
|
|
io.submit(Op::ThreadSettings {
|
|
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
|
|
service_tier: Some(updated_tier),
|
|
..Default::default()
|
|
},
|
|
reply: Some(reply),
|
|
})
|
|
.await
|
|
.expect("submit settings before registration");
|
|
applied
|
|
.await
|
|
.expect("receive settings reply")
|
|
.expect("apply settings");
|
|
assert_eq!(
|
|
controller.root_service_tier().as_deref(),
|
|
previous_shared_tier
|
|
);
|
|
|
|
drop(persistence);
|
|
let started = start.await;
|
|
if previous_shared_tier.is_some() {
|
|
let error = started.err().expect("reject the duplicate runtime");
|
|
assert!(
|
|
matches!(error.details(), CodexErrorDetails::InvalidRequest(message)
|
|
if *message == format!("thread {thread_id} is already running"))
|
|
);
|
|
startup.cleanup().await;
|
|
} else {
|
|
started.expect("register the first runtime");
|
|
}
|
|
assert_eq!(controller.root_service_tier().as_deref(), Some("default"));
|
|
}
|
|
|
|
manager
|
|
.get_thread(thread_id)
|
|
.await
|
|
.expect("find registered thread")
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("stop registered thread");
|
|
}
|
|
|
|
fn managed_stack(requirements: Option<RequirementsExecPolicy>) -> ConfigLayerStack {
|
|
let config = codex_config::ConfigRequirements {
|
|
exec_policy: requirements.map(|policy| {
|
|
codex_config::Sourced::new(policy, codex_config::RequirementSource::Unknown)
|
|
}),
|
|
..Default::default()
|
|
};
|
|
ConfigLayerStack::new(Vec::new(), config, Default::default())
|
|
.unwrap()
|
|
.with_user_and_project_exec_policy_rules_ignored(
|
|
/*ignore_user_and_project_exec_policy_rules*/ true,
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn child_and_grandchild_inherit_session_amendments_and_managed_rules() {
|
|
let (home, mut config, manager) = startup_fixture().await;
|
|
let mut managed = Policy::empty();
|
|
managed
|
|
.add_prefix_rule(&["rm".into()], Decision::Forbidden)
|
|
.unwrap();
|
|
let requirements = RequirementsExecPolicy::new(managed);
|
|
config.config_layer_stack = managed_stack(Some(requirements));
|
|
let source = ExecPolicyManager::load(&config.config_layer_stack)
|
|
.await
|
|
.unwrap();
|
|
source
|
|
.append_amendment_and_update(
|
|
home.path(),
|
|
&ExecPolicyAmendment::new(vec!["git".into(), "status".into()]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let (mut policy, mut source_requirements) = source.snapshot();
|
|
// The child forwards its installed snapshot when it starts the grandchild.
|
|
for _ in 0..2 {
|
|
let mut options = StartThreadOptions::new(config.clone());
|
|
options.set_inherited_exec_policy(policy, source_requirements);
|
|
let runtime = manager.start_thread(options).await.unwrap();
|
|
(policy, source_requirements) = runtime.thread.session.services.exec_policy.snapshot();
|
|
assert!(policy.rules().contains_key("git"));
|
|
assert!(policy.rules().contains_key("rm"));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn policy_transfer_rejects_changed_sources_and_weakened_destination_rules() {
|
|
let mut managed = Policy::empty();
|
|
managed
|
|
.add_prefix_rule(&["git".into()], Decision::Forbidden)
|
|
.unwrap();
|
|
managed
|
|
.add_network_rule(
|
|
"example.org",
|
|
NetworkRuleProtocol::Https,
|
|
Decision::Forbidden,
|
|
/*justification*/ None,
|
|
)
|
|
.unwrap();
|
|
let requirements = RequirementsExecPolicy::new(managed.clone());
|
|
let stack = managed_stack(Some(requirements.clone()));
|
|
let require_rejection = async |policy, stack: &ConfigLayerStack| {
|
|
let snapshot = InheritedExecPolicy {
|
|
policy: Arc::new(policy),
|
|
source_requirements: Some(Arc::new(requirements.clone())),
|
|
};
|
|
let result = ExecPolicyManager::from_inherited(&snapshot, stack).await;
|
|
assert_eq!(
|
|
result.as_ref().err().map(CodexErrKind::from),
|
|
Some(CodexErrKind::InvalidRequest)
|
|
);
|
|
};
|
|
require_rejection(Policy::empty(), &stack).await;
|
|
require_rejection(managed.clone(), &managed_stack(/*requirements*/ None)).await;
|
|
require_rejection(
|
|
managed.clone(),
|
|
&stack
|
|
.clone()
|
|
.with_user_and_project_exec_policy_rules_ignored(
|
|
/*ignore_user_and_project_exec_policy_rules*/ false,
|
|
),
|
|
)
|
|
.await;
|
|
let mut different_network = managed.clone();
|
|
different_network
|
|
.add_network_rule(
|
|
"another.org",
|
|
NetworkRuleProtocol::Https,
|
|
Decision::Forbidden,
|
|
/*justification*/ None,
|
|
)
|
|
.unwrap();
|
|
require_rejection(
|
|
managed.clone(),
|
|
&managed_stack(Some(RequirementsExecPolicy::new(different_network))),
|
|
)
|
|
.await;
|
|
let mut weakened = managed.clone();
|
|
weakened
|
|
.add_network_rule(
|
|
"example.org",
|
|
NetworkRuleProtocol::Https,
|
|
Decision::Allow,
|
|
/*justification*/ None,
|
|
)
|
|
.unwrap();
|
|
require_rejection(weakened, &stack).await;
|
|
if cfg!(unix) {
|
|
managed
|
|
.add_prefix_rule(&["/usr/bin/git".into()], Decision::Allow)
|
|
.unwrap();
|
|
require_rejection(managed, &stack).await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fixed_thread_text_supersedes_the_live_provider_and_isolated_helpers_reject_inheritance() {
|
|
let (_home, config, manager) = startup_fixture().await;
|
|
let instructions = |text: &str| Instructions {
|
|
text: text.to_owned(),
|
|
source: None,
|
|
};
|
|
for (fixed, expected) in [
|
|
(None, Some("live")),
|
|
(Some("captured"), Some("captured")),
|
|
(Some(""), None),
|
|
] {
|
|
let mut options = StartThreadOptions::new(config.clone());
|
|
options.thread_instructions_provider =
|
|
Some(Arc::new(ParentInstructionsProvider(instructions("live"))));
|
|
if let Some(text) = fixed {
|
|
options.set_inherited_thread_instructions(instructions(text));
|
|
}
|
|
let runtime = manager.start_thread(options).await.unwrap();
|
|
let session = &runtime.thread.session;
|
|
let (result, _) = session
|
|
.services
|
|
.agents_md_manager
|
|
.refresh(&config, &TurnEnvironmentSnapshot::default())
|
|
.await;
|
|
result.unwrap();
|
|
let inherited = session.inherited_instructions().await;
|
|
assert_eq!(inherited.thread, expected.map(instructions));
|
|
}
|
|
let mut with_policy = StartThreadOptions::new(config.clone());
|
|
with_policy.config.config_layer_stack = managed_stack(/*requirements*/ None);
|
|
with_policy
|
|
.set_inherited_exec_policy(Arc::new(Policy::empty()), /*source_requirements*/ None);
|
|
let mut with_instructions = StartThreadOptions::new(config);
|
|
with_instructions.set_inherited_thread_instructions(instructions("captured"));
|
|
for mut isolated in [with_policy, with_instructions] {
|
|
isolated
|
|
.thread_extension_init
|
|
.insert(SessionIsolation::Isolated);
|
|
let result = manager.start_thread(isolated).await;
|
|
assert_eq!(
|
|
result.as_ref().err().map(CodexErrKind::from),
|
|
Some(CodexErrKind::InvalidRequest)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test_case::test_case(None, "chatgpt.com", 1; "ordinary_openai")]
|
|
#[test_case::test_case(Some(codex_features::RuntimeAuthMode::DualHeader), "chatgpt.com", 0; "dual_header_openai")]
|
|
#[test_case::test_case(Some(codex_features::RuntimeAuthMode::AssertionOnly), "chatgpt.com", 0; "assertion_only_openai")]
|
|
#[test_case::test_case(Some(codex_features::RuntimeAuthMode::DualHeader), "example.test", 1; "dual_header_custom_backend")]
|
|
#[test_case::test_case(Some(codex_features::RuntimeAuthMode::AssertionOnly), "example.test", 1; "assertion_only_custom_backend")]
|
|
#[tokio::test]
|
|
async fn resumed_analytics_validates_openai_destination(
|
|
mode: Option<codex_features::RuntimeAuthMode>,
|
|
host: &str,
|
|
expected_requests: usize,
|
|
) {
|
|
let proxy = MockServer::start().await;
|
|
wiremock::Mock::given(wiremock::matchers::path("/codex/analytics-events/events"))
|
|
.respond_with(wiremock::ResponseTemplate::new(200))
|
|
.mount(&proxy)
|
|
.await;
|
|
let home = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = home.path().abs();
|
|
config.cwd = home.path().abs();
|
|
config.chatgpt_base_url = format!("http://{host}:{}", proxy.address().port());
|
|
config.model_provider.base_url = Some(proxy.uri());
|
|
config.respect_system_proxy = true;
|
|
config.cli_auth_credentials_store_mode = codex_login::AuthCredentialsStoreMode::File;
|
|
config.analytics_enabled = Some(true);
|
|
if let Some(mode) = mode {
|
|
config
|
|
.features
|
|
.enable(Feature::RuntimeAgentInstances)
|
|
.unwrap();
|
|
config.runtime_auth_mode = mode;
|
|
} else {
|
|
config
|
|
.features
|
|
.disable(Feature::RuntimeAgentInstances)
|
|
.unwrap();
|
|
}
|
|
codex_http_client::cache_system_proxy_route_for_test(
|
|
&format!("{}/codex/analytics-events/events", config.chatgpt_base_url),
|
|
proxy.uri(),
|
|
);
|
|
codex_login::login_with_api_key(
|
|
&config.codex_home,
|
|
"resumed-key",
|
|
codex_login::AuthCredentialsStoreMode::File,
|
|
codex_login::AuthKeyringBackendKind::default(),
|
|
)
|
|
.unwrap();
|
|
let resumed_auth =
|
|
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false)
|
|
.await
|
|
.unwrap();
|
|
let original_auth = CodexAuth::from_api_key("startup-key");
|
|
let mut manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
original_auth.clone(),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let startup_analytics = AnalyticsEventsClient::new(
|
|
AuthManager::from_auth_for_testing(original_auth),
|
|
proxy.uri(),
|
|
/*analytics_enabled*/ Some(true),
|
|
);
|
|
Arc::get_mut(&mut manager.state)
|
|
.expect("unshared thread manager state")
|
|
.analytics_events_client = Some(startup_analytics);
|
|
|
|
let original = manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start original thread");
|
|
original.thread.ensure_rollout_materialized().await;
|
|
original.thread.flush_rollout().await.unwrap();
|
|
original.thread.shutdown_and_wait().await.unwrap();
|
|
manager.remove_thread(&original.thread_id).await;
|
|
|
|
let resumed = manager
|
|
.resume_thread_with_history(
|
|
config,
|
|
InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
conversation_id: original.thread_id,
|
|
history: Arc::new(Vec::new()),
|
|
rollout_path: original.thread.rollout_path(),
|
|
}),
|
|
resumed_auth,
|
|
/*parent_trace*/ None,
|
|
ClientMcpExtensions::default(),
|
|
)
|
|
.await
|
|
.expect("resume with another auth owner");
|
|
let analytics = &resumed.thread.session.services.analytics_events_client;
|
|
analytics.track_plugin_used(
|
|
codex_analytics::TrackEventsContext {
|
|
turn_metadata: None,
|
|
model_slug: "test-model".to_string(),
|
|
turn_id: "test-turn".to_string(),
|
|
thread_id: resumed.thread_id.to_string(),
|
|
product_client_id: "codex_work_cca".to_string(),
|
|
},
|
|
codex_plugin::PluginTelemetryMetadata {
|
|
plugin_id: Some(codex_plugin::PluginId::parse("test@local").expect("valid plugin ID")),
|
|
remote_plugin_id: None,
|
|
capability_summary: None,
|
|
},
|
|
);
|
|
analytics.flush().await;
|
|
resumed.thread.shutdown_and_wait().await.unwrap();
|
|
|
|
let requests: Vec<_> = proxy
|
|
.received_requests()
|
|
.await
|
|
.unwrap()
|
|
.into_iter()
|
|
.filter(|request| request.url.path() == "/codex/analytics-events/events")
|
|
.collect();
|
|
assert_eq!(requests.len(), expected_requests);
|
|
for request in requests {
|
|
assert_eq!(request.headers["authorization"], "Bearer resumed-key");
|
|
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
|
|
}
|
|
}
|
|
|
|
/// A thread opt-out wins over a shared client without disabling its siblings.
|
|
#[tokio::test]
|
|
async fn thread_analytics_opt_out_overrides_shared_client() {
|
|
let server = MockServer::start().await;
|
|
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
|
.and(wiremock::matchers::path("/codex/analytics-events/events"))
|
|
.respond_with(wiremock::ResponseTemplate::new(200))
|
|
.mount(&server)
|
|
.await;
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.chatgpt_base_url = server.uri();
|
|
config.model_provider.base_url = Some(server.uri());
|
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
let shared_client = AnalyticsEventsClient::new(
|
|
AuthManager::from_auth_for_testing(auth.clone()),
|
|
server.uri(),
|
|
/*analytics_enabled*/ Some(true),
|
|
);
|
|
let mut expected_thread_ids = Vec::new();
|
|
let mut opted_out_thread_ids = Vec::new();
|
|
|
|
for (name, client_override, expected_enabled) in [
|
|
(
|
|
"enabled_override",
|
|
Some(shared_client.clone()),
|
|
[false, true, true],
|
|
),
|
|
(
|
|
"disabled_override",
|
|
Some(AnalyticsEventsClient::disabled()),
|
|
[false, false, false],
|
|
),
|
|
("no_override", None, [false, true, true]),
|
|
] {
|
|
config.codex_home = temp_dir.path().join(name).abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
let mut manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
auth.clone(),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
Arc::get_mut(&mut manager.state)
|
|
.expect("unshared thread manager state")
|
|
.analytics_events_client = client_override;
|
|
|
|
for (setting, enabled) in [Some(false), Some(true), None]
|
|
.into_iter()
|
|
.zip(expected_enabled)
|
|
{
|
|
config.analytics_enabled = setting;
|
|
let started = manager
|
|
.start_thread(StartThreadOptions::new(config.clone()))
|
|
.await
|
|
.expect("start analytics test thread");
|
|
let services = &started.thread.session.services;
|
|
assert_eq!(started.thread.analytics_enabled(), enabled);
|
|
assert_eq!(
|
|
services
|
|
.session_extension_data
|
|
.get::<AnalyticsEventsClient>()
|
|
.expect("analytics client in session store")
|
|
.is_enabled(),
|
|
enabled,
|
|
);
|
|
let thread_id = started.thread_id.to_string();
|
|
if enabled {
|
|
expected_thread_ids.push(thread_id.clone());
|
|
} else {
|
|
opted_out_thread_ids.push(thread_id.clone());
|
|
}
|
|
services.analytics_events_client.track_app_used(
|
|
codex_analytics::TrackEventsContext {
|
|
turn_metadata: None,
|
|
model_slug: "test-model".to_string(),
|
|
turn_id: format!("test-turn-{thread_id}"),
|
|
thread_id,
|
|
product_client_id: "codex_work_cca".to_string(),
|
|
},
|
|
codex_analytics::AppInvocation {
|
|
connector_id: Some("test-connector".to_string()),
|
|
app_name: None,
|
|
invocation_type: None,
|
|
},
|
|
/*elicitation_type*/ None,
|
|
);
|
|
services.analytics_events_client.flush().await;
|
|
}
|
|
let shutdown = manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
assert_eq!(shutdown.completed.len(), 3);
|
|
}
|
|
|
|
let events: Vec<serde_json::Value> = server
|
|
.received_requests()
|
|
.await
|
|
.expect("analytics requests")
|
|
.into_iter()
|
|
.filter(|request| request.url.path() == "/codex/analytics-events/events")
|
|
.flat_map(|request| {
|
|
request.body_json::<serde_json::Value>().expect("JSON body")["events"]
|
|
.as_array()
|
|
.expect("events array")
|
|
.clone()
|
|
})
|
|
.collect();
|
|
assert!(events.iter().all(|event| {
|
|
!opted_out_thread_ids
|
|
.iter()
|
|
.any(|thread_id| event["event_params"]["thread_id"] == thread_id.as_str())
|
|
}));
|
|
let mut actual_thread_ids: Vec<String> = events
|
|
.iter()
|
|
.filter(|event| event["event_type"] == "codex_app_used")
|
|
.map(|event| {
|
|
event["event_params"]["thread_id"]
|
|
.as_str()
|
|
.expect("app usage thread ID")
|
|
.to_string()
|
|
})
|
|
.collect();
|
|
actual_thread_ids.sort();
|
|
expected_thread_ids.sort();
|
|
assert_eq!(actual_thread_ids, expected_thread_ids);
|
|
}
|
|
|
|
/// Controls without a custom allocation policy still produce distinct thread identifiers.
|
|
#[test]
|
|
fn thread_id_generator_defaults_to_standard_ids() {
|
|
let agent_control = LocalAgentControl::default();
|
|
|
|
assert_ne!(
|
|
agent_control.runtime.generate_thread_id(),
|
|
agent_control.runtime.generate_thread_id()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reserved_thread_id_is_used_without_changing_normal_id_generation() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let generated_ids = [
|
|
ThreadId::from_u128(/*value*/ 0x018f_0000_0000_7000_8000_0000_0000_0001),
|
|
ThreadId::from_u128(/*value*/ 0x018f_0000_0000_7000_8000_0000_0000_0002),
|
|
ThreadId::from_u128(/*value*/ 0x018f_0000_0000_7000_8000_0000_0000_0003),
|
|
];
|
|
let next_id = std::sync::atomic::AtomicUsize::new(0);
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
)
|
|
.with_thread_id_generator(move || generated_ids[next_id.fetch_add(1, Ordering::Relaxed)]);
|
|
|
|
let reserved_id = manager.reserve_thread_id();
|
|
let mut reserved_options = StartThreadOptions::new(config.clone());
|
|
reserved_options.reserved_thread_id = Some(reserved_id);
|
|
let reserved = manager
|
|
.start_thread(reserved_options)
|
|
.await
|
|
.expect("start reserved thread");
|
|
let mut resumed_options = StartThreadOptions::new(config.clone());
|
|
resumed_options.initial_history = InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
conversation_id: reserved.thread_id,
|
|
history: Arc::new(Vec::new()),
|
|
rollout_path: None,
|
|
});
|
|
let resumed_id = manager.reserve_thread_id();
|
|
resumed_options.reserved_thread_id = Some(resumed_id);
|
|
let resume_error = manager
|
|
.start_thread(resumed_options)
|
|
.await
|
|
.err()
|
|
.expect("reject reserved ID for resume");
|
|
let generated = manager
|
|
.start_thread(StartThreadOptions::new(config.clone()))
|
|
.await
|
|
.expect("start generated thread");
|
|
|
|
assert_eq!(reserved.thread_id, generated_ids[0]);
|
|
assert!(matches!(
|
|
resume_error.details(),
|
|
codex_protocol::error::CodexErrorDetails::InvalidRequest(message)
|
|
if message == "reserved thread ID cannot be used when resuming a thread"
|
|
));
|
|
assert_eq!(generated.thread_id, generated_ids[2]);
|
|
}
|
|
|
|
/// One custom ID factory supplies identifiers for roots, actual child agents, and forks.
|
|
#[tokio::test]
|
|
async fn thread_id_generator_applies_to_roots_children_and_forks() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let generated_ids = [
|
|
ThreadId::from_u128(/*value*/ 0x018f_0000_0000_7000_8000_0000_0000_0001),
|
|
ThreadId::from_u128(/*value*/ 0x018f_0000_0000_7000_8000_0000_0000_0002),
|
|
ThreadId::from_u128(/*value*/ 0x018f_0000_0000_7000_8000_0000_0000_0003),
|
|
];
|
|
let next_id = std::sync::atomic::AtomicUsize::new(0);
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
)
|
|
.with_thread_id_generator(move || generated_ids[next_id.fetch_add(1, Ordering::Relaxed)]);
|
|
let root = manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start root thread");
|
|
let child = root
|
|
.thread
|
|
.session
|
|
.services
|
|
.local_agent_runtime
|
|
.control(root.thread.session.session_id())
|
|
.spawn_agent_with_metadata(
|
|
config.clone(),
|
|
vec![UserInput::Text {
|
|
text: "child task".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
parent_thread_id: root.thread_id,
|
|
depth: 1,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
}),
|
|
SpawnAgentOptions {
|
|
parent_thread_id: Some(root.thread_id),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("spawn actual child agent");
|
|
let fork = manager
|
|
.spawn_legacy_subagent(root.thread_id, StartThreadOptions::new(config))
|
|
.await
|
|
.expect("fork root thread");
|
|
|
|
assert_eq!(
|
|
[root.thread_id, child.thread_id, fork.thread_id],
|
|
generated_ids
|
|
);
|
|
|
|
let report = manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
assert_eq!(report.completed.len(), 3);
|
|
}
|
|
|
|
/// Resuming a thread preserves its stored ID instead of invoking the new manager's factory.
|
|
#[tokio::test]
|
|
async fn thread_id_generator_does_not_replace_resumed_thread_id() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let original_thread_id =
|
|
ThreadId::from_u128(/*value*/ 0x018f_0000_0000_7000_8000_0000_0000_0001);
|
|
let original_manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
)
|
|
.with_thread_id_generator(move || original_thread_id);
|
|
let original = original_manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start source thread");
|
|
original.thread.ensure_rollout_materialized().await;
|
|
original
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush source rollout");
|
|
let rollout_path = original
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
assert_eq!(original.thread_id, original_thread_id);
|
|
original
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shut down source thread");
|
|
let _ = original_manager.remove_thread(&original_thread_id).await;
|
|
|
|
let resumed_manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
)
|
|
.with_thread_id_generator(|| panic!("resuming must not allocate a new thread ID"));
|
|
let resumed = resumed_manager
|
|
.resume_legacy_thread_from_rollout(
|
|
config,
|
|
rollout_path,
|
|
Arc::clone(&resumed_manager.state.auth_manager),
|
|
/*parent_trace*/ None,
|
|
ClientMcpExtensions::default(),
|
|
)
|
|
.await
|
|
.expect("resume existing source thread");
|
|
|
|
assert_eq!(resumed.thread_id, original_thread_id);
|
|
resumed
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shut down resumed thread");
|
|
}
|
|
|
|
#[test_case::test_case(false; "local_child")]
|
|
#[test_case::test_case(true; "host_child")]
|
|
#[tokio::test]
|
|
async fn child_session_inherits_resumed_parent_auth(host_child: bool) {
|
|
let home = tempdir().expect("temporary home");
|
|
let mut config = test_config().await;
|
|
config.codex_home = home.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("key-a"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let original = manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start original thread");
|
|
original.thread.ensure_rollout_materialized().await;
|
|
original
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush original history");
|
|
original
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("stop original thread");
|
|
manager.remove_thread(&original.thread_id).await;
|
|
let replacement = AuthManager::from_auth_for_testing_with_home(
|
|
CodexAuth::from_api_key("key-b"),
|
|
config.codex_home.to_path_buf(),
|
|
);
|
|
let parent = manager
|
|
.resume_thread_with_history(
|
|
config.clone(),
|
|
InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
conversation_id: original.thread_id,
|
|
history: Arc::new(Vec::new()),
|
|
rollout_path: original.thread.rollout_path(),
|
|
}),
|
|
Arc::clone(&replacement),
|
|
/*parent_trace*/ None,
|
|
ClientMcpExtensions::default(),
|
|
)
|
|
.await
|
|
.expect("resume with replacement credentials");
|
|
let options = StartThreadOptions {
|
|
reserved_thread_id: host_child.then(|| manager.reserve_thread_id()),
|
|
session_source: Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
parent_thread_id: parent.thread_id,
|
|
depth: 1,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
})),
|
|
..StartThreadOptions::new(config)
|
|
};
|
|
let control = parent
|
|
.thread
|
|
.session
|
|
.services
|
|
.local_agent_runtime
|
|
.control(parent.thread.session.session_id());
|
|
let child = if host_child {
|
|
manager
|
|
.state
|
|
.spawn_host_child_thread(
|
|
options,
|
|
control.into(),
|
|
parent.thread_id,
|
|
crate::agent::host::PreparedChildInheritance {
|
|
instructions: parent.thread.session.inherited_instructions().await,
|
|
exec_policy: None,
|
|
originator: parent.thread.session.originator().await,
|
|
attachment_source: None,
|
|
},
|
|
)
|
|
.await
|
|
} else {
|
|
manager
|
|
.state
|
|
.spawn_child_thread(
|
|
options,
|
|
control,
|
|
Some(parent.thread_id),
|
|
/*inherited_exec_policy*/ None,
|
|
)
|
|
.await
|
|
}
|
|
.expect("spawn child under the resumed parent");
|
|
|
|
assert!(
|
|
Arc::ptr_eq(&child.thread.session.services.auth_manager, &replacement),
|
|
"child must use the resumed parent's credentials",
|
|
);
|
|
assert!(
|
|
Arc::ptr_eq(
|
|
&child.thread.session.services.runtime_auth,
|
|
&parent.thread.session.services.runtime_auth,
|
|
),
|
|
"child must share the resumed parent's runtime owner",
|
|
);
|
|
assert!(
|
|
!Arc::ptr_eq(
|
|
&child.thread.session.services.runtime_auth,
|
|
&manager.state.runtime_auth
|
|
),
|
|
"replacement-auth descendants must stay separate from the harness runtime",
|
|
);
|
|
let report = manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
assert_eq!(report.completed.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn child_session_inherits_client_mcp_extensions() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let parent = manager
|
|
.start_thread(StartThreadOptions {
|
|
client_mcp_extensions: ClientMcpExtensions::new(HashMap::from([
|
|
(OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({})),
|
|
(
|
|
MCP_APP_UI_EXTENSION_ID.to_string(),
|
|
serde_json::json!({
|
|
"mimeTypes": ["text/html;profile=mcp-app"],
|
|
}),
|
|
),
|
|
])),
|
|
..StartThreadOptions::new(config)
|
|
})
|
|
.await
|
|
.expect("start parent thread");
|
|
|
|
let (client_mcp_extensions, _) = manager
|
|
.state
|
|
.inherited_child_settings(Some(parent.thread_id))
|
|
.await;
|
|
assert_eq!(
|
|
client_mcp_extensions,
|
|
ClientMcpExtensions::new(HashMap::from([
|
|
(OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({})),
|
|
(
|
|
MCP_APP_UI_EXTENSION_ID.to_string(),
|
|
serde_json::json!({
|
|
"mimeTypes": ["text/html;profile=mcp-app"],
|
|
}),
|
|
),
|
|
]))
|
|
);
|
|
}
|
|
|
|
struct FakeAgentGraphStore {
|
|
root_thread_id: ThreadId,
|
|
descendant_thread_ids: Vec<ThreadId>,
|
|
}
|
|
|
|
impl codex_agent_graph_store::AgentGraphStore for FakeAgentGraphStore {
|
|
fn upsert_thread_spawn_edge(
|
|
&self,
|
|
_parent_thread_id: ThreadId,
|
|
_child_thread_id: ThreadId,
|
|
_status: codex_agent_graph_store::ThreadSpawnEdgeStatus,
|
|
) -> codex_agent_graph_store::AgentGraphStoreFuture<'_, ()> {
|
|
Box::pin(async { panic!("unexpected graph upsert") })
|
|
}
|
|
|
|
fn set_thread_spawn_edge_status(
|
|
&self,
|
|
_child_thread_id: ThreadId,
|
|
_status: codex_agent_graph_store::ThreadSpawnEdgeStatus,
|
|
) -> codex_agent_graph_store::AgentGraphStoreFuture<'_, ()> {
|
|
Box::pin(async { panic!("unexpected graph status update") })
|
|
}
|
|
|
|
fn list_thread_spawn_children(
|
|
&self,
|
|
_parent_thread_id: ThreadId,
|
|
_status_filter: Option<codex_agent_graph_store::ThreadSpawnEdgeStatus>,
|
|
) -> codex_agent_graph_store::AgentGraphStoreFuture<'_, Vec<ThreadId>> {
|
|
Box::pin(async { panic!("unexpected direct-child listing") })
|
|
}
|
|
|
|
fn list_thread_spawn_descendants(
|
|
&self,
|
|
root_thread_id: ThreadId,
|
|
status_filter: Option<codex_agent_graph_store::ThreadSpawnEdgeStatus>,
|
|
) -> codex_agent_graph_store::AgentGraphStoreFuture<'_, Vec<ThreadId>> {
|
|
assert_eq!(root_thread_id, self.root_thread_id);
|
|
assert_eq!(status_filter, None);
|
|
let descendant_thread_ids = self.descendant_thread_ids.clone();
|
|
Box::pin(async move { Ok(descendant_thread_ids) })
|
|
}
|
|
}
|
|
|
|
fn user_msg(text: &str) -> ResponseItem {
|
|
ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
annotations: None,
|
|
logprobs: None,
|
|
text: text.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}
|
|
}
|
|
fn assistant_msg(text: &str) -> ResponseItem {
|
|
ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
annotations: None,
|
|
logprobs: None,
|
|
text: text.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}
|
|
}
|
|
|
|
fn contextual_user_interrupted_marker() -> ResponseItem {
|
|
interrupted_turn_history_marker(InterruptedTurnHistoryMarker::ContextualUser)
|
|
.expect("contextual-user interrupted marker should be enabled")
|
|
}
|
|
|
|
fn developer_interrupted_marker() -> ResponseItem {
|
|
interrupted_turn_history_marker(InterruptedTurnHistoryMarker::Developer)
|
|
.expect("developer interrupted marker should be enabled")
|
|
}
|
|
|
|
#[test]
|
|
fn effective_originator_prefers_thread_scoped_sources_before_env_originator() {
|
|
for (metrics_service_name, persisted_originator, inherited_originator, expected_originator) in [
|
|
(
|
|
Some("codex_work_desktop"),
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"codex_work_desktop",
|
|
),
|
|
(
|
|
Some("codex_work_web"),
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"codex_work_web",
|
|
),
|
|
(
|
|
Some("codex_work_mobile"),
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"codex_work_mobile",
|
|
),
|
|
(
|
|
Some("codex_work_cca"),
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"codex_work_cca",
|
|
),
|
|
(
|
|
Some("chatgpt_cca"),
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"chatgpt_cca",
|
|
),
|
|
(
|
|
Some("chatgpt_cca_extra"),
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"persisted_originator",
|
|
),
|
|
(
|
|
None,
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"persisted_originator",
|
|
),
|
|
(
|
|
None,
|
|
None,
|
|
Some("inherited_originator"),
|
|
"inherited_originator",
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
effective_originator_value(
|
|
metrics_service_name,
|
|
Some("Codex Desktop".to_string()),
|
|
persisted_originator.map(str::to_string),
|
|
inherited_originator.map(str::to_string),
|
|
"codex_cli_rs".to_string(),
|
|
),
|
|
expected_originator
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn truncates_before_requested_user_message() {
|
|
let items = [
|
|
user_msg("u1"),
|
|
assistant_msg("a1"),
|
|
assistant_msg("a2"),
|
|
user_msg("u2"),
|
|
assistant_msg("a3"),
|
|
ResponseItem::Reasoning {
|
|
id: Some(ResponseItemId::with_suffix("rs", "1")),
|
|
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
|
text: "s".to_string(),
|
|
}],
|
|
content: None,
|
|
encrypted_content: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::FunctionCall {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
call_id: "c1".to_string(),
|
|
name: "tool".to_string(),
|
|
namespace: None,
|
|
arguments: "{}".to_string(),
|
|
encrypted_function_args: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
assistant_msg("a4"),
|
|
];
|
|
|
|
let initial: Vec<RolloutItem> = items
|
|
.iter()
|
|
.cloned()
|
|
.map(|item| RolloutItem::ResponseItem(item.into()))
|
|
.collect();
|
|
let truncated = truncate_before_nth_user_message(
|
|
InitialHistory::Forked(initial),
|
|
/*n*/ 1,
|
|
&SnapshotTurnState {
|
|
ends_mid_turn: false,
|
|
active_turn_id: None,
|
|
active_turn_started_at: None,
|
|
active_turn_root_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
let got_items = truncated.get_rollout_items();
|
|
let expected_items = vec![
|
|
RolloutItem::ResponseItem(items[0].clone().into()),
|
|
RolloutItem::ResponseItem(items[1].clone().into()),
|
|
RolloutItem::ResponseItem(items[2].clone().into()),
|
|
];
|
|
assert_eq!(
|
|
serde_json::to_value(got_items).unwrap(),
|
|
serde_json::to_value(&expected_items).unwrap()
|
|
);
|
|
|
|
let initial2: Vec<RolloutItem> = items
|
|
.iter()
|
|
.cloned()
|
|
.map(|item| RolloutItem::ResponseItem(item.into()))
|
|
.collect();
|
|
let truncated2 = truncate_before_nth_user_message(
|
|
InitialHistory::Forked(initial2.clone()),
|
|
/*n*/ 2,
|
|
&SnapshotTurnState {
|
|
ends_mid_turn: false,
|
|
active_turn_id: None,
|
|
active_turn_started_at: None,
|
|
active_turn_root_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(truncated2.get_rollout_items()).unwrap(),
|
|
serde_json::to_value(initial2).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn out_of_range_truncation_drops_only_unfinished_suffix_mid_turn() {
|
|
let items = vec![
|
|
RolloutItem::ResponseItem(user_msg("u1").into()),
|
|
RolloutItem::ResponseItem(assistant_msg("a1").into()),
|
|
RolloutItem::ResponseItem(user_msg("u2").into()),
|
|
RolloutItem::ResponseItem(assistant_msg("partial").into()),
|
|
];
|
|
|
|
let truncated = truncate_before_nth_user_message(
|
|
InitialHistory::Forked(items.clone()),
|
|
usize::MAX,
|
|
&SnapshotTurnState {
|
|
ends_mid_turn: true,
|
|
active_turn_id: None,
|
|
active_turn_started_at: None,
|
|
active_turn_root_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(truncated.get_rollout_items()).unwrap(),
|
|
serde_json::to_value(items[..2].to_vec()).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fork_thread_accepts_legacy_usize_snapshot_argument() {
|
|
fn assert_legacy_snapshot_callsite(
|
|
manager: &ThreadManager,
|
|
config: Config,
|
|
path: std::path::PathBuf,
|
|
) {
|
|
let _future =
|
|
manager.fork_legacy_thread(usize::MAX, crate::StartThreadOptions::new(config), path);
|
|
}
|
|
|
|
let _: fn(&ThreadManager, Config, std::path::PathBuf) = assert_legacy_snapshot_callsite;
|
|
}
|
|
|
|
#[test]
|
|
fn out_of_range_truncation_drops_pre_user_active_turn_prefix() {
|
|
let items = vec![
|
|
RolloutItem::ResponseItem(user_msg("u1").into()),
|
|
RolloutItem::ResponseItem(assistant_msg("a1").into()),
|
|
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_attribution: None,
|
|
turn_id: "turn-2".to_string(),
|
|
root_turn_id: None,
|
|
trace_id: None,
|
|
started_at: None,
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
})),
|
|
RolloutItem::ResponseItem(user_msg("u2").into()),
|
|
RolloutItem::ResponseItem(assistant_msg("partial").into()),
|
|
];
|
|
|
|
let snapshot_state = snapshot_turn_state(&InitialHistory::Forked(items.clone()));
|
|
assert_eq!(
|
|
snapshot_state,
|
|
SnapshotTurnState {
|
|
ends_mid_turn: true,
|
|
active_turn_id: Some("turn-2".to_string()),
|
|
active_turn_started_at: None,
|
|
active_turn_root_id: None,
|
|
active_turn_start_index: Some(2),
|
|
},
|
|
);
|
|
|
|
let truncated = truncate_before_nth_user_message(
|
|
InitialHistory::Forked(items.clone()),
|
|
usize::MAX,
|
|
&snapshot_state,
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(truncated.get_rollout_items()).unwrap(),
|
|
serde_json::to_value(items[..2].to_vec()).unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ignores_session_prefix_messages_when_truncating() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let turn_context = Arc::new(turn_context);
|
|
let world_state = build_world_state_from_turn_context(&session, &turn_context).await;
|
|
let step_context = StepContext::for_test(turn_context);
|
|
let updates = session
|
|
.build_initial_context_with_world_state(&step_context, &world_state)
|
|
.await
|
|
.0;
|
|
let mut items = crate::context_manager::updates::merge_world_state_updates(updates);
|
|
items.push(user_msg("feature request"));
|
|
items.push(assistant_msg("ack"));
|
|
items.push(user_msg("second question"));
|
|
items.push(assistant_msg("answer"));
|
|
|
|
let rollout_items: Vec<RolloutItem> = items
|
|
.iter()
|
|
.cloned()
|
|
.map(|item| RolloutItem::ResponseItem(item.into()))
|
|
.collect();
|
|
|
|
let truncated = truncate_before_nth_user_message(
|
|
InitialHistory::Forked(rollout_items),
|
|
/*n*/ 1,
|
|
&SnapshotTurnState {
|
|
ends_mid_turn: false,
|
|
active_turn_id: None,
|
|
active_turn_started_at: None,
|
|
active_turn_root_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
let got_items = truncated.get_rollout_items();
|
|
|
|
let expected: Vec<RolloutItem> = vec![
|
|
RolloutItem::ResponseItem(items[0].clone().into()),
|
|
RolloutItem::ResponseItem(items[1].clone().into()),
|
|
RolloutItem::ResponseItem(items[2].clone().into()),
|
|
RolloutItem::ResponseItem(items[3].clone().into()),
|
|
];
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(got_items).unwrap(),
|
|
serde_json::to_value(&expected).unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let thread_1 = manager
|
|
.start_thread(StartThreadOptions::new(config.clone()))
|
|
.await
|
|
.expect("start first thread")
|
|
.thread_id;
|
|
let thread_2 = manager
|
|
.start_thread(StartThreadOptions::new(config.clone()))
|
|
.await
|
|
.expect("start second thread")
|
|
.thread_id;
|
|
|
|
let report = manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
|
|
let mut expected_completed = vec![thread_1, thread_2];
|
|
expected_completed.sort_by_key(std::string::ToString::to_string);
|
|
assert_eq!(report.completed, expected_completed);
|
|
assert!(report.submit_failed.is_empty());
|
|
assert!(report.timed_out.is_empty());
|
|
assert!(manager.list_thread_ids().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn code_mode_session_provider_is_shared_across_threads() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
config
|
|
.features
|
|
.enable(Feature::CodeModeHostOutputTruncation)
|
|
.expect("enable the built-in provider's server truncation feature");
|
|
|
|
let provider: Arc<dyn CodeModeSessionProvider> = Arc::new(DisabledCodeModeSessionProvider);
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
)
|
|
// Exercise the production factory with the startup flag before overriding
|
|
// its lazy built-in provider. No host is started by this test.
|
|
.with_code_mode_host_program_for_tests(temp_dir.path().join("unused-code-mode-host"), &config)
|
|
.with_code_mode_session_provider(Arc::clone(&provider));
|
|
let first = manager
|
|
.start_thread(StartThreadOptions::new(config.clone()))
|
|
.await
|
|
.expect("start first thread");
|
|
let second = manager
|
|
.start_thread(StartThreadOptions::new(config))
|
|
.await
|
|
.expect("start second thread");
|
|
|
|
let first_provider = first
|
|
.thread
|
|
.session
|
|
.services
|
|
.code_mode_service
|
|
.session_provider();
|
|
let second_provider = second
|
|
.thread
|
|
.session
|
|
.services
|
|
.code_mode_service
|
|
.session_provider();
|
|
assert!(Arc::ptr_eq(&first_provider, &second_provider));
|
|
assert!(Arc::ptr_eq(&first_provider, &provider));
|
|
assert!(Arc::ptr_eq(
|
|
&first_provider,
|
|
&manager.state.code_mode_session_provider
|
|
));
|
|
|
|
let mut completed = vec![first.thread_id, second.thread_id];
|
|
completed.sort_by_key(std::string::ToString::to_string);
|
|
let report = manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
assert_eq!(
|
|
report,
|
|
ThreadShutdownReport {
|
|
completed,
|
|
submit_failed: Vec::new(),
|
|
timed_out: Vec::new(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_invalidation_refreshes_threads_that_are_still_starting() {
|
|
struct BlockingThreadStartup {
|
|
entered: tokio::sync::Notify,
|
|
release: tokio::sync::Notify,
|
|
refreshed: tokio::sync::Notify,
|
|
projections: std::sync::atomic::AtomicUsize,
|
|
}
|
|
|
|
impl codex_extension_api::ThreadLifecycleContributor<Config> for BlockingThreadStartup {
|
|
fn on_thread_start<'a>(
|
|
&'a self,
|
|
_input: codex_extension_api::ThreadStartInput<'a, Config>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
self.entered.notify_one();
|
|
self.release.notified().await;
|
|
})
|
|
}
|
|
}
|
|
|
|
impl codex_extension_api::McpServerContributor<Config> for BlockingThreadStartup {
|
|
fn id(&self) -> &'static str {
|
|
"starting_mcp_runtime_refresh_test"
|
|
}
|
|
|
|
fn contribute<'a>(
|
|
&'a self,
|
|
_context: codex_extension_api::McpServerContributionContext<'a, Config>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, Vec<codex_extension_api::McpServerContribution>>
|
|
{
|
|
Box::pin(async move {
|
|
if self.projections.fetch_add(1, Ordering::AcqRel) != 0 {
|
|
self.refreshed.notify_one();
|
|
}
|
|
Vec::new()
|
|
})
|
|
}
|
|
}
|
|
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
// Resolve the model locally before waiting for the MCP lifecycle hook.
|
|
config.model_catalog = Some(
|
|
codex_models_manager::bundled_models_response().expect("bundled model catalog should load"),
|
|
);
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let observer = Arc::new(BlockingThreadStartup {
|
|
entered: tokio::sync::Notify::new(),
|
|
release: tokio::sync::Notify::new(),
|
|
refreshed: tokio::sync::Notify::new(),
|
|
projections: std::sync::atomic::AtomicUsize::new(0),
|
|
});
|
|
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
|
|
extensions.thread_lifecycle_contributor(observer.clone());
|
|
extensions.mcp_server_contributor(observer.clone());
|
|
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
|
|
let manager = Arc::new(ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
Arc::clone(&auth_manager),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
Arc::new(extensions.build()),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
|
|
/*agent_graph_store*/ None,
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
));
|
|
let starting = tokio::spawn({
|
|
let manager = Arc::clone(&manager);
|
|
async move { manager.start_thread(StartThreadOptions::new(config)).await }
|
|
});
|
|
|
|
tokio::time::timeout(Duration::from_secs(5), observer.entered.notified())
|
|
.await
|
|
.expect("thread should enter its startup lifecycle");
|
|
assert!(manager.list_thread_ids().await.is_empty());
|
|
manager.invalidate_mcp_runtimes().await;
|
|
observer.release.notify_one();
|
|
starting
|
|
.await
|
|
.expect("thread startup task should finish")
|
|
.expect("thread should start");
|
|
tokio::time::timeout(Duration::from_secs(5), observer.refreshed.notified())
|
|
.await
|
|
.expect("invalidation during startup should refresh the newly published thread");
|
|
let shutdown = manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(5))
|
|
.await;
|
|
assert!(shutdown.timed_out.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let thread = manager
|
|
.start_thread(StartThreadOptions {
|
|
session_source: Some(SessionSource::Internal(
|
|
InternalSessionSource::MemoryConsolidation,
|
|
)),
|
|
environments: Some(Vec::new()),
|
|
..StartThreadOptions::new(config)
|
|
})
|
|
.await
|
|
.expect("internal thread should start");
|
|
|
|
assert_eq!(manager.list_thread_ids().await, Vec::new());
|
|
assert!(manager.get_thread(thread.thread_id).await.is_err());
|
|
assert!(
|
|
codex_diagnostics::snapshot()
|
|
.gauges
|
|
.iter()
|
|
.any(|gauge| gauge.name == "core.threads.live" && gauge.value > 0)
|
|
);
|
|
|
|
let report = manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
assert_eq!(report.completed, vec![thread.thread_id]);
|
|
assert!(report.submit_failed.is_empty());
|
|
assert!(report.timed_out.is_empty());
|
|
assert!(manager.list_thread_ids().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spawn_internal_guardian_session_preserves_windows_sandbox_proxy_settings() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let parent = manager
|
|
.start_thread(StartThreadOptions::new(config.clone()))
|
|
.await
|
|
.expect("start parent thread");
|
|
let reviewer = manager
|
|
.spawn_internal_session(
|
|
parent.thread_id,
|
|
StartThreadOptions {
|
|
session_source: Some(SessionSource::Internal(InternalSessionSource::Guardian)),
|
|
..StartThreadOptions::new(config)
|
|
},
|
|
)
|
|
.await
|
|
.expect("start internal reviewer");
|
|
|
|
assert_eq!(
|
|
(
|
|
parent.thread.session.windows_sandbox_proxy_settings_mode,
|
|
reviewer.thread.session.windows_sandbox_proxy_settings_mode,
|
|
),
|
|
(
|
|
codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile,
|
|
codex_sandboxing::WindowsSandboxProxySettingsMode::Preserve,
|
|
)
|
|
);
|
|
|
|
manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fork_internal_session_uses_only_the_selected_history() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let parent = manager
|
|
.start_thread(StartThreadOptions::new(config.clone()))
|
|
.await
|
|
.expect("start parent");
|
|
let selected = vec![
|
|
user_msg("committed review"),
|
|
assistant_msg("completed assessment"),
|
|
];
|
|
parent
|
|
.thread
|
|
.inject_response_items(vec![user_msg("unrelated parent work")])
|
|
.await
|
|
.expect("inject parent history");
|
|
let reviewer = manager
|
|
.fork_internal_session(
|
|
parent.thread_id,
|
|
StartThreadOptions {
|
|
session_source: Some(SessionSource::Internal(InternalSessionSource::Guardian)),
|
|
..StartThreadOptions::new(config)
|
|
},
|
|
selected
|
|
.iter()
|
|
.cloned()
|
|
.map(|item| RolloutItem::ResponseItem(item.into()))
|
|
.collect(),
|
|
)
|
|
.await
|
|
.expect("fork internal reviewer");
|
|
let history = reviewer.thread.conversation_history_snapshot().await;
|
|
let mut actual = history.items().cloned().collect::<Vec<_>>();
|
|
// Recording history assigns message IDs and provenance; compare the selected content.
|
|
for item in &mut actual {
|
|
if let ResponseItem::Message {
|
|
id,
|
|
internal_chat_message_metadata_passthrough,
|
|
..
|
|
} = item
|
|
{
|
|
*id = None;
|
|
*internal_chat_message_metadata_passthrough = None;
|
|
}
|
|
}
|
|
assert_eq!(actual, selected);
|
|
assert_eq!(
|
|
reviewer.thread.config_snapshot().await.parent_thread_id,
|
|
Some(parent.thread_id)
|
|
);
|
|
assert!(Arc::ptr_eq(
|
|
&reviewer.thread.session.services.auth_manager,
|
|
&parent.thread.session.services.auth_manager,
|
|
));
|
|
assert_eq!(manager.list_thread_ids().await, vec![parent.thread_id]);
|
|
assert!(manager.get_thread(reviewer.thread_id).await.is_err());
|
|
manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spawn_internal_session_preserves_parent_lineage_without_forking_history() {
|
|
struct ParentLifecycleContributor {
|
|
observed_mcp_sources: Arc<std::sync::Mutex<Vec<SessionSource>>>,
|
|
}
|
|
|
|
impl codex_extension_api::ThreadLifecycleContributor<Config> for ParentLifecycleContributor {}
|
|
|
|
impl codex_extension_api::McpServerContributor<Config> for ParentLifecycleContributor {
|
|
fn id(&self) -> &'static str {
|
|
"parent_mcp_contributor"
|
|
}
|
|
|
|
fn contribute<'a>(
|
|
&'a self,
|
|
context: codex_extension_api::McpServerContributionContext<'a, Config>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, Vec<codex_extension_api::McpServerContribution>>
|
|
{
|
|
Box::pin(async move {
|
|
if let Some(session_source) = context.session_source() {
|
|
self.observed_mcp_sources
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push(session_source.clone());
|
|
}
|
|
Vec::new()
|
|
})
|
|
}
|
|
}
|
|
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.rollout_budget = Some(RolloutBudgetConfig {
|
|
enforce_limit: true,
|
|
limit_tokens: 100,
|
|
reminder_at_remaining_tokens: vec![75, 50, 25],
|
|
sampling_token_weight: 1.0,
|
|
prefill_token_weight: 1.0,
|
|
});
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let mut managed_exec_policy = codex_execpolicy::Policy::empty();
|
|
managed_exec_policy
|
|
.add_prefix_rule(&["rm".to_string()], codex_execpolicy::Decision::Forbidden)
|
|
.expect("add managed execution restriction");
|
|
let mut requirements = config.config_layer_stack.requirements().clone();
|
|
requirements.exec_policy = Some(codex_config::Sourced::new(
|
|
codex_execpolicy::RequirementsExecPolicy::new(managed_exec_policy),
|
|
codex_config::RequirementSource::Unknown,
|
|
));
|
|
requirements.additional_developer_instructions = Some(codex_config::Sourced::new(
|
|
"managed instructions must not shape the reviewer".to_string(),
|
|
codex_config::RequirementSource::Unknown,
|
|
));
|
|
let mut requirements_toml = config.config_layer_stack.requirements_toml().clone();
|
|
requirements_toml.additional_developer_instructions =
|
|
Some("managed instructions must not shape the reviewer".to_string());
|
|
config.config_layer_stack = codex_config::ConfigLayerStack::new(
|
|
config
|
|
.config_layer_stack
|
|
.all_layers_low_to_high()
|
|
.cloned()
|
|
.collect(),
|
|
requirements,
|
|
requirements_toml,
|
|
)
|
|
.expect("managed requirements stack");
|
|
|
|
let parent_instructions = codex_extension_api::Instructions {
|
|
text: "parent user instructions must not be inherited".to_string(),
|
|
source: Some(config.codex_home.join("AGENTS.md")),
|
|
};
|
|
let mut manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let observed_mcp_sources = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let parent_contributor = Arc::new(ParentLifecycleContributor {
|
|
observed_mcp_sources: Arc::clone(&observed_mcp_sources),
|
|
});
|
|
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
|
|
extensions.thread_lifecycle_contributor(parent_contributor.clone());
|
|
extensions.mcp_server_contributor(parent_contributor);
|
|
let manager_state = Arc::get_mut(&mut manager.state).expect("unshared thread manager state");
|
|
manager_state.extensions = Arc::new(extensions.build());
|
|
manager_state.mcp_manager = Arc::new(McpManager::new_with_extensions(
|
|
Arc::clone(&manager_state.plugins_manager),
|
|
Arc::clone(&manager_state.extensions),
|
|
manager_state.mcp_manager.codex_apps_tools_cache(),
|
|
));
|
|
let parent_provider = Arc::new(ParentInstructionsProvider(parent_instructions.clone()));
|
|
manager_state.user_instructions_provider = parent_provider.clone();
|
|
let parent = manager
|
|
.start_thread(StartThreadOptions {
|
|
metrics_service_name: Some("codex_work_desktop".to_string()),
|
|
thread_instructions_provider: Some(parent_provider),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start parent thread");
|
|
parent
|
|
.thread
|
|
.session
|
|
.set_multi_agent_version_if_unset(MultiAgentVersion::V2);
|
|
let parent_snapshot = parent.thread.session.inherited_instructions().await;
|
|
assert_eq!(
|
|
(parent_snapshot.user, parent_snapshot.thread),
|
|
(Some(parent_instructions.clone()), Some(parent_instructions))
|
|
);
|
|
assert_eq!(
|
|
parent
|
|
.thread
|
|
.session
|
|
.services
|
|
.extensions
|
|
.thread_lifecycle_contributors()
|
|
.len(),
|
|
1
|
|
);
|
|
let mut reviewer_environments = parent
|
|
.thread
|
|
.session
|
|
.services
|
|
.turn_environments
|
|
.selections();
|
|
let reviewer_environment = reviewer_environments
|
|
.first_mut()
|
|
.expect("parent should have an environment");
|
|
reviewer_environment.config =
|
|
EnvironmentConfigState::Ready(codex_protocol::protocol::EnvironmentConfig {
|
|
environment_hooks: Default::default(),
|
|
allow_login_shell: true,
|
|
workspace_roots: reviewer_environment.workspace_roots.clone(),
|
|
permission_profile: config.permissions.permission_profile_state().snapshot(),
|
|
shell_environment_policy: Default::default(),
|
|
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
|
windows_sandbox_type: config.permissions.windows_sandbox_type,
|
|
use_legacy_landlock: config.features.use_legacy_landlock(),
|
|
exec_policy: Some(codex_execpolicy::RequirementsExecPolicy::new(
|
|
codex_execpolicy::Policy::empty(),
|
|
)),
|
|
mcp_policy: None,
|
|
network_policy: None,
|
|
selected_capability_roots: Vec::new(),
|
|
});
|
|
let reviewer = manager
|
|
.spawn_internal_session(
|
|
parent.thread_id,
|
|
StartThreadOptions {
|
|
session_source: Some(SessionSource::Internal(InternalSessionSource::Guardian)),
|
|
initial_history: InitialHistory::Forked(vec![RolloutItem::ResponseItem(
|
|
user_msg("parent history must not be inherited").into(),
|
|
)]),
|
|
environments: Some(
|
|
reviewer_environments
|
|
.into_iter()
|
|
.map(TurnEnvironmentSelection::into_request)
|
|
.collect(),
|
|
),
|
|
..StartThreadOptions::new(config)
|
|
},
|
|
)
|
|
.await
|
|
.expect("start internal reviewer");
|
|
let reviewer_config = reviewer.thread.config_snapshot().await;
|
|
assert!(Arc::ptr_eq(
|
|
&reviewer.thread.session.services.agent_control,
|
|
&parent.thread.session.services.agent_control,
|
|
));
|
|
|
|
assert_eq!(
|
|
reviewer.session_configured.session_id,
|
|
parent.session_configured.session_id
|
|
);
|
|
reviewer
|
|
.thread
|
|
.session
|
|
.services
|
|
.agent_control
|
|
.record_usage(TokenUsage {
|
|
output_tokens: 25,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("record reviewer usage");
|
|
let reminder = parent
|
|
.thread
|
|
.session
|
|
.services
|
|
.agent_control
|
|
.pending_budget_reminder(parent.thread_id, "window")
|
|
.await
|
|
.expect("parent budget reminder");
|
|
assert_eq!(reminder.remaining_tokens, 75);
|
|
assert_eq!(reviewer_config.parent_thread_id, Some(parent.thread_id));
|
|
assert_eq!(reviewer_config.forked_from_thread_id, None);
|
|
assert_eq!(reviewer_config.originator, "codex_work_desktop");
|
|
assert_eq!(
|
|
reviewer.thread.multi_agent_version(),
|
|
Some(MultiAgentVersion::Disabled)
|
|
);
|
|
assert_eq!(
|
|
reviewer.session_configured.parent_thread_id,
|
|
Some(parent.thread_id)
|
|
);
|
|
assert_eq!(reviewer.session_configured.forked_from_id, None);
|
|
let reviewer_snapshot = reviewer.thread.session.inherited_instructions().await;
|
|
assert_eq!(
|
|
(reviewer_snapshot.user, reviewer_snapshot.thread),
|
|
(None, None)
|
|
);
|
|
assert!(
|
|
reviewer
|
|
.thread
|
|
.session
|
|
.services
|
|
.exec_policy
|
|
.current()
|
|
.rules()
|
|
.contains_key("rm")
|
|
);
|
|
let reviewer_turn = reviewer.thread.session.new_default_turn().await;
|
|
let reviewer_world_state =
|
|
build_world_state_from_turn_context(&reviewer.thread.session, &reviewer_turn).await;
|
|
let reviewer_step = StepContext::for_test(Arc::clone(&reviewer_turn));
|
|
let reviewer_context = reviewer
|
|
.thread
|
|
.session
|
|
.build_initial_context_with_world_state(&reviewer_step, &reviewer_world_state)
|
|
.await
|
|
.0;
|
|
let reviewer_context =
|
|
crate::context_manager::updates::merge_world_state_updates(reviewer_context);
|
|
assert!(
|
|
!serde_json::to_string(&reviewer_context)
|
|
.expect("reviewer context should serialize")
|
|
.contains("managed instructions must not shape the reviewer")
|
|
);
|
|
let reviewer_environment = reviewer
|
|
.thread
|
|
.environment_selections()
|
|
.await
|
|
.into_iter()
|
|
.next()
|
|
.expect("reviewer should retain its selected environment");
|
|
assert!(matches!(
|
|
reviewer_environment.config,
|
|
EnvironmentConfigState::Ready(config) if config.exec_policy.is_some()
|
|
));
|
|
assert!(
|
|
reviewer
|
|
.thread
|
|
.session
|
|
.services
|
|
.extensions
|
|
.thread_lifecycle_contributors()
|
|
.is_empty()
|
|
);
|
|
{
|
|
let observed_mcp_sources = observed_mcp_sources
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
assert!(!observed_mcp_sources.is_empty());
|
|
assert!(
|
|
observed_mcp_sources
|
|
.iter()
|
|
.all(|source| !source.is_internal())
|
|
);
|
|
}
|
|
assert_eq!(manager.list_thread_ids().await, vec![parent.thread_id]);
|
|
assert!(manager.get_thread(reviewer.thread_id).await.is_err());
|
|
assert!(
|
|
reviewer
|
|
.thread
|
|
.session
|
|
.clone_history()
|
|
.await
|
|
.raw_items()
|
|
.next()
|
|
.is_none()
|
|
);
|
|
|
|
// Captured lineage also works for an inline parent with no registry entry.
|
|
manager.remove_thread(&parent.thread_id).await;
|
|
let child = manager
|
|
.start_thread(StartThreadOptions {
|
|
internal_parent: Some(InternalSessionParent {
|
|
thread_id: parent.thread_id,
|
|
auth_manager: Arc::clone(&parent.thread.session.services.auth_manager),
|
|
agent_control: AgentControlInit::Provided {
|
|
control: Arc::clone(&parent.thread.session.services.agent_control),
|
|
runtime: parent.thread.session.services.local_agent_runtime.clone(),
|
|
},
|
|
originator: reviewer_config.originator.clone(),
|
|
inherited_instructions: None,
|
|
}),
|
|
session_source: Some(SessionSource::Internal(
|
|
InternalSessionSource::MemoryConsolidation,
|
|
)),
|
|
..StartThreadOptions::new(parent.thread.session.get_config().await.as_ref().clone())
|
|
})
|
|
.await
|
|
.expect("start child without a parent registry entry");
|
|
let child_config = child.thread.config_snapshot().await;
|
|
assert_eq!(
|
|
(
|
|
child_config.parent_thread_id,
|
|
child_config.originator,
|
|
child.thread.session.session_id(),
|
|
),
|
|
(
|
|
Some(parent.thread_id),
|
|
reviewer_config.originator,
|
|
parent.thread.session.session_id(),
|
|
)
|
|
);
|
|
assert!(Arc::ptr_eq(
|
|
&child.thread.session.services.auth_manager,
|
|
&parent.thread.session.services.auth_manager
|
|
));
|
|
assert!(manager.list_thread_ids().await.is_empty());
|
|
parent
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("stop parent");
|
|
manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() {
|
|
struct InitialDataRecorder {
|
|
lifecycle_observed: Arc<std::sync::Mutex<Vec<(String, String)>>>,
|
|
mcp_observed: Arc<std::sync::Mutex<Vec<(String, SessionSource)>>>,
|
|
mcp_loaded: Arc<std::sync::atomic::AtomicUsize>,
|
|
}
|
|
|
|
impl codex_extension_api::ThreadLifecycleContributor<Config> for InitialDataRecorder {
|
|
fn on_thread_start<'a>(
|
|
&'a self,
|
|
input: codex_extension_api::ThreadStartInput<'a, Config>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let selected_root = input
|
|
.thread_store
|
|
.get::<Vec<SelectedCapabilityRoot>>()
|
|
.and_then(|roots| roots.first().cloned())
|
|
.expect("selected root should be available");
|
|
self.lifecycle_observed
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push((input.thread_store.level_id().to_string(), selected_root.id));
|
|
input
|
|
.thread_store
|
|
.insert(Vec::<SelectedCapabilityRoot>::new());
|
|
})
|
|
}
|
|
}
|
|
|
|
impl codex_extension_api::McpServerContributor<Config> for InitialDataRecorder {
|
|
fn id(&self) -> &'static str {
|
|
"selected_root_test"
|
|
}
|
|
|
|
fn contribute<'a>(
|
|
&'a self,
|
|
_context: codex_extension_api::McpServerContributionContext<'a, Config>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, Vec<codex_extension_api::McpServerContribution>>
|
|
{
|
|
Box::pin(async { Vec::new() })
|
|
}
|
|
|
|
fn selected_plugins<'a>(
|
|
&'a self,
|
|
context: codex_extension_api::McpServerContributionContext<'a, Config>,
|
|
_plugins_config: &'a codex_config::types::PluginsConfigToml,
|
|
) -> codex_extension_api::ExtensionFuture<'a, Vec<codex_extension_api::SelectedPlugin<'a>>>
|
|
{
|
|
Box::pin(async move {
|
|
assert_eq!(
|
|
context.selected_environments(),
|
|
Some([].as_slice()),
|
|
"thread MCP projection must preserve explicitly empty selections"
|
|
);
|
|
let thread_init = context
|
|
.thread_init()
|
|
.expect("initial MCP resolution should be thread-scoped");
|
|
let selected_root = thread_init
|
|
.get::<Vec<SelectedCapabilityRoot>>()
|
|
.and_then(|roots| roots.first().cloned())
|
|
.expect("selected root should be available");
|
|
self.mcp_observed
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push((
|
|
selected_root.id.clone(),
|
|
context
|
|
.session_source()
|
|
.expect("thread-scoped MCP resolution should identify its source")
|
|
.clone(),
|
|
));
|
|
let mut server = codex_mcp::codex_apps_mcp_server_config(
|
|
"https://selected.invalid",
|
|
/*apps_mcp_product_sku*/ None,
|
|
/*originator*/ None,
|
|
);
|
|
let CapabilityRootLocation::Environment { environment_id, .. } =
|
|
&selected_root.location;
|
|
let source_environment_id = environment_id.clone();
|
|
server.environment_id = source_environment_id.clone();
|
|
server.enabled = false;
|
|
let selected_root_id = selected_root.id;
|
|
let plugin_id = format!("plugin-{selected_root_id}");
|
|
let mcp_loaded = Arc::clone(&self.mcp_loaded);
|
|
vec![codex_extension_api::SelectedPlugin {
|
|
selected_root_id: selected_root_id.clone(),
|
|
plugin_id: plugin_id.clone(),
|
|
identity: codex_core_plugins::PluginIdentity::Local {
|
|
plugin_id: plugin_id.clone(),
|
|
},
|
|
enabled: true,
|
|
mcp: Box::pin(async move {
|
|
mcp_loaded.fetch_add(1, Ordering::Relaxed);
|
|
codex_extension_api::SelectedPluginContribution {
|
|
plugin_display_name: plugin_id,
|
|
source_environment_id,
|
|
connector_ids: vec![format!("{selected_root_id}-connector")],
|
|
servers: vec![(selected_root_id, server)],
|
|
}
|
|
}),
|
|
}]
|
|
})
|
|
}
|
|
}
|
|
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
config
|
|
.features
|
|
.enable(Feature::Apps)
|
|
.expect("test config should allow apps");
|
|
config
|
|
.features
|
|
.enable(Feature::Plugins)
|
|
.expect("enable plugins");
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let lifecycle_observed = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let mcp_observed = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let mcp_loaded = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
// This fixture counts explicit MCP projections, excluding background websocket prewarming.
|
|
config.model_provider.supports_websockets = false;
|
|
let recorder = Arc::new(InitialDataRecorder {
|
|
lifecycle_observed: Arc::clone(&lifecycle_observed),
|
|
mcp_observed: Arc::clone(&mcp_observed),
|
|
mcp_loaded: Arc::clone(&mcp_loaded),
|
|
});
|
|
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
|
|
extensions.thread_lifecycle_contributor(recorder.clone());
|
|
extensions.mcp_server_contributor(recorder.clone());
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
Arc::new(extensions.build()),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
|
|
/*agent_graph_store*/ None,
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
let selected_root_init = |id: &str, environment_id: &str| {
|
|
let mut init = codex_extension_api::ExtensionDataInit::new();
|
|
init.insert(vec![SelectedCapabilityRoot {
|
|
id: id.to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: environment_id.to_string(),
|
|
path: PathUri::parse(&format!("file:///plugins/{id}")).expect("plugin root URI"),
|
|
},
|
|
}]);
|
|
init
|
|
};
|
|
|
|
let first_thread = manager
|
|
.start_thread(StartThreadOptions {
|
|
metrics_service_name: Some("codex_work_desktop".to_string()),
|
|
environments: Some(Vec::new()),
|
|
thread_extension_init: selected_root_init("selected-a", "env-a"),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start first thread");
|
|
let second_session_source = SessionSource::SubAgent(SubAgentSource::Review);
|
|
let second_thread = manager
|
|
.start_thread(StartThreadOptions {
|
|
environments: Some(Vec::new()),
|
|
session_source: Some(second_session_source.clone()),
|
|
thread_extension_init: selected_root_init("selected-b", "env-b"),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start second thread");
|
|
let first_session = &first_thread.thread.session;
|
|
let first_originator = first_session.originator().await;
|
|
let first_resolved = first_session
|
|
.services
|
|
.mcp_manager
|
|
.runtime_config_for_step(
|
|
&config,
|
|
&first_session.services.mcp_thread_init,
|
|
&first_session.services.thread_extension_data,
|
|
McpThreadIdentity {
|
|
auth_changed: false,
|
|
session_source: &SessionSource::Exec,
|
|
originator: &first_originator,
|
|
disabled_plugin_ids: &[],
|
|
environments: McpEnvironmentScope::Selected(
|
|
&first_session.services.turn_environments.selections(),
|
|
),
|
|
},
|
|
/*ready_selected_capability_roots*/ &[],
|
|
/*executor_capability_discovery*/ None,
|
|
/*executor_capability_discovery_v2*/ None,
|
|
)
|
|
.await;
|
|
let second_session = &second_thread.thread.session;
|
|
let second_originator = second_session.originator().await;
|
|
let second_resolved = second_session
|
|
.services
|
|
.mcp_manager
|
|
.runtime_config_for_step(
|
|
&config,
|
|
&second_session.services.mcp_thread_init,
|
|
&second_session.services.thread_extension_data,
|
|
McpThreadIdentity {
|
|
auth_changed: false,
|
|
session_source: &second_session_source,
|
|
originator: &second_originator,
|
|
disabled_plugin_ids: &[],
|
|
environments: McpEnvironmentScope::Selected(
|
|
&second_session.services.turn_environments.selections(),
|
|
),
|
|
},
|
|
/*ready_selected_capability_roots*/ &[],
|
|
/*executor_capability_discovery*/ None,
|
|
/*executor_capability_discovery_v2*/ None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
*lifecycle_observed
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner),
|
|
vec![
|
|
(first_thread.thread_id.to_string(), "selected-a".to_string()),
|
|
(
|
|
second_thread.thread_id.to_string(),
|
|
"selected-b".to_string()
|
|
),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
*mcp_observed
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner),
|
|
vec![
|
|
("selected-a".to_string(), SessionSource::Exec),
|
|
("selected-b".to_string(), second_session_source.clone()),
|
|
("selected-a".to_string(), SessionSource::Exec),
|
|
("selected-b".to_string(), second_session_source),
|
|
]
|
|
);
|
|
let selected_servers = |config: &codex_mcp::McpConfig| {
|
|
codex_mcp::configured_mcp_servers(config)
|
|
.into_iter()
|
|
.filter(|(name, _)| name.starts_with("selected-"))
|
|
.map(|(name, server)| (name, server.environment_id))
|
|
.collect::<std::collections::BTreeMap<_, _>>()
|
|
};
|
|
assert_eq!(
|
|
selected_servers(&first_resolved.config),
|
|
std::collections::BTreeMap::from([("selected-a".to_string(), "env-a".to_string())])
|
|
);
|
|
assert_eq!(
|
|
selected_servers(&second_resolved.config),
|
|
std::collections::BTreeMap::from([("selected-b".to_string(), "env-b".to_string())])
|
|
);
|
|
for (config, name, source_environment_id) in [
|
|
(&first_resolved.config, "selected-a", "env-a"),
|
|
(&second_resolved.config, "selected-b", "env-b"),
|
|
] {
|
|
let server = config
|
|
.mcp_server_catalog
|
|
.server(name)
|
|
.expect("selected plugin server should be registered");
|
|
let plugin_id = format!("plugin-{name}");
|
|
let mut expected = codex_mcp::ResolvedMcpCatalog::builder();
|
|
expected.register(codex_mcp::McpServerRegistration::from_selected_plugin(
|
|
name.to_string(),
|
|
codex_mcp::McpPluginAttribution::new(plugin_id.clone(), plugin_id),
|
|
/*selection_order*/ 0,
|
|
source_environment_id,
|
|
server.config().clone(),
|
|
));
|
|
assert_eq!(Some(server), expected.build().server(name));
|
|
}
|
|
let codex_apps_server = codex_mcp::configured_mcp_servers(&first_resolved.config)
|
|
.remove(codex_mcp::CODEX_APPS_MCP_SERVER_NAME)
|
|
.expect("Codex Apps server should be configured");
|
|
let codex_apps_headers = match codex_apps_server.transport {
|
|
codex_config::McpServerTransportConfig::StreamableHttp { http_headers, .. } => http_headers,
|
|
codex_config::McpServerTransportConfig::Stdio { .. } => {
|
|
panic!("Codex Apps server should use streamable HTTP")
|
|
}
|
|
};
|
|
assert_eq!(
|
|
codex_apps_headers
|
|
.expect("Codex Apps headers should be configured")
|
|
.get("originator"),
|
|
Some(&"codex_work_desktop".to_string())
|
|
);
|
|
for (policy, disabled_plugin_ids, selected_enabled, expected_mcp_loads) in [
|
|
("", vec!["plugin-selected-a".to_string()], false, 1),
|
|
("", vec![], true, 1),
|
|
("[plugins._default]\nenabled = false", vec![], false, 0),
|
|
(
|
|
"[plugins._default]\nenabled = false\n[plugins.plugin-selected-a]\nenabled = true",
|
|
vec![],
|
|
true,
|
|
1,
|
|
),
|
|
] {
|
|
let mut config = config.clone();
|
|
config.config_layer_stack = config
|
|
.config_layer_stack
|
|
.with_user_config(
|
|
&config.codex_home.join("config.toml").abs(),
|
|
toml::from_str(policy).expect("plugin policy"),
|
|
)
|
|
.expect("plugin policy layers");
|
|
config.plugins = toml::from_str::<codex_config::config_toml::ConfigToml>(policy)
|
|
.unwrap()
|
|
.plugins;
|
|
let loaded_before = mcp_loaded.load(Ordering::Relaxed);
|
|
let projection = first_session
|
|
.services
|
|
.mcp_manager
|
|
.runtime_config_for_step(
|
|
&config,
|
|
&first_session.services.mcp_thread_init,
|
|
&first_session.services.thread_extension_data,
|
|
McpThreadIdentity {
|
|
auth_changed: false,
|
|
session_source: &SessionSource::Exec,
|
|
originator: &first_originator,
|
|
disabled_plugin_ids: &disabled_plugin_ids,
|
|
environments: McpEnvironmentScope::Selected(
|
|
&first_session.services.turn_environments.selections(),
|
|
),
|
|
},
|
|
/*ready_selected_capability_roots*/ &[],
|
|
/*executor_capability_discovery*/ None,
|
|
/*executor_capability_discovery_v2*/ None,
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
projection.selected_plugins.disabled_plugin_roots,
|
|
if selected_enabled {
|
|
vec![]
|
|
} else {
|
|
vec!["selected-a".to_string()]
|
|
}
|
|
);
|
|
assert_eq!(
|
|
selected_servers(&projection.config).contains_key("selected-a"),
|
|
selected_enabled
|
|
);
|
|
assert_eq!(
|
|
projection
|
|
.config
|
|
.connector_snapshot
|
|
.disabled_connector_ids()
|
|
.contains("selected-a-connector"),
|
|
!disabled_plugin_ids.is_empty()
|
|
);
|
|
assert_eq!(
|
|
projection.selected_plugins.plugins.len(),
|
|
usize::from(selected_enabled)
|
|
);
|
|
assert_eq!(
|
|
mcp_loaded.load(Ordering::Relaxed) - loaded_before,
|
|
expected_mcp_loads
|
|
);
|
|
// Installation-derived rejections must not become global plugin-ID denials.
|
|
let ready_roots = vec![SelectedCapabilityRoot {
|
|
id: "selected-a".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "env-a".to_string(),
|
|
path: PathUri::parse("file:///plugins/selected-a").expect("plugin root URI"),
|
|
},
|
|
}];
|
|
let selected = first_session
|
|
.services
|
|
.mcp_manager
|
|
.selected_plugins_for_step(
|
|
codex_extension_api::McpServerContributionContext::for_step(
|
|
&config,
|
|
&first_session.services.mcp_thread_init,
|
|
&first_session.services.thread_extension_data,
|
|
&first_originator,
|
|
&ready_roots,
|
|
/*executor_capability_discovery*/ None,
|
|
/*executor_capability_discovery_v2*/ None,
|
|
)
|
|
.with_selected_environments(&[])
|
|
.with_session_source(&SessionSource::Exec),
|
|
&config.plugins,
|
|
&disabled_plugin_ids,
|
|
)
|
|
.await;
|
|
let mut selected_disabled_ids = selected.disabled_plugin_ids;
|
|
selected_disabled_ids.sort();
|
|
selected_disabled_ids.dedup();
|
|
assert_eq!(selected_disabled_ids, disabled_plugin_ids);
|
|
assert_eq!(
|
|
selected.disabled_plugin_roots,
|
|
projection.selected_plugins.disabled_plugin_roots
|
|
);
|
|
assert_eq!(selected.plugins.len(), usize::from(selected_enabled));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn selected_capability_roots_round_trip_through_fork() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let selected_roots = vec![SelectedCapabilityRoot {
|
|
id: "demo@1".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "build".to_string(),
|
|
path: PathUri::parse("file:///plugins/demo").expect("plugin root URI"),
|
|
},
|
|
}];
|
|
let inherited = manager
|
|
.start_thread(StartThreadOptions {
|
|
initial_history: InitialHistory::Forked(vec![RolloutItem::SessionMeta(Box::new(
|
|
SessionMetaLine {
|
|
meta: SessionMeta {
|
|
selected_capability_roots: selected_roots.clone(),
|
|
..SessionMeta::default()
|
|
},
|
|
git: None,
|
|
},
|
|
))]),
|
|
environments: Some(Vec::new()),
|
|
..StartThreadOptions::new(config)
|
|
})
|
|
.await
|
|
.expect("start inherited fork");
|
|
inherited.thread.ensure_rollout_materialized().await;
|
|
inherited
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush inherited fork");
|
|
let inherited_history = RolloutRecorder::get_rollout_history(
|
|
&inherited
|
|
.thread
|
|
.rollout_path()
|
|
.expect("inherited fork rollout path"),
|
|
)
|
|
.await
|
|
.expect("read inherited fork rollout");
|
|
|
|
assert_eq!(
|
|
inherited_history.get_selected_capability_roots(),
|
|
selected_roots
|
|
);
|
|
}
|
|
|
|
/// Shared startup honors explicit root selections and only inherits roots when isolation permits.
|
|
#[tokio::test]
|
|
async fn selected_capability_roots_respect_startup_precedence_and_isolation() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let root = |id: &str| SelectedCapabilityRoot {
|
|
id: id.to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "local".to_string(),
|
|
path: PathUri::from_abs_path(&config.cwd),
|
|
},
|
|
};
|
|
let mut parent_init = ExtensionDataInit::new();
|
|
parent_init.insert(vec![root("parent")]);
|
|
let parent = manager
|
|
.start_thread(StartThreadOptions {
|
|
thread_extension_init: parent_init,
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start parent");
|
|
let parent_environments = parent
|
|
.thread
|
|
.session
|
|
.services
|
|
.turn_environments
|
|
.snapshot()
|
|
.await;
|
|
|
|
for (isolation, explicit_roots, expected_roots) in [
|
|
(SessionIsolation::Inherit, None, vec![root("parent")]),
|
|
(
|
|
SessionIsolation::Inherit,
|
|
Some(vec![root("explicit")]),
|
|
vec![root("explicit")],
|
|
),
|
|
(SessionIsolation::Inherit, Some(Vec::new()), Vec::new()),
|
|
(SessionIsolation::Isolated, None, vec![root("saved")]),
|
|
(
|
|
SessionIsolation::Isolated,
|
|
Some(vec![root("explicit")]),
|
|
vec![root("explicit")],
|
|
),
|
|
] {
|
|
let mut thread_extension_init = ExtensionDataInit::new();
|
|
thread_extension_init.insert(isolation);
|
|
if let Some(roots) = explicit_roots {
|
|
thread_extension_init.insert(roots);
|
|
}
|
|
let child = manager
|
|
.start_thread(StartThreadOptions {
|
|
initial_history: InitialHistory::Forked(vec![RolloutItem::SessionMeta(Box::new(
|
|
SessionMetaLine {
|
|
meta: SessionMeta {
|
|
selected_capability_roots: vec![root("saved")],
|
|
..SessionMeta::default()
|
|
},
|
|
git: None,
|
|
},
|
|
))]),
|
|
inherited_environments: Some(parent_environments.clone()),
|
|
thread_extension_init,
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start child");
|
|
assert_eq!(
|
|
child.thread.session.services.selected_capability_roots,
|
|
expected_roots
|
|
);
|
|
assert_eq!(
|
|
child
|
|
.thread
|
|
.session
|
|
.services
|
|
.turn_environments
|
|
.snapshot()
|
|
.await
|
|
.selected_capability_roots(),
|
|
expected_roots
|
|
);
|
|
child.thread.ensure_rollout_materialized().await;
|
|
child.thread.flush_rollout().await.expect("flush child");
|
|
let history = RolloutRecorder::get_rollout_history(
|
|
&child.thread.rollout_path().expect("child rollout path"),
|
|
)
|
|
.await
|
|
.expect("read child rollout");
|
|
assert_eq!(history.get_selected_capability_roots(), expected_roots);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
|
|
/*agent_graph_store*/ None,
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
let selected_cwd =
|
|
AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path");
|
|
std::fs::create_dir_all(&selected_cwd).expect("create selected cwd");
|
|
let environments = vec![TurnEnvironmentSelection {
|
|
selected_capability_roots: Default::default(),
|
|
environment_id: "local".to_string(),
|
|
cwd: PathUri::from_abs_path(&selected_cwd),
|
|
workspace_roots: Vec::new(),
|
|
config: EnvironmentConfigState::FromThread,
|
|
}];
|
|
let default_cwd = config.cwd.clone();
|
|
let mut source_config = config.clone();
|
|
source_config.cwd = selected_cwd.clone();
|
|
let source = manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
environments: Some(
|
|
environments
|
|
.clone()
|
|
.into_iter()
|
|
.map(TurnEnvironmentSelection::into_request)
|
|
.collect(),
|
|
),
|
|
..StartThreadOptions::new(source_config)
|
|
})
|
|
.await
|
|
.expect("start source thread");
|
|
source.thread.ensure_rollout_materialized().await;
|
|
source
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush source rollout");
|
|
let rollout_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source thread before resume");
|
|
let _ = manager.remove_thread(&source.thread_id).await;
|
|
|
|
let resumed = manager
|
|
.resume_legacy_thread_from_rollout(
|
|
config.clone(),
|
|
rollout_path.clone(),
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
ClientMcpExtensions::default(),
|
|
)
|
|
.await
|
|
.expect("resume source thread");
|
|
let (prepared_turn, _) = resumed
|
|
.thread
|
|
.session
|
|
.new_turn_with_sub_id(
|
|
"resume-turn".to_string(),
|
|
SessionSettingsUpdate::default(),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.expect("build resumed turn context");
|
|
let resumed_turn = prepared_turn;
|
|
assert_eq!(
|
|
resumed_turn
|
|
.initial_environments
|
|
.turn_environments()
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
resumed_turn
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.cwd(),
|
|
&PathUri::from_abs_path(&default_cwd)
|
|
);
|
|
assert_ne!(
|
|
resumed_turn
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.cwd(),
|
|
&PathUri::from_abs_path(&selected_cwd)
|
|
);
|
|
|
|
let forked = manager
|
|
.fork_legacy_thread(
|
|
ForkSnapshot::Interrupted,
|
|
crate::StartThreadOptions::new(config),
|
|
rollout_path,
|
|
)
|
|
.await
|
|
.expect("fork source thread");
|
|
let (prepared_turn, _) = forked
|
|
.thread
|
|
.session
|
|
.new_turn_with_sub_id(
|
|
"fork-turn".to_string(),
|
|
SessionSettingsUpdate::default(),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.expect("build forked turn context");
|
|
let forked_turn = prepared_turn;
|
|
assert_eq!(
|
|
forked_turn.initial_environments.turn_environments().count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
forked_turn
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.cwd(),
|
|
&PathUri::from_abs_path(&default_cwd)
|
|
);
|
|
assert_ne!(
|
|
forked_turn
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.cwd(),
|
|
&PathUri::from_abs_path(&selected_cwd)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explicit_installation_id_skips_codex_home_file() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let installation_id = uuid::Uuid::new_v4().to_string();
|
|
let state_db = init_state_db(&config).await;
|
|
let thread_store = ThreadStoreBuilder::from_config(&config, state_db.clone()).build();
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
installation_id.clone(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
thread_store,
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let thread = manager
|
|
.start_thread(StartThreadOptions::new(config.clone()))
|
|
.await
|
|
.expect("start thread with explicit installation id");
|
|
|
|
assert!(!config.codex_home.join(INSTALLATION_ID_FILENAME).exists());
|
|
assert_eq!(thread.thread.session.installation_id, installation_id);
|
|
|
|
thread
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown thread");
|
|
let _ = manager.remove_thread(&thread.thread_id).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_active_thread_from_rollout_returns_running_thread() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
|
|
/*agent_graph_store*/ None,
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start source thread");
|
|
source.thread.ensure_rollout_materialized().await;
|
|
source
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush source rollout");
|
|
let rollout_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
|
|
let resumed = manager
|
|
.resume_legacy_thread_from_rollout(
|
|
config,
|
|
rollout_path,
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
ClientMcpExtensions::default(),
|
|
)
|
|
.await
|
|
.expect("resume active source thread");
|
|
assert_eq!(resumed.thread_id, source.thread_id);
|
|
assert!(Arc::ptr_eq(&resumed.thread, &source.thread));
|
|
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source thread");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
|
|
/*agent_graph_store*/ None,
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start source thread");
|
|
source.thread.ensure_rollout_materialized().await;
|
|
source
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush source rollout");
|
|
let rollout_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source thread");
|
|
|
|
let resumed = manager
|
|
.resume_legacy_thread_from_rollout(
|
|
config,
|
|
rollout_path,
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
ClientMcpExtensions::default(),
|
|
)
|
|
.await
|
|
.expect("resume stopped source thread");
|
|
assert_eq!(resumed.thread_id, source.thread_id);
|
|
assert!(!Arc::ptr_eq(&resumed.thread, &source.thread));
|
|
|
|
resumed
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown resumed thread");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let state_db = init_state_db(&config).await;
|
|
let thread_store = ThreadStoreBuilder::from_config(&config, state_db.clone()).build();
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
thread_store,
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
thread_source: Some(ThreadSource::User),
|
|
environments: Some(Vec::new()),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("start source thread");
|
|
source.thread.ensure_rollout_materialized().await;
|
|
source
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush source rollout");
|
|
let rollout_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source thread before resume");
|
|
let _ = manager.remove_thread(&source.thread_id).await;
|
|
|
|
let resumed = manager
|
|
.resume_legacy_thread_from_rollout(
|
|
config,
|
|
rollout_path,
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
ClientMcpExtensions::default(),
|
|
)
|
|
.await
|
|
.expect("resume source thread");
|
|
|
|
assert_eq!(
|
|
resumed
|
|
.thread
|
|
.config_snapshot()
|
|
.await
|
|
.thread_source
|
|
.as_ref(),
|
|
Some(&ThreadSource::User)
|
|
);
|
|
|
|
resumed
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown resumed thread");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn subtree_listing_uses_injected_graph_store_without_state_db() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let root_thread_id = ThreadId::new();
|
|
let descendant_thread_ids = vec![ThreadId::new(), ThreadId::new()];
|
|
let agent_graph_store = Arc::new(FakeAgentGraphStore {
|
|
root_thread_id,
|
|
descendant_thread_ids: descendant_thread_ids.clone(),
|
|
});
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
|
|
Some(agent_graph_store),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let mut expected_thread_ids = vec![root_thread_id];
|
|
expected_thread_ids.extend(descendant_thread_ids);
|
|
assert_eq!(
|
|
manager
|
|
.list_agent_subtree_thread_ids(root_thread_id)
|
|
.await
|
|
.expect("subtree should load from injected graph store"),
|
|
expected_thread_ids
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
config.experimental_thread_store = ThreadStoreConfig::InMemory {
|
|
id: format!("thread-manager-{}", uuid::Uuid::new_v4()),
|
|
};
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let state_db = init_state_db(&config).await;
|
|
let thread_store = ThreadStoreBuilder::from_config(&config, state_db.clone()).build();
|
|
let in_memory_store = thread_store
|
|
.as_any()
|
|
.downcast_ref::<InMemoryThreadStore>()
|
|
.expect("configured in-memory store");
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
thread_store.clone(),
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread(StartThreadOptions::new(config.clone()))
|
|
.await
|
|
.expect("start source thread");
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source thread");
|
|
let _ = manager.remove_thread(&source.thread_id).await;
|
|
|
|
let rollout_path = config
|
|
.codex_home
|
|
.join("rollouts/source.jsonl")
|
|
.to_path_buf();
|
|
let resumed = manager
|
|
.resume_thread_with_history(
|
|
config.clone(),
|
|
InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
conversation_id: source.thread_id,
|
|
history: Arc::new(vec![RolloutItem::ResponseItem(user_msg("hello").into())]),
|
|
rollout_path: Some(rollout_path.clone()),
|
|
}),
|
|
auth_manager.clone(),
|
|
/*parent_trace*/ None,
|
|
ClientMcpExtensions::default(),
|
|
)
|
|
.await
|
|
.expect("seed rollout path in store");
|
|
resumed
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown seeded resumed thread");
|
|
let _ = manager.remove_thread(&resumed.thread_id).await;
|
|
|
|
let resumed_from_path = manager
|
|
.resume_legacy_thread_from_rollout(
|
|
config.clone(),
|
|
rollout_path.clone(),
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
ClientMcpExtensions::default(),
|
|
)
|
|
.await
|
|
.expect("resume from rollout path");
|
|
assert_eq!(resumed_from_path.thread_id, resumed.thread_id);
|
|
|
|
let forked = manager
|
|
.fork_legacy_thread(
|
|
ForkSnapshot::Interrupted,
|
|
crate::StartThreadOptions::new(config),
|
|
rollout_path,
|
|
)
|
|
.await
|
|
.expect("fork from rollout path");
|
|
assert_ne!(forked.thread_id, resumed.thread_id);
|
|
|
|
let calls = in_memory_store.calls().await;
|
|
assert_eq!(calls.read_thread_by_rollout_path, 2);
|
|
|
|
resumed_from_path
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown path-resumed thread");
|
|
forked
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown forked thread");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn metadata_update_without_result_reads_only_when_the_caller_needs_the_thread() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
config.experimental_thread_store = ThreadStoreConfig::InMemory {
|
|
id: format!("metadata-update-none-{}", uuid::Uuid::new_v4()),
|
|
};
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let thread_store = ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build();
|
|
let in_memory_store = thread_store
|
|
.as_any()
|
|
.downcast_ref::<InMemoryThreadStore>()
|
|
.expect("configured in-memory store");
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
thread_store.clone(),
|
|
/*agent_graph_store*/ None,
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
let started = manager
|
|
.start_thread(StartThreadOptions::new(config))
|
|
.await
|
|
.expect("start thread");
|
|
started
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush initial metadata");
|
|
manager
|
|
.update_thread_metadata(
|
|
started.thread_id,
|
|
ThreadMetadataPatch {
|
|
name: Some(Some("initial name".to_string())),
|
|
..Default::default()
|
|
},
|
|
/*include_archived*/ false,
|
|
)
|
|
.await
|
|
.expect("flush pending live metadata before measuring calls");
|
|
in_memory_store.omit_metadata_update_result_for_testing();
|
|
|
|
let before_loaded_update = in_memory_store.calls().await;
|
|
let loaded = manager
|
|
.update_thread_metadata(
|
|
started.thread_id,
|
|
ThreadMetadataPatch {
|
|
name: Some(Some("loaded name".to_string())),
|
|
..Default::default()
|
|
},
|
|
/*include_archived*/ false,
|
|
)
|
|
.await
|
|
.expect("update loaded thread metadata");
|
|
assert_eq!(loaded.name.as_deref(), Some("loaded name"));
|
|
let after_loaded_update = in_memory_store.calls().await;
|
|
assert_eq!(
|
|
after_loaded_update.update_thread_metadata,
|
|
before_loaded_update.update_thread_metadata + 1
|
|
);
|
|
assert_eq!(
|
|
after_loaded_update.read_thread,
|
|
before_loaded_update.read_thread + 1
|
|
);
|
|
|
|
started
|
|
.thread
|
|
.append_rollout_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(
|
|
UserMessageEvent {
|
|
message: "completion-only metadata".to_string(),
|
|
..Default::default()
|
|
},
|
|
))])
|
|
.await
|
|
.expect("append item with derived metadata");
|
|
let after_completion_only_update = in_memory_store.calls().await;
|
|
assert_eq!(
|
|
after_completion_only_update.update_thread_metadata,
|
|
after_loaded_update.update_thread_metadata + 1
|
|
);
|
|
assert_eq!(
|
|
after_completion_only_update.read_thread,
|
|
after_loaded_update.read_thread
|
|
);
|
|
|
|
started
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown loaded thread");
|
|
let _ = manager.remove_thread(&started.thread_id).await;
|
|
let before_cold_update = in_memory_store.calls().await;
|
|
let cold = manager
|
|
.update_thread_metadata(
|
|
started.thread_id,
|
|
ThreadMetadataPatch {
|
|
name: Some(Some("cold name".to_string())),
|
|
..Default::default()
|
|
},
|
|
/*include_archived*/ false,
|
|
)
|
|
.await
|
|
.expect("update cold thread metadata");
|
|
assert_eq!(cold.name.as_deref(), Some("cold name"));
|
|
let after_cold_update = in_memory_store.calls().await;
|
|
assert_eq!(
|
|
after_cold_update.update_thread_metadata,
|
|
before_cold_update.update_thread_metadata + 1
|
|
);
|
|
assert_eq!(
|
|
after_cold_update.read_thread,
|
|
before_cold_update.read_thread + 1
|
|
);
|
|
}
|
|
|
|
#[test_case::test_case(true; "disk_cache")]
|
|
#[test_case::test_case(false; "without_cache")]
|
|
#[tokio::test]
|
|
async fn new_uses_active_provider_for_model_refresh(with_cache: bool) {
|
|
let server = MockServer::start().await;
|
|
let models_mock = mount_models_once(&server, ModelsResponse { models: vec![] }).await;
|
|
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
config.model_catalog = None;
|
|
config.model_provider.base_url = Some(server.uri());
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(if with_cache {
|
|
ModelCatalogSettings::disk(config.codex_home.to_path_buf())
|
|
} else {
|
|
ModelCatalogSettings::default()
|
|
}),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
|
|
/*agent_graph_store*/ None,
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let _ = manager
|
|
.list_models(
|
|
RefreshStrategy::Online,
|
|
crate::test_support::default_http_client_factory(),
|
|
)
|
|
.await;
|
|
assert_eq!(models_mock.requests().len(), 1);
|
|
|
|
let mut thread_config = config.clone();
|
|
thread_config.model = Some("remote-search-model".to_string());
|
|
thread_config.model_provider.request_max_retries = Some(1);
|
|
thread_config.model_provider.stream_max_retries = Some(1);
|
|
thread_config
|
|
.model_provider
|
|
.http_headers
|
|
.get_or_insert_default()
|
|
.insert("X-OpenAI-Product-Sku".to_string(), "test-product".into());
|
|
|
|
// Thread startup must select the originator catalog before resolving model metadata.
|
|
for originator in [
|
|
"codex_work_cca",
|
|
"codex_work_web",
|
|
"codex_work_desktop",
|
|
"codex_work_mobile",
|
|
"chatgpt_cca",
|
|
] {
|
|
let mut model =
|
|
codex_models_manager::model_info::model_info_from_slug("remote-search-model");
|
|
model.description = Some(originator.to_string());
|
|
model.supports_search_tool = true;
|
|
model.tool_mode = Some(codex_protocol::openai_models::ToolMode::CodeModeOnly);
|
|
model.use_responses_lite = true;
|
|
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
|
.and(wiremock::matchers::path("/models"))
|
|
.and(wiremock::matchers::header("originator", originator))
|
|
.and(wiremock::matchers::header(
|
|
"X-OpenAI-Product-Sku",
|
|
"test-product",
|
|
))
|
|
.respond_with(
|
|
wiremock::ResponseTemplate::new(200).set_body_json(ModelsResponse {
|
|
models: vec![model],
|
|
}),
|
|
)
|
|
.expect(if with_cache { 1..=1 } else { 1..=2 })
|
|
.mount(&server)
|
|
.await;
|
|
let started = manager
|
|
.start_thread(StartThreadOptions {
|
|
metrics_service_name: Some(originator.to_string()),
|
|
environments: Some(Vec::new()),
|
|
..StartThreadOptions::new(thread_config.clone())
|
|
})
|
|
.await
|
|
.expect("start scoped thread");
|
|
let model = started
|
|
.thread
|
|
.session
|
|
.services
|
|
.models_manager
|
|
.get_model_info(
|
|
"remote-search-model",
|
|
&thread_config.to_models_manager_config(),
|
|
)
|
|
.await;
|
|
assert_eq!(model.description.as_deref(), Some(originator));
|
|
assert!(model.supports_search_tool);
|
|
|
|
let child = manager
|
|
.state
|
|
.spawn_child_thread(
|
|
StartThreadOptions {
|
|
session_source: Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
parent_thread_id: started.thread_id,
|
|
depth: 1,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
})),
|
|
environments: Some(Vec::new()),
|
|
..StartThreadOptions::new(thread_config.clone())
|
|
},
|
|
started
|
|
.thread
|
|
.session
|
|
.services
|
|
.local_agent_runtime
|
|
.control(started.thread.session.session_id()),
|
|
Some(started.thread_id),
|
|
/*inherited_exec_policy*/ None,
|
|
)
|
|
.await
|
|
.expect("spawn child with the parent's provider headers");
|
|
let (turn, _) = child
|
|
.thread
|
|
.session
|
|
.new_turn_with_sub_id(
|
|
"child-turn".to_string(),
|
|
SessionSettingsUpdate::default(),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.expect("prepare child's first turn");
|
|
assert_eq!(turn.model_info().as_ref(), &model);
|
|
}
|
|
let legacy = manager
|
|
.start_thread(StartThreadOptions {
|
|
metrics_service_name: Some("codex_cli_rs".to_string()),
|
|
environments: Some(Vec::new()),
|
|
..StartThreadOptions::new(config)
|
|
})
|
|
.await
|
|
.expect("start legacy thread");
|
|
assert!(Arc::ptr_eq(
|
|
&legacy.thread.session.services.models_manager,
|
|
&manager.get_models_manager()
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_catalog_cache_policy_controls_refresh() {
|
|
let server = MockServer::start().await;
|
|
let _ = mount_models_once(&server, ModelsResponse { models: vec![] }).await;
|
|
let _ = mount_models_once(&server, ModelsResponse { models: vec![] }).await;
|
|
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
config.model_catalog = None;
|
|
config.model_provider.base_url = Some(server.uri());
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager,
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::default()),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Custom("test-embedder".to_string()),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
|
|
/*agent_graph_store*/ None,
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let http_client_factory = crate::test_support::default_http_client_factory();
|
|
let _ = manager
|
|
.list_models(
|
|
RefreshStrategy::OnlineIfUncached,
|
|
http_client_factory.clone(),
|
|
)
|
|
.await;
|
|
let _ = manager
|
|
.list_models(RefreshStrategy::OnlineIfUncached, http_client_factory)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
server.received_requests().await.unwrap_or_default().len(),
|
|
2
|
|
);
|
|
assert!(!config.codex_home.join("models_cache.json").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn interrupted_fork_snapshot_appends_interrupt_boundary() {
|
|
let committed_history =
|
|
InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("hello").into())]);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(
|
|
append_interrupted_boundary(
|
|
committed_history,
|
|
/*turn_id*/ None,
|
|
/*root_turn_id*/ None,
|
|
/*started_at*/ None,
|
|
InterruptedTurnHistoryMarker::ContextualUser,
|
|
)
|
|
.get_rollout_items()
|
|
)
|
|
.expect("serialize interrupted fork history"),
|
|
serde_json::to_value(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello").into()),
|
|
RolloutItem::ResponseItem(contextual_user_interrupted_marker().into()),
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
root_turn_id: None,
|
|
turn_id: None,
|
|
started_at: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
error: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
})),
|
|
])
|
|
.expect("serialize expected interrupted fork history"),
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(
|
|
append_interrupted_boundary(
|
|
InitialHistory::New,
|
|
/*turn_id*/ None,
|
|
/*root_turn_id*/ None,
|
|
/*started_at*/ None,
|
|
InterruptedTurnHistoryMarker::ContextualUser,
|
|
)
|
|
.get_rollout_items()
|
|
)
|
|
.expect("serialize interrupted empty fork history"),
|
|
serde_json::to_value(vec![
|
|
RolloutItem::ResponseItem(contextual_user_interrupted_marker().into()),
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
root_turn_id: None,
|
|
turn_id: None,
|
|
started_at: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
error: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
})),
|
|
])
|
|
.expect("serialize expected interrupted empty history"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_interrupted_fork_snapshot_appends_only_interrupt_event() {
|
|
let committed_history =
|
|
InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("hello").into())]);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(
|
|
append_interrupted_boundary(
|
|
committed_history,
|
|
/*turn_id*/ None,
|
|
/*root_turn_id*/ None,
|
|
/*started_at*/ None,
|
|
InterruptedTurnHistoryMarker::Disabled,
|
|
)
|
|
.get_rollout_items()
|
|
)
|
|
.expect("serialize disabled interrupted fork history"),
|
|
serde_json::to_value(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello").into()),
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
root_turn_id: None,
|
|
turn_id: None,
|
|
started_at: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
error: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
})),
|
|
])
|
|
.expect("serialize expected disabled interrupted fork history"),
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(
|
|
append_interrupted_boundary(
|
|
InitialHistory::New,
|
|
/*turn_id*/ None,
|
|
/*root_turn_id*/ None,
|
|
/*started_at*/ None,
|
|
InterruptedTurnHistoryMarker::Disabled,
|
|
)
|
|
.get_rollout_items()
|
|
)
|
|
.expect("serialize disabled interrupted empty fork history"),
|
|
serde_json::to_value(vec![RolloutItem::EventMsg(EventMsg::TurnAborted(
|
|
TurnAbortedEvent {
|
|
root_turn_id: None,
|
|
turn_id: None,
|
|
started_at: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
error: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
))])
|
|
.expect("serialize expected disabled interrupted empty fork history"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interrupted_snapshot_is_not_mid_turn() {
|
|
let interrupted_history = InitialHistory::Forked(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello").into()),
|
|
RolloutItem::ResponseItem(assistant_msg("partial").into()),
|
|
RolloutItem::ResponseItem(contextual_user_interrupted_marker().into()),
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
root_turn_id: None,
|
|
turn_id: Some("turn-1".to_string()),
|
|
started_at: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
error: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
})),
|
|
]);
|
|
|
|
assert_eq!(
|
|
snapshot_turn_state(&interrupted_history),
|
|
SnapshotTurnState {
|
|
ends_mid_turn: false,
|
|
active_turn_id: None,
|
|
active_turn_started_at: None,
|
|
active_turn_root_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_agent_v2_interrupted_marker_uses_developer_input_message() {
|
|
assert_eq!(
|
|
developer_interrupted_marker(),
|
|
ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
role: "developer".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: format!(
|
|
"<turn_aborted>\n{}\n</turn_aborted>",
|
|
crate::context::TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE
|
|
),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: Some(
|
|
InternalChatMessageMetadataPassthrough {
|
|
content_item_kinds: Some(vec![ContentItemKind(
|
|
"generic.turn_aborted".to_string()
|
|
)]),
|
|
content_item_metadata: Some(vec![ContentItemMetadata::harness()]),
|
|
..Default::default()
|
|
}
|
|
),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completed_legacy_event_history_is_not_mid_turn() {
|
|
let completed_history = InitialHistory::Forked(vec![
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: None,
|
|
message: "hello".to_string(),
|
|
images: None,
|
|
text_elements: Vec::new(),
|
|
local_images: Vec::new(),
|
|
..Default::default()
|
|
})),
|
|
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
|
|
moderation: None,
|
|
message: "done".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
})),
|
|
]);
|
|
|
|
assert_eq!(
|
|
snapshot_turn_state(&completed_history),
|
|
SnapshotTurnState {
|
|
ends_mid_turn: false,
|
|
active_turn_id: None,
|
|
active_turn_started_at: None,
|
|
active_turn_root_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_response_and_legacy_user_event_history_is_mid_turn() {
|
|
let mixed_history = InitialHistory::Forked(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello").into()),
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: None,
|
|
message: "hello".to_string(),
|
|
images: None,
|
|
text_elements: Vec::new(),
|
|
local_images: Vec::new(),
|
|
..Default::default()
|
|
})),
|
|
]);
|
|
|
|
assert_eq!(
|
|
snapshot_turn_state(&mixed_history),
|
|
SnapshotTurnState {
|
|
ends_mid_turn: true,
|
|
active_turn_id: None,
|
|
active_turn_started_at: None,
|
|
active_turn_root_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_history() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let state_db = init_state_db(&config).await;
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, state_db.clone()).build(),
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
initial_history: InitialHistory::Forked(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello").into()),
|
|
RolloutItem::ResponseItem(assistant_msg("partial").into()),
|
|
]),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("create source thread from completed history");
|
|
let source_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
let source_history = RolloutRecorder::get_rollout_history(&source_path)
|
|
.await
|
|
.expect("read source rollout history");
|
|
let source_snapshot_state = snapshot_turn_state(&source_history);
|
|
assert!(source_snapshot_state.ends_mid_turn);
|
|
let expected_turn_id = source_snapshot_state.active_turn_id.clone();
|
|
assert_eq!(expected_turn_id, None);
|
|
|
|
let forked = manager
|
|
.fork_legacy_thread(
|
|
ForkSnapshot::Interrupted,
|
|
crate::StartThreadOptions::new(config.clone()),
|
|
source_path,
|
|
)
|
|
.await
|
|
.expect("fork interrupted snapshot");
|
|
let forked_path = forked
|
|
.thread
|
|
.rollout_path()
|
|
.expect("forked rollout path should exist");
|
|
let history = RolloutRecorder::get_rollout_history(&forked_path)
|
|
.await
|
|
.expect("read forked rollout history");
|
|
assert!(!snapshot_turn_state(&history).ends_mid_turn);
|
|
let rollout_items: Vec<_> = history
|
|
.get_rollout_items()
|
|
.iter()
|
|
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
|
.collect();
|
|
let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem(
|
|
contextual_user_interrupted_marker().into(),
|
|
))
|
|
.expect("serialize interrupted marker");
|
|
let interrupted_abort_json = serde_json::to_value(RolloutItem::EventMsg(
|
|
EventMsg::TurnAborted(TurnAbortedEvent {
|
|
root_turn_id: None,
|
|
turn_id: expected_turn_id,
|
|
started_at: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
error: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
}),
|
|
))
|
|
.expect("serialize interrupted abort event");
|
|
assert_eq!(
|
|
rollout_items
|
|
.iter()
|
|
.filter(|item| {
|
|
strip_response_item_ids_from_json(
|
|
serde_json::to_value(item).expect("serialize rollout item"),
|
|
) == interrupted_marker_json
|
|
})
|
|
.count(),
|
|
1,
|
|
);
|
|
assert_eq!(
|
|
rollout_items
|
|
.iter()
|
|
.filter(|item| {
|
|
serde_json::to_value(item).expect("serialize rollout item")
|
|
== interrupted_abort_json
|
|
})
|
|
.count(),
|
|
1,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let state_db = init_state_db(&config).await;
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, state_db.clone()).build(),
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
initial_history: InitialHistory::Forked(vec![
|
|
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_attribution: None,
|
|
turn_id: "turn-explicit".to_string(),
|
|
root_turn_id: None,
|
|
trace_id: None,
|
|
started_at: None,
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
})),
|
|
RolloutItem::ResponseItem(user_msg("hello").into()),
|
|
RolloutItem::ResponseItem(assistant_msg("partial").into()),
|
|
]),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("create source thread from explicit partial history");
|
|
let source_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
let source_history = RolloutRecorder::get_rollout_history(&source_path)
|
|
.await
|
|
.expect("read source rollout history");
|
|
let source_snapshot_state = snapshot_turn_state(&source_history);
|
|
assert_eq!(
|
|
source_snapshot_state,
|
|
SnapshotTurnState {
|
|
ends_mid_turn: true,
|
|
active_turn_id: Some("turn-explicit".to_string()),
|
|
active_turn_started_at: None,
|
|
active_turn_root_id: None,
|
|
active_turn_start_index: Some(1),
|
|
},
|
|
);
|
|
|
|
let forked = manager
|
|
.fork_legacy_thread(
|
|
ForkSnapshot::Interrupted,
|
|
crate::StartThreadOptions::new(config.clone()),
|
|
source_path,
|
|
)
|
|
.await
|
|
.expect("fork interrupted snapshot");
|
|
let forked_path = forked
|
|
.thread
|
|
.rollout_path()
|
|
.expect("forked rollout path should exist");
|
|
let history = RolloutRecorder::get_rollout_history(&forked_path)
|
|
.await
|
|
.expect("read forked rollout history");
|
|
let rollout_items: Vec<_> = history
|
|
.get_rollout_items()
|
|
.iter()
|
|
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
|
.collect();
|
|
|
|
assert!(rollout_items.iter().any(|item| {
|
|
matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
root_turn_id: None,
|
|
turn_id: Some(turn_id),
|
|
started_at: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
error: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
})) if turn_id == "turn-explicit"
|
|
)
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_source() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let state_db = init_state_db(&config).await;
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
crate::build_startup_models(
|
|
&config,
|
|
auth_manager.clone(),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
|
|
),
|
|
crate::CodexAppsToolsCache::default(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
passthrough_image_store(),
|
|
ThreadStoreBuilder::from_config(&config, state_db.clone()).build(),
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread(StartThreadOptions {
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
initial_history: InitialHistory::Forked(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello").into()),
|
|
RolloutItem::ResponseItem(assistant_msg("partial").into()),
|
|
]),
|
|
..StartThreadOptions::new(config.clone())
|
|
})
|
|
.await
|
|
.expect("create source thread from partial history");
|
|
let source_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
let source_history = RolloutRecorder::get_rollout_history(&source_path)
|
|
.await
|
|
.expect("read source rollout history");
|
|
assert!(snapshot_turn_state(&source_history).ends_mid_turn);
|
|
manager.remove_thread(&source.thread_id).await;
|
|
|
|
let forked = manager
|
|
.fork_legacy_thread(
|
|
ForkSnapshot::Interrupted,
|
|
crate::StartThreadOptions::new(config.clone()),
|
|
source_path,
|
|
)
|
|
.await
|
|
.expect("fork interrupted snapshot");
|
|
let forked_path = forked
|
|
.thread
|
|
.rollout_path()
|
|
.expect("forked rollout path should exist");
|
|
let history = RolloutRecorder::get_rollout_history(&forked_path)
|
|
.await
|
|
.expect("read forked rollout history");
|
|
assert!(!snapshot_turn_state(&history).ends_mid_turn);
|
|
|
|
let forked_rollout_items: Vec<_> = history
|
|
.get_rollout_items()
|
|
.iter()
|
|
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
|
.collect();
|
|
let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem(
|
|
contextual_user_interrupted_marker().into(),
|
|
))
|
|
.expect("serialize interrupted marker");
|
|
assert_eq!(
|
|
forked_rollout_items
|
|
.iter()
|
|
.filter(|item| {
|
|
strip_response_item_ids_from_json(
|
|
serde_json::to_value(item).expect("serialize forked rollout item"),
|
|
) == interrupted_marker_json
|
|
})
|
|
.count(),
|
|
1,
|
|
);
|
|
|
|
manager.remove_thread(&forked.thread_id).await;
|
|
let reforked = manager
|
|
.fork_legacy_thread(
|
|
ForkSnapshot::Interrupted,
|
|
crate::StartThreadOptions::new(config.clone()),
|
|
forked_path,
|
|
)
|
|
.await
|
|
.expect("re-fork interrupted snapshot");
|
|
let reforked_path = reforked
|
|
.thread
|
|
.rollout_path()
|
|
.expect("re-forked rollout path should exist");
|
|
let reforked_history = RolloutRecorder::get_rollout_history(&reforked_path)
|
|
.await
|
|
.expect("read re-forked rollout history");
|
|
let reforked_rollout_items: Vec<_> = reforked_history
|
|
.get_rollout_items()
|
|
.iter()
|
|
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
reforked_rollout_items
|
|
.iter()
|
|
.filter(|item| {
|
|
strip_response_item_ids_from_json(
|
|
serde_json::to_value(item).expect("serialize re-forked rollout item"),
|
|
) == interrupted_marker_json
|
|
})
|
|
.count(),
|
|
1,
|
|
);
|
|
assert_eq!(
|
|
reforked_rollout_items
|
|
.iter()
|
|
.filter(|item| {
|
|
matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
reason: TurnAbortReason::Interrupted,
|
|
..
|
|
}))
|
|
)
|
|
})
|
|
.count(),
|
|
1,
|
|
);
|
|
}
|