mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## Why Resuming a thread should retain its selected workspace folders, including additional roots and explicit empty selections. Resume overrides also need to survive a subsequent resume when no turn has run. ## What changed - Persist `runtime_workspace_roots` in startup metadata and thread settings snapshots, separately from explicit environment selections and permission-profile roots. - Restore roots from the latest snapshot owned by the resumed thread, falling back to owned startup metadata only when no snapshot exists. Honor explicit `runtimeWorkspaceRoots` overrides, retarget the old `cwd` root when `cwd` changes, deduplicate roots, and validate restored paths for the current host. - Checkpoint effective settings on resume and restored settings after revert. Reload resume configuration if saved workspace roots change during loading. - Normalize Windows rollout path spellings when matching thread search results, preserving selection of the correct rollout after revert, including compressed rollouts. ## Testing Add regression coverage for workspace restoration, empty and explicit overrides, foreign paths, compaction and revert, resume checkpoints without recency changes, concurrent settings persistence, and rollout search path matching. GitOrigin-RevId: d98d9d34dd63934d441120916c61c12b69e7f062
6212 lines
212 KiB
Rust
6212 lines
212 KiB
Rust
use anyhow::Result;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::MockResponsesConfig;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::create_apply_patch_sse_response;
|
|
use app_test_support::create_command_execution_sse_response;
|
|
use app_test_support::create_fake_paginated_rollout;
|
|
use app_test_support::create_fake_rollout;
|
|
use app_test_support::create_fake_rollout_with_source;
|
|
use app_test_support::create_fake_rollout_with_text_elements;
|
|
use app_test_support::create_fake_rollout_with_token_usage;
|
|
use app_test_support::create_final_assistant_message_sse_response;
|
|
use app_test_support::create_mock_responses_server_repeating_assistant;
|
|
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
|
use app_test_support::rollout_path;
|
|
use app_test_support::test_absolute_path;
|
|
use app_test_support::to_response;
|
|
use app_test_support::write_chatgpt_auth;
|
|
use chrono::Utc;
|
|
use codex_app_server_protocol::ActivePermissionProfile;
|
|
use codex_app_server_protocol::ApprovalsReviewer;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
use codex_app_server_protocol::ClientInfo;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
|
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
|
use codex_app_server_protocol::DeprecationNoticeNotification;
|
|
use codex_app_server_protocol::FileChangeApprovalDecision;
|
|
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
|
|
use codex_app_server_protocol::ItemStartedNotification;
|
|
use codex_app_server_protocol::JSONRPCError;
|
|
use codex_app_server_protocol::JSONRPCErrorError;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::McpToolCallAppContext;
|
|
use codex_app_server_protocol::PatchApplyStatus;
|
|
use codex_app_server_protocol::PatchChangeKind;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::SandboxMode;
|
|
use codex_app_server_protocol::SandboxPolicy as AppSandboxPolicy;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ServerRequest;
|
|
use codex_app_server_protocol::SessionSource;
|
|
use codex_app_server_protocol::SortDirection;
|
|
use codex_app_server_protocol::ThreadActiveFlag;
|
|
use codex_app_server_protocol::ThreadForkParams;
|
|
use codex_app_server_protocol::ThreadForkResponse;
|
|
use codex_app_server_protocol::ThreadGoalClearResponse;
|
|
use codex_app_server_protocol::ThreadGoalGetParams;
|
|
use codex_app_server_protocol::ThreadGoalGetResponse;
|
|
use codex_app_server_protocol::ThreadGoalSetResponse;
|
|
use codex_app_server_protocol::ThreadGoalStatus;
|
|
use codex_app_server_protocol::ThreadHistoryMode;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_app_server_protocol::ThreadListResponse;
|
|
use codex_app_server_protocol::ThreadLoadedListParams;
|
|
use codex_app_server_protocol::ThreadLoadedListResponse;
|
|
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
|
|
use codex_app_server_protocol::ThreadMetadataUpdateParams;
|
|
use codex_app_server_protocol::ThreadReadParams;
|
|
use codex_app_server_protocol::ThreadReadResponse;
|
|
use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
use codex_app_server_protocol::ThreadSettingsUpdateParams;
|
|
use codex_app_server_protocol::ThreadSettingsUpdateResponse;
|
|
use codex_app_server_protocol::ThreadSource;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::ThreadStatus;
|
|
use codex_app_server_protocol::ThreadStatusChangedNotification;
|
|
use codex_app_server_protocol::ThreadTurnsListParams;
|
|
use codex_app_server_protocol::ThreadTurnsListResponse;
|
|
use codex_app_server_protocol::ThreadUnsubscribeParams;
|
|
use codex_app_server_protocol::TurnEnvironmentParams;
|
|
use codex_app_server_protocol::TurnItemsView;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStartResponse;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
|
|
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
|
use codex_features::Feature;
|
|
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::config_types::CollaborationMode;
|
|
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::config_types::Personality;
|
|
use codex_protocol::config_types::Settings;
|
|
use codex_protocol::mcp::CallToolResult;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_protocol::protocol::AgentMessageEvent;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::ImageGenerationEndEvent;
|
|
use codex_protocol::protocol::McpInvocation;
|
|
use codex_protocol::protocol::McpToolCallEndEvent;
|
|
use codex_protocol::protocol::MultiAgentVersion;
|
|
use codex_protocol::protocol::SessionMeta;
|
|
use codex_protocol::protocol::SessionMetaLine;
|
|
use codex_protocol::protocol::SessionSource as RolloutSessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
|
|
use codex_protocol::protocol::TokenCountEvent;
|
|
use codex_protocol::protocol::TokenUsage;
|
|
use codex_protocol::protocol::TokenUsageInfo;
|
|
use codex_protocol::protocol::TurnAbortReason;
|
|
use codex_protocol::protocol::TurnAbortedEvent;
|
|
use codex_protocol::protocol::TurnStartedEvent;
|
|
use codex_protocol::user_input::ByteRange;
|
|
use codex_protocol::user_input::TextElement;
|
|
use codex_rollout::CompactedItem;
|
|
use codex_rollout::RolloutItem;
|
|
use codex_rollout::RolloutRecorder;
|
|
use codex_rollout::append_rollout_item_to_path;
|
|
use codex_rollout::read_session_meta_line;
|
|
use codex_state::StateRuntime;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_absolute_path::test_support::PathExt;
|
|
use codex_utils_path_uri::LegacyAppPathString;
|
|
use core_test_support::responses;
|
|
use core_test_support::skip_if_no_network;
|
|
use core_test_support::skip_if_remote;
|
|
use core_test_support::skip_if_wine_exec;
|
|
use core_test_support::streaming_sse::StreamingSseChunk;
|
|
use core_test_support::streaming_sse::start_streaming_sse_server;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use std::fs::FileTimes;
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use tokio::sync::oneshot;
|
|
use tokio::time::timeout;
|
|
use uuid::Uuid;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
use super::analytics::assert_basic_thread_initialized_event;
|
|
use super::analytics::mount_analytics_capture;
|
|
use super::analytics::thread_initialized_event;
|
|
use super::analytics::wait_for_analytics_payload;
|
|
use super::analytics::wait_for_goal_event;
|
|
use super::analytics::wait_for_matching_analytics_event;
|
|
|
|
#[cfg(windows)]
|
|
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
|
|
#[cfg(not(windows))]
|
|
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals.";
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_paginated_model_context_preserves_original_metadata() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let saved_cwd = normalized_existing_path(codex_home.path())?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let conversation_id = create_fake_paginated_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &conversation_id);
|
|
let settings: ThreadSettingsAppliedEvent = serde_json::from_value(json!({
|
|
"thread_id": conversation_id,
|
|
"thread_settings": {
|
|
"model": "gpt-5.4",
|
|
"model_provider_id": "mock_provider",
|
|
"cwd": saved_cwd,
|
|
"approval_policy": "never",
|
|
"approvals_reviewer": "user",
|
|
"permission_profile": PermissionProfile::read_only(),
|
|
"collaboration_mode": { "mode": "default", "settings": { "model": "gpt-5.4" } },
|
|
},
|
|
}))?;
|
|
append_rollout_item_to_path(
|
|
&path,
|
|
&RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(settings)),
|
|
)
|
|
.await?;
|
|
append_rollout_item_to_path(
|
|
&path,
|
|
&RolloutItem::Compacted(CompactedItem {
|
|
message: "compacted history".to_string(),
|
|
replacement_history: Some(Vec::new()),
|
|
retained_context: None,
|
|
guardian_history: None,
|
|
mcp_resource_origins: None,
|
|
window_number: Some(1),
|
|
first_window_id: None,
|
|
previous_window_id: None,
|
|
window_id: None,
|
|
compaction_response_id: None,
|
|
latest_token_usage_record: None,
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
let mut primary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id.clone(),
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed,
|
|
cwd,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, primary.read_response(resume_id)).await??;
|
|
assert_eq!(cwd.as_path(), saved_cwd);
|
|
assert_eq!(resumed.id, conversation_id);
|
|
assert_eq!(resumed.history_mode, ThreadHistoryMode::Paginated);
|
|
assert_eq!(resumed.preview, "Saved user message");
|
|
assert!(resumed.turns.is_empty());
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: conversation_id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "bounded suffix user message".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.shutdown_gracefully()).await??;
|
|
|
|
let mut secondary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = secondary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id.clone(),
|
|
path: Some(path),
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed,
|
|
cwd,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, secondary.read_response(resume_id)).await??;
|
|
// The resume checkpoint keeps the effective cwd inside the bounded replay,
|
|
// even after the original settings snapshot falls outside its window.
|
|
assert_eq!(cwd.as_path(), saved_cwd);
|
|
assert_eq!(resumed.preview, "Saved user message");
|
|
assert!(resumed.turns.is_empty());
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
secondary.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: conversation_id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "resumed user message".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
|
|
let state_db = StateRuntime::init(
|
|
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
|
|
"mock_provider".into(),
|
|
)
|
|
.await?;
|
|
let metadata = state_db
|
|
.get_thread(ThreadId::from_string(&conversation_id)?)
|
|
.await?
|
|
.expect("thread metadata should exist");
|
|
assert_eq!(
|
|
(
|
|
metadata.preview.as_deref(),
|
|
metadata.title.as_str(),
|
|
metadata.first_user_message.as_deref(),
|
|
),
|
|
(
|
|
Some("Saved user message"),
|
|
"Saved user message",
|
|
Some("Saved user message"),
|
|
)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_rejects_legacy_writer_owned_by_another_process() -> Result<()> {
|
|
assert_thread_resume_rejects_writer_owned_by_another_process(ThreadHistoryMode::Legacy).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_rejects_paginated_writer_owned_by_another_process() -> Result<()> {
|
|
assert_thread_resume_rejects_writer_owned_by_another_process(ThreadHistoryMode::Paginated).await
|
|
}
|
|
|
|
async fn assert_thread_resume_rejects_writer_owned_by_another_process(
|
|
history_mode: ThreadHistoryMode,
|
|
) -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut primary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } = primary
|
|
.start_thread(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
history_mode: Some(history_mode),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "first writer".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
|
|
let secondary_sqlite_home = TempDir::new()?;
|
|
let secondary_sqlite_home_path = secondary_sqlite_home.path().to_string_lossy();
|
|
let mut secondary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[(
|
|
"CODEX_SQLITE_HOME",
|
|
Some(secondary_sqlite_home_path.as_ref()),
|
|
)])
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = secondary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let error = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
secondary.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(error.error.code, -32600);
|
|
assert_eq!(
|
|
error.error.message,
|
|
format!("thread {} already has an active writer", thread.id)
|
|
);
|
|
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.shutdown_gracefully()).await??;
|
|
|
|
let next_resume_id = secondary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _: ThreadResumeResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
secondary.read_response(next_resume_id),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
secondary.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "second writer".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn normalized_existing_path(path: impl AsRef<Path>) -> Result<PathBuf> {
|
|
Ok(AbsolutePathBuf::from_absolute_path(path.as_ref().canonicalize()?)?.into_path_buf())
|
|
}
|
|
|
|
async fn wait_for_responses_request_count(
|
|
server: &wiremock::MockServer,
|
|
expected_count: usize,
|
|
) -> Result<()> {
|
|
timeout(DEFAULT_READ_TIMEOUT, async {
|
|
loop {
|
|
let Some(requests) = server.received_requests().await else {
|
|
anyhow::bail!("wiremock did not record requests");
|
|
};
|
|
let responses_request_count = requests
|
|
.iter()
|
|
.filter(|request| {
|
|
request.method == "POST" && request.url.path().ends_with("/responses")
|
|
})
|
|
.count();
|
|
if responses_request_count == expected_count {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
if responses_request_count > expected_count {
|
|
anyhow::bail!(
|
|
"expected exactly {expected_count} /responses requests, got {responses_request_count}"
|
|
);
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
// Start a thread.
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
// Resume should fail before the first user message materializes rollout storage.
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let resume_err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
resume_err
|
|
.error
|
|
.message
|
|
.contains("no rollout found for thread id"),
|
|
"unexpected resume error: {}",
|
|
resume_err.error.message
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize rollout".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
path: Some(PathBuf::new()),
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed, ..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(resumed.id, thread.id);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Result<()> {
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"cached instruction-source fixture is outside the selected remote cwd"
|
|
);
|
|
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let workspace = TempDir::new()?;
|
|
let project_agents = workspace.path().join("AGENTS.md");
|
|
std::fs::write(&project_agents, "project instructions")?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
// TODO(anp): Move the cached instruction-source fixture into the auto environment cwd.
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
cwd: Some(workspace.path().display().to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse {
|
|
thread,
|
|
instruction_sources,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
let project_agents = AbsolutePathBuf::try_from(project_agents)?;
|
|
let project_agents_source = LegacyAppPathString::from_abs_path(&project_agents);
|
|
assert_eq!(instruction_sources, vec![project_agents_source.clone()]);
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize rollout".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
std::fs::remove_file(project_agents.as_path())?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
instruction_sources,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(instruction_sources, vec![project_agents_source]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_restores_startup_and_updated_runtime_workspace_roots() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let initial_workspace = TempDir::new()?;
|
|
let initial_cwd = normalized_existing_path(initial_workspace.path())?.abs();
|
|
let extra_workspace = TempDir::new()?;
|
|
let extra_root = extra_workspace.path();
|
|
let initial_roots = vec![initial_cwd.clone(), extra_root.abs()];
|
|
let explicit_workspace = TempDir::new()?;
|
|
let explicit_cwd = normalized_existing_path(explicit_workspace.path())?.abs();
|
|
let explicit_environments = vec![TurnEnvironmentParams {
|
|
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
|
|
cwd: explicit_cwd.clone().into(),
|
|
runtime_workspace_roots: Some(vec![explicit_cwd.clone().into()]),
|
|
}];
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
// These compatibility parameters carry host-native paths, not foreign environment paths.
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
cwd: Some(initial_cwd.to_string_lossy().into_owned()),
|
|
runtime_workspace_roots: Some(initial_roots.clone()),
|
|
environments: Some(explicit_environments.clone()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let updated_roots = vec![initial_cwd.clone(), explicit_cwd.clone(), extra_root.abs()];
|
|
let mut roots_with_duplicate = updated_roots.clone();
|
|
roots_with_duplicate.push(extra_root.abs());
|
|
for (environments, roots) in [
|
|
// Materialize the rollout before settings updates begin emitting persisted snapshots.
|
|
(None, None),
|
|
(None, Some(roots_with_duplicate)),
|
|
// Explicit environments own their live roots; the top-level list is ignored.
|
|
(Some(explicit_environments), Some(initial_roots.clone())),
|
|
] {
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "update workspace selection".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
environments,
|
|
runtime_workspace_roots: roots,
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
}
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
runtime_workspace_roots,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
assert_eq!(runtime_workspace_roots, vec![explicit_cwd.clone()]);
|
|
|
|
// A cwd-only update must retain the fallback's additional folder, not the live roots.
|
|
mcp.clear_message_buffer();
|
|
let update_id = mcp
|
|
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
|
|
thread_id: thread.id.clone(),
|
|
cwd: Some(initial_cwd.to_path_buf()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _: ThreadSettingsUpdateResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(update_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/settings/updated"),
|
|
)
|
|
.await??;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??;
|
|
|
|
// The header preserves the startup fallback selection, not the explicit environment's roots.
|
|
let startup = read_session_meta_line(thread.path.as_ref().expect("rollout path")).await?;
|
|
assert_eq!(
|
|
startup.meta.runtime_workspace_roots,
|
|
Some(
|
|
initial_roots
|
|
.iter()
|
|
.map(AbsolutePathBuf::to_path_buf)
|
|
.collect()
|
|
)
|
|
);
|
|
// Check both input deduplication and cwd retargeting before resume can normalize roots.
|
|
let expected_roots = vec![initial_cwd.clone(), extra_root.abs()];
|
|
let (items, _, _) =
|
|
RolloutRecorder::load_rollout_items(thread.path.as_ref().expect("rollout path")).await?;
|
|
let snapshot_roots: Vec<_> = items
|
|
.into_iter()
|
|
.rev()
|
|
.filter_map(|item| match item {
|
|
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) => {
|
|
Some(event.thread_settings.runtime_workspace_roots)
|
|
}
|
|
_ => None,
|
|
})
|
|
.take(/*n*/ 3)
|
|
.collect();
|
|
assert_eq!(
|
|
snapshot_roots,
|
|
vec![
|
|
Some(expected_roots.clone()),
|
|
Some(vec![explicit_cwd, extra_root.abs()]),
|
|
Some(updated_roots),
|
|
]
|
|
);
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
cwd,
|
|
runtime_workspace_roots,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
assert_eq!(
|
|
(cwd, runtime_workspace_roots),
|
|
(initial_cwd.clone(), expected_roots)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_resume_workspace_roots_honor_owned_snapshot_and_caller_overrides() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let saved_cwd = normalized_existing_path(codex_home.path())?.abs();
|
|
let new_workspace = TempDir::new()?;
|
|
let new_cwd = normalized_existing_path(new_workspace.path())?.abs();
|
|
let extra_workspace = TempDir::new()?;
|
|
let extra_root = extra_workspace.path().abs();
|
|
let thread_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved workspace selection",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &thread_id);
|
|
let owned: ThreadSettingsAppliedEvent = serde_json::from_value(json!({
|
|
"thread_id": thread_id,
|
|
"thread_settings": {
|
|
"model": "gpt-5.4",
|
|
"model_provider_id": "mock_provider",
|
|
"cwd": saved_cwd,
|
|
"runtime_workspace_roots": [saved_cwd, new_cwd, extra_root],
|
|
"approval_policy": "never",
|
|
"approvals_reviewer": "user",
|
|
"permission_profile": PermissionProfile::read_only(),
|
|
"collaboration_mode": { "mode": "default", "settings": { "model": "gpt-5.4" } },
|
|
},
|
|
}))?;
|
|
let mut foreign = owned.clone();
|
|
foreign.thread_id = Some(ThreadId::new());
|
|
foreign.thread_settings.runtime_workspace_roots = Some(vec![extra_root.clone()]);
|
|
for settings in [owned, foreign] {
|
|
append_rollout_item_to_path(
|
|
&path,
|
|
&RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(settings)),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// Every iteration is a cold resume; the final one must retain the preceding explicit clear.
|
|
for (cwd_override, roots_override, expected_cwd, expected_roots) in [
|
|
(
|
|
Some(&new_cwd),
|
|
None,
|
|
&new_cwd,
|
|
vec![new_cwd.clone(), extra_root.clone()],
|
|
),
|
|
(
|
|
Some(&saved_cwd),
|
|
Some(vec![new_cwd.clone()]),
|
|
&saved_cwd,
|
|
vec![new_cwd.clone()],
|
|
),
|
|
(None, Some(Vec::new()), &saved_cwd, Vec::new()),
|
|
(None, None, &saved_cwd, Vec::new()),
|
|
] {
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
cwd: cwd_override.map(|cwd| cwd.to_string_lossy().into_owned()),
|
|
runtime_workspace_roots: roots_override,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let resumed: ThreadResumeResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
assert_eq!(
|
|
(resumed.cwd, resumed.runtime_workspace_roots),
|
|
(expected_cwd.clone(), expected_roots.clone())
|
|
);
|
|
|
|
// Inspect persistence before shutdown or another resume can hide a missing checkpoint.
|
|
let expected_thread_id = ThreadId::from_string(&thread_id)?;
|
|
let (items, _, _) = RolloutRecorder::load_rollout_items(&path).await?;
|
|
let persisted = items.into_iter().rev().find_map(|item| match item {
|
|
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event))
|
|
if event.thread_id == Some(expected_thread_id) =>
|
|
{
|
|
Some((
|
|
event.thread_settings.cwd,
|
|
event.thread_settings.runtime_workspace_roots,
|
|
))
|
|
}
|
|
_ => None,
|
|
});
|
|
assert_eq!(
|
|
persisted,
|
|
Some((expected_cwd.clone(), Some(expected_roots)))
|
|
);
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_resume_workspace_roots_validate_foreign_startup_paths() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let local_cwd = normalized_existing_path(codex_home.path())?.abs();
|
|
let extra_workspace = TempDir::new()?;
|
|
let extra_root = normalized_existing_path(extra_workspace.path())?.abs();
|
|
let extra_root_string = extra_root.to_string_lossy();
|
|
let unnormalized_extra_root = extra_root
|
|
.as_path()
|
|
.join("child")
|
|
.join("..")
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
let (foreign_cwd, foreign_extra) = if cfg!(windows) {
|
|
("/foreign/project", "/foreign/extra")
|
|
} else {
|
|
(r"C:\foreign\project", r"C:\foreign\extra")
|
|
};
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
// Startup metadata uses the app-server host's paths, not executor paths.
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
for (saved_roots, roots_override, expected_roots) in [
|
|
(
|
|
vec![
|
|
foreign_cwd,
|
|
extra_root_string.as_ref(),
|
|
unnormalized_extra_root.as_str(),
|
|
],
|
|
None,
|
|
Some(vec![local_cwd.clone(), extra_root.clone()]),
|
|
),
|
|
(vec![foreign_cwd, foreign_extra], None, None),
|
|
(
|
|
vec![foreign_cwd, foreign_extra],
|
|
Some(Vec::new()),
|
|
Some(Vec::new()),
|
|
),
|
|
] {
|
|
let thread_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Foreign startup paths",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &thread_id);
|
|
let contents = std::fs::read_to_string(&path)?;
|
|
let (meta, history) = contents.split_once('\n').expect("rollout metadata line");
|
|
let mut meta: serde_json::Value = serde_json::from_str(meta)?;
|
|
meta["payload"]["cwd"] = json!(foreign_cwd);
|
|
meta["payload"]["runtime_workspace_roots"] = json!(saved_roots);
|
|
std::fs::write(&path, format!("{meta}\n{history}"))?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
cwd: Some(local_cwd.to_string_lossy().into_owned()),
|
|
runtime_workspace_roots: roots_override,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
if let Some(expected_roots) = expected_roots {
|
|
let ThreadResumeResponse {
|
|
thread,
|
|
cwd,
|
|
runtime_workspace_roots,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
assert_eq!(
|
|
(thread.id, cwd, runtime_workspace_roots),
|
|
(thread_id, local_cwd.clone(), expected_roots)
|
|
);
|
|
} else {
|
|
let error = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(error.error.code, -32602);
|
|
assert!(error.error.message.contains(foreign_extra));
|
|
assert!(error.error.message.contains("runtimeWorkspaceRoots"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_preserves_persisted_approvals_reviewer() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let thread_id = {
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize this thread".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
thread.id
|
|
};
|
|
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
config_path,
|
|
config.replace(
|
|
"approval_policy = \"never\"\n",
|
|
"approval_policy = \"never\"\napprovals_reviewer = \"user\"\n",
|
|
),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
approvals_reviewer, ..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(approvals_reviewer, ApprovalsReviewer::AutoReview);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_restores_persisted_approval_policy() -> Result<()> {
|
|
assert_thread_resume_approval_policy(
|
|
ThreadHistoryMode::Legacy,
|
|
/*approval_policy*/ None,
|
|
AskForApproval::Never,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn paginated_thread_resume_restores_persisted_approval_policy() -> Result<()> {
|
|
assert_thread_resume_approval_policy(
|
|
ThreadHistoryMode::Paginated,
|
|
/*approval_policy*/ None,
|
|
AskForApproval::Never,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_approval_policy_override_wins_over_persisted_policy() -> Result<()> {
|
|
assert_thread_resume_approval_policy(
|
|
ThreadHistoryMode::Legacy,
|
|
Some(AskForApproval::OnRequest),
|
|
AskForApproval::OnRequest,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn assert_thread_resume_approval_policy(
|
|
history_mode: ThreadHistoryMode,
|
|
approval_policy: Option<AskForApproval>,
|
|
expected_approval_policy: AskForApproval,
|
|
) -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
std::fs::write(
|
|
&config_path,
|
|
format!(
|
|
r#"
|
|
model = "gpt-5.4"
|
|
approval_policy = "never"
|
|
model_provider = "mock_provider"
|
|
|
|
[model_providers.mock_provider]
|
|
name = "Mock provider for test"
|
|
base_url = "{}/v1"
|
|
wire_api = "responses"
|
|
request_max_retries = 0
|
|
stream_max_retries = 0
|
|
"#,
|
|
server.uri()
|
|
),
|
|
)?;
|
|
|
|
let thread_id = {
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
history_mode: Some(history_mode),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize this thread".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
thread.id
|
|
};
|
|
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
config_path,
|
|
config.replace(
|
|
"approval_policy = \"never\"",
|
|
"approval_policy = \"on-request\"",
|
|
),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
approval_policy,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
approval_policy, ..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(approval_policy, expected_approval_policy);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_preserves_goal_first_and_fork_approvals_reviewer() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
&config_path,
|
|
config.replace("personality = true\n", "personality = true\ngoals = true\n"),
|
|
)?;
|
|
|
|
let (thread_id, fork_thread_id) = {
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.2-codex".to_string()),
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
let rollout_path = thread.path.clone().expect("thread path");
|
|
|
|
for objective in [
|
|
"keep auto review after restart",
|
|
"still keep auto review after restart",
|
|
] {
|
|
let goal_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"objective": objective,
|
|
"status": "paused",
|
|
})),
|
|
)
|
|
.await?;
|
|
let _: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
|
)
|
|
.await??;
|
|
}
|
|
|
|
let persisted_rollout = std::fs::read_to_string(rollout_path)?;
|
|
assert_eq!(
|
|
persisted_rollout
|
|
.matches(r#""type":"thread_settings_applied""#)
|
|
.count(),
|
|
1
|
|
);
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: thread.id.clone(),
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
thread: fork_thread,
|
|
approvals_reviewer,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
assert_eq!(approvals_reviewer, ApprovalsReviewer::User);
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??;
|
|
let (items, _, _) =
|
|
RolloutRecorder::load_rollout_items(fork_thread.path.as_ref().expect("fork rollout"))
|
|
.await?;
|
|
assert_eq!(
|
|
items
|
|
.into_iter()
|
|
.filter_map(|item| match item {
|
|
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) =>
|
|
event.thread_id,
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
ThreadId::from_string(&thread.id)?,
|
|
ThreadId::from_string(&fork_thread.id)?,
|
|
]
|
|
);
|
|
|
|
(thread.id, fork_thread.id)
|
|
};
|
|
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
config_path,
|
|
config.replace(
|
|
"approval_policy = \"never\"\n",
|
|
"approval_policy = \"never\"\napprovals_reviewer = \"user\"\n",
|
|
),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
for (thread_id, expected_reviewer) in [
|
|
(thread_id, ApprovalsReviewer::AutoReview),
|
|
(fork_thread_id, ApprovalsReviewer::User),
|
|
] {
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
approvals_reviewer, ..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(approvals_reviewer, expected_reviewer);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewer_update()
|
|
-> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let updated_workspace = TempDir::new()?;
|
|
let persisted_cwd = normalized_existing_path(updated_workspace.path())?;
|
|
let live_cwd = normalized_existing_path(codex_home.path())?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config_toml = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
&config_path,
|
|
config_toml.replace(
|
|
"model = \"gpt-5.4\"",
|
|
"model = \"gpt-5.4\"\nmodel_reasoning_effort = \"high\"",
|
|
),
|
|
)?;
|
|
|
|
let (thread_id, rollout_path) = {
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
history_mode: Some(ThreadHistoryMode::Legacy),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize this thread".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
|
|
let update_id = mcp
|
|
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
|
|
thread_id: thread.id.clone(),
|
|
model: Some("gpt-5.2-codex".to_string()),
|
|
effort: Some(ReasoningEffort::Ultra),
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
cwd: Some(persisted_cwd.clone()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _: ThreadSettingsUpdateResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(update_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/settings/updated"),
|
|
)
|
|
.await??;
|
|
|
|
let read_id = mcp
|
|
.send_thread_read_request(ThreadReadParams {
|
|
thread_id: thread.id.clone(),
|
|
include_turns: false,
|
|
})
|
|
.await?;
|
|
let ThreadReadResponse { thread: read } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
|
|
assert_eq!(read.cwd.as_path(), persisted_cwd);
|
|
|
|
let list_id = mcp
|
|
.send_raw_request(
|
|
"thread/list",
|
|
Some(json!({ "cwd": persisted_cwd, "useStateDbOnly": true })),
|
|
)
|
|
.await?;
|
|
let ThreadListResponse { data, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(list_id)).await??;
|
|
assert_eq!(
|
|
data.iter()
|
|
.find(|listed| listed.id == thread.id)
|
|
.map(|listed| &listed.cwd),
|
|
Some(&read.cwd)
|
|
);
|
|
|
|
(thread.id, read.path.expect("materialized rollout path"))
|
|
};
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
cwd: Some(live_cwd.to_string_lossy().into_owned()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread,
|
|
cwd,
|
|
model,
|
|
reasoning_effort,
|
|
approvals_reviewer,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(model, "gpt-5.2-codex");
|
|
assert_eq!(reasoning_effort, Some(ReasoningEffort::Ultra));
|
|
assert_eq!(approvals_reviewer, ApprovalsReviewer::AutoReview);
|
|
assert_eq!(thread.cwd.as_path(), live_cwd);
|
|
assert_eq!(cwd.as_path(), live_cwd);
|
|
|
|
let update_id = mcp
|
|
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
|
|
thread_id: thread_id.clone(),
|
|
cwd: Some(persisted_cwd.clone()),
|
|
collaboration_mode: Some(CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model: "gpt-5.2-codex".to_string(),
|
|
reasoning_effort: None,
|
|
developer_instructions: None,
|
|
},
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _: ThreadSettingsUpdateResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(update_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/settings/updated"),
|
|
)
|
|
.await??;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??;
|
|
|
|
// Older rollouts can retain a frozen turn context after an accepted settings update.
|
|
let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path).await?;
|
|
let frozen_context = items
|
|
.into_iter()
|
|
.find(|item| matches!(item, RolloutItem::TurnContext(_)))
|
|
.expect("initial turn context");
|
|
append_rollout_item_to_path(&rollout_path, &frozen_context).await?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
cwd,
|
|
reasoning_effort,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
assert_eq!(reasoning_effort, None);
|
|
assert_eq!(cwd.as_path(), persisted_cwd);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_resume_reresolves_persisted_active_permission_profile() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
for history_mode in [ThreadHistoryMode::Legacy, ThreadHistoryMode::Paginated] {
|
|
let codex_home = TempDir::new()?;
|
|
let previous_workspace_root = TempDir::new()?;
|
|
let profile_workspace = TempDir::new()?;
|
|
write_dev_permission_config(&server.uri(), codex_home.path(), ":workspace")?;
|
|
let profile_root = serde_json::to_string(profile_workspace.path())?;
|
|
writeln!(
|
|
std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(codex_home.path().join("config.toml"))?,
|
|
"\n[permissions.dev.workspace_roots]\n{profile_root} = true"
|
|
)?;
|
|
let thread_id = {
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let thread_id = materialize_dev_permission_thread(&mut mcp, history_mode).await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: thread_id.clone(),
|
|
runtime_workspace_roots: Some(vec![AbsolutePathBuf::from_absolute_path(
|
|
previous_workspace_root.path(),
|
|
)?]),
|
|
input: vec![UserInput::Text {
|
|
text: "update runtime workspace roots".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
thread_id
|
|
};
|
|
|
|
write_dev_permission_config(&server.uri(), codex_home.path(), ":read-only")?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
sandbox,
|
|
active_permission_profile,
|
|
runtime_workspace_roots,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert!(matches!(sandbox, AppSandboxPolicy::ReadOnly { .. }));
|
|
assert_eq!(
|
|
active_permission_profile,
|
|
Some(ActivePermissionProfile {
|
|
id: "dev".to_string(),
|
|
extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
runtime_workspace_roots,
|
|
vec![previous_workspace_root.path().abs()]
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_resume_with_removed_permission_profile_uses_configured_default() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
for history_mode in [ThreadHistoryMode::Legacy, ThreadHistoryMode::Paginated] {
|
|
let codex_home = TempDir::new()?;
|
|
write_dev_permission_config(&server.uri(), codex_home.path(), ":workspace")?;
|
|
let thread_id = {
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
materialize_dev_permission_thread(&mut mcp, history_mode).await?
|
|
};
|
|
|
|
MockResponsesConfig::new(&server.uri())
|
|
.with_root_config(&format!(
|
|
"default_permissions = \"{BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS}\""
|
|
))
|
|
.write(codex_home.path())?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
sandbox,
|
|
active_permission_profile,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert!(matches!(sandbox, AppSandboxPolicy::DangerFullAccess));
|
|
assert_eq!(
|
|
active_permission_profile,
|
|
Some(ActivePermissionProfile::new(
|
|
BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS,
|
|
))
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_resume_permission_overrides_win_over_persisted_profile() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
write_dev_permission_config(&server.uri(), codex_home.path(), ":workspace")?;
|
|
let thread_id = {
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
materialize_dev_permission_thread(&mut mcp, ThreadHistoryMode::Legacy).await?
|
|
};
|
|
|
|
for params in [
|
|
ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
sandbox: Some(SandboxMode::ReadOnly),
|
|
..Default::default()
|
|
},
|
|
ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
|
|
..Default::default()
|
|
},
|
|
ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
config: Some(std::collections::HashMap::from([(
|
|
"default_permissions".to_string(),
|
|
json!(BUILT_IN_PERMISSION_PROFILE_READ_ONLY),
|
|
)])),
|
|
..Default::default()
|
|
},
|
|
] {
|
|
let expected_active_permission_profile = params
|
|
.sandbox
|
|
.is_none()
|
|
.then(ActivePermissionProfile::read_only);
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp.send_thread_resume_request(params).await?;
|
|
let ThreadResumeResponse {
|
|
sandbox,
|
|
active_permission_profile,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert!(matches!(sandbox, AppSandboxPolicy::ReadOnly { .. }));
|
|
assert_eq!(
|
|
active_permission_profile,
|
|
expected_active_permission_profile
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_resume_without_active_permission_profile_uses_current_config() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let thread_id = {
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } = mcp
|
|
.start_thread(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "persist full access".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
sandbox_policy: Some(AppSandboxPolicy::DangerFullAccess),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
thread.id
|
|
};
|
|
|
|
MockResponsesConfig::new(&server.uri())
|
|
.with_root_config(&format!(
|
|
"default_permissions = \"{BUILT_IN_PERMISSION_PROFILE_WORKSPACE}\""
|
|
))
|
|
.write(codex_home.path())?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
sandbox,
|
|
active_permission_profile,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert!(matches!(sandbox, AppSandboxPolicy::WorkspaceWrite { .. }));
|
|
assert_eq!(
|
|
active_permission_profile,
|
|
Some(ActivePermissionProfile::new(
|
|
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
|
|
))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_resume_restores_profile_selected_by_settings_update() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let thread_id = {
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } = mcp
|
|
.start_thread(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "persist permission profile".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
let update_id = mcp
|
|
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
|
|
thread_id: thread.id.clone(),
|
|
permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _: ThreadSettingsUpdateResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(update_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/settings/updated"),
|
|
)
|
|
.await??;
|
|
thread.id
|
|
};
|
|
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
sandbox,
|
|
active_permission_profile,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert!(matches!(sandbox, AppSandboxPolicy::WorkspaceWrite { .. }));
|
|
assert_eq!(
|
|
active_permission_profile,
|
|
Some(ActivePermissionProfile::new(
|
|
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
|
|
))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn materialize_dev_permission_thread(
|
|
mcp: &mut TestAppServer,
|
|
history_mode: ThreadHistoryMode,
|
|
) -> Result<String> {
|
|
let ThreadStartResponse { thread, .. } = mcp
|
|
.start_thread(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
history_mode: Some(history_mode),
|
|
permissions: Some("dev".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "persist permission profile".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
Ok(thread.id)
|
|
}
|
|
|
|
fn write_dev_permission_config(
|
|
server_uri: &str,
|
|
codex_home: &Path,
|
|
dev_extends: &str,
|
|
) -> std::io::Result<()> {
|
|
MockResponsesConfig::new(server_uri)
|
|
.with_root_config("default_permissions = \":danger-full-access\"")
|
|
.with_extra_config(&format!("[permissions.dev]\nextends = \"{dev_extends}\""))
|
|
.write(codex_home)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
&config_path,
|
|
config.replace("personality = true\n", "personality = true\ngoals = true\n"),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.2-codex".to_string()),
|
|
ephemeral: Some(true),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let goal_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/get",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
})),
|
|
)
|
|
.await?;
|
|
let goal_err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(goal_id)),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
goal_err
|
|
.error
|
|
.message
|
|
.contains("ephemeral thread does not support goals"),
|
|
"unexpected goal/get error: {}",
|
|
goal_err.error.message
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unloaded_thread_goal_mutations_respect_parent_ownership() -> Result<()> {
|
|
const TIMESTAMP: &str = "2026-08-20T12-00-00";
|
|
let server = responses::start_mock_server().await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri())
|
|
.enable_feature(Feature::Goals)
|
|
.enable_feature(Feature::Sqlite)
|
|
.write(codex_home.path())?;
|
|
let child_source = RolloutSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
parent_thread_id: ThreadId::new(),
|
|
depth: 1,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
});
|
|
let mut app = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
for (source, version) in [
|
|
(child_source.clone(), Some(MultiAgentVersion::V2)),
|
|
(child_source.clone(), Some(MultiAgentVersion::V1)),
|
|
(child_source, None),
|
|
(RolloutSessionSource::Cli, Some(MultiAgentVersion::V2)),
|
|
] {
|
|
let rejects_mutation = matches!(source, RolloutSessionSource::SubAgent(_))
|
|
&& version == Some(MultiAgentVersion::V2);
|
|
let thread_id = create_fake_rollout_with_source(
|
|
codex_home.path(),
|
|
TIMESTAMP,
|
|
"2026-08-20T12:00:00Z",
|
|
"Saved task",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
source,
|
|
)?;
|
|
let path = rollout_path(codex_home.path(), TIMESTAMP, &thread_id);
|
|
let params = json!({
|
|
"threadId": thread_id,
|
|
"objective": "Original goal",
|
|
"status": "paused",
|
|
});
|
|
let request_id = app
|
|
.send_raw_request("thread/goal/set", Some(params))
|
|
.await?;
|
|
let original: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, app.read_response(request_id)).await??;
|
|
|
|
// The initial header has no version, as in older rollouts. Later metadata
|
|
// must take precedence, just as it does when the thread resumes.
|
|
let mut meta = read_session_meta_line(&path).await?;
|
|
meta.meta.multi_agent_version = version;
|
|
append_rollout_item_to_path(&path, &RolloutItem::SessionMeta(meta)).await?;
|
|
|
|
for (method, params) in [
|
|
(
|
|
"thread/goal/set",
|
|
json!({"threadId": thread_id, "objective": "Replacement goal", "status": "paused"}),
|
|
),
|
|
("thread/goal/clear", json!({"threadId": thread_id})),
|
|
] {
|
|
let request_id = app.send_raw_request(method, Some(params)).await?;
|
|
if rejects_mutation {
|
|
let error = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
app.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(
|
|
error.error,
|
|
JSONRPCErrorError {
|
|
code: -32600,
|
|
message:
|
|
"direct app-server input is not allowed for multi-agent v2 sub-agents"
|
|
.to_string(),
|
|
data: None,
|
|
},
|
|
);
|
|
let retained: ThreadGoalGetResponse = app
|
|
.request(|request_id| ClientRequest::ThreadGoalGet {
|
|
request_id,
|
|
params: ThreadGoalGetParams {
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(
|
|
retained,
|
|
ThreadGoalGetResponse {
|
|
goal: Some(original.goal.clone()),
|
|
},
|
|
);
|
|
} else if method == "thread/goal/set" {
|
|
let _: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, app.read_response(request_id)).await??;
|
|
} else {
|
|
let cleared: ThreadGoalClearResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, app.read_response(request_id)).await??;
|
|
assert_eq!(cleared, ThreadGoalClearResponse { cleared: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
let loaded: ThreadLoadedListResponse = app
|
|
.request(|request_id| ClientRequest::ThreadLoadedList {
|
|
request_id,
|
|
params: ThreadLoadedListParams::default(),
|
|
})
|
|
.await?;
|
|
assert_eq!(loaded.data, Vec::<String>::new());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_goal_mutations_preserve_authoritative_sqlite_metadata() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.enable_feature(Feature::Goals)
|
|
.write(codex_home.path())?;
|
|
let thread_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Rollout preview",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let state_db = StateRuntime::init(
|
|
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
|
|
"mock_provider".into(),
|
|
)
|
|
.await?;
|
|
let thread_id = ThreadId::from_string(&thread_id)?;
|
|
let mut metadata = state_db
|
|
.get_thread(thread_id)
|
|
.await?
|
|
.expect("thread metadata should exist");
|
|
metadata.preview = Some("SQLite preview before goal set".to_string());
|
|
state_db.upsert_thread(&metadata).await?;
|
|
|
|
let goal_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread_id.to_string(),
|
|
"objective": "preserve SQLite metadata",
|
|
"status": "paused",
|
|
})),
|
|
)
|
|
.await?;
|
|
let _: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
|
)
|
|
.await??;
|
|
|
|
let mut metadata = state_db
|
|
.get_thread(thread_id)
|
|
.await?
|
|
.expect("thread metadata should survive goal set");
|
|
assert_eq!(
|
|
metadata.preview.as_deref(),
|
|
Some("SQLite preview before goal set")
|
|
);
|
|
metadata.preview = Some("SQLite preview before goal clear".to_string());
|
|
state_db.upsert_thread(&metadata).await?;
|
|
|
|
let clear_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/clear",
|
|
Some(json!({
|
|
"threadId": thread_id.to_string(),
|
|
})),
|
|
)
|
|
.await?;
|
|
let cleared: ThreadGoalClearResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(clear_id)).await??;
|
|
assert!(cleared.cleared);
|
|
let metadata = state_db
|
|
.get_thread(thread_id)
|
|
.await?
|
|
.expect("thread metadata should survive goal clear");
|
|
assert_eq!(
|
|
metadata.preview.as_deref(),
|
|
Some("SQLite preview before goal clear")
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_goal_set_repairs_missing_sqlite_metadata() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.enable_feature(Feature::Goals)
|
|
.write(codex_home.path())?;
|
|
let thread_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Rollout preview",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let state_db = StateRuntime::init(
|
|
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
|
|
"mock_provider".into(),
|
|
)
|
|
.await?;
|
|
let thread_id = ThreadId::from_string(&thread_id)?;
|
|
state_db.delete_thread(thread_id).await?;
|
|
|
|
let goal_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread_id.to_string(),
|
|
"objective": "repair missing SQLite metadata",
|
|
"status": "paused",
|
|
})),
|
|
)
|
|
.await?;
|
|
let _: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??;
|
|
assert!(state_db.get_thread(thread_id).await?.is_some());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn goal_first_live_thread_appears_in_state_db_thread_list() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let codex_home_path = normalized_existing_path(codex_home.path())?;
|
|
mock_responses_config(&server.uri()).write(&codex_home_path)?;
|
|
let config_path = codex_home_path.join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
&config_path,
|
|
config.replace("personality = true\n", "personality = true\ngoals = true\n"),
|
|
)?;
|
|
|
|
let sqlite_home = codex_home_path
|
|
.as_path()
|
|
.to_str()
|
|
.expect("test codex home should be utf-8");
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(&codex_home_path)
|
|
.without_managed_config()
|
|
.with_env_overrides(&[("CODEX_SQLITE_HOME", Some(sqlite_home))])
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.2-codex".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, cwd, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let goal_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id.clone(),
|
|
"objective": "keep the goal-first thread visible",
|
|
"status": "paused",
|
|
})),
|
|
)
|
|
.await?;
|
|
let _goal: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
|
)
|
|
.await??;
|
|
|
|
let list_id = mcp
|
|
.send_raw_request(
|
|
"thread/list",
|
|
Some(json!({
|
|
"limit": 10,
|
|
"modelProviders": ["mock_provider"],
|
|
"sourceKinds": ["vscode"],
|
|
"archived": false,
|
|
"cwd": cwd.as_path().to_string_lossy().to_string(),
|
|
"useStateDbOnly": true,
|
|
})),
|
|
)
|
|
.await?;
|
|
let list: ThreadListResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(list_id)).await??;
|
|
assert_eq!(
|
|
list.data
|
|
.iter()
|
|
.map(|thread| &thread.id)
|
|
.collect::<Vec<_>>(),
|
|
vec![&thread.id]
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.with_root_config(&format!(r#"chatgpt_base_url = "{}""#, server.uri()))
|
|
.write(codex_home.path())?;
|
|
mount_analytics_capture(&server, codex_home.path()).await?;
|
|
|
|
let conversation_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
set_session_meta_on_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
&conversation_id,
|
|
"user",
|
|
"codex_work_desktop",
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
assert!(
|
|
!thread.session_id.is_empty(),
|
|
"session id should not be empty"
|
|
);
|
|
assert_eq!(thread.thread_source, Some(ThreadSource::User));
|
|
|
|
let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?;
|
|
let event = thread_initialized_event(&payload)?;
|
|
assert_basic_thread_initialized_event(
|
|
event,
|
|
&thread.id,
|
|
&thread.session_id,
|
|
"codex_work_desktop",
|
|
"gpt-5.4",
|
|
"resumed",
|
|
"user",
|
|
);
|
|
assert_eq!(event["event_params"]["thread_source"], "user");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_running_thread_tracks_thread_originator_in_analytics() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.with_root_config(&format!(r#"chatgpt_base_url = "{}""#, server.uri()))
|
|
.write(codex_home.path())?;
|
|
mount_analytics_capture(&server, codex_home.path()).await?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
thread_source: Some(ThreadSource::User),
|
|
service_name: Some("codex_work_desktop".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize rollout".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed, ..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
let event = wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| {
|
|
event["event_type"] == "codex_thread_initialized"
|
|
&& event["event_params"]["thread_id"] == resumed.id
|
|
&& event["event_params"]["initialization_mode"] == "resumed"
|
|
})
|
|
.await?;
|
|
assert_basic_thread_initialized_event(
|
|
&event,
|
|
&resumed.id,
|
|
&resumed.session_id,
|
|
"codex_work_desktop",
|
|
"mock-model",
|
|
"resumed",
|
|
"user",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn set_session_meta_on_fake_rollout(
|
|
codex_home: &std::path::Path,
|
|
filename_ts: &str,
|
|
thread_id: &str,
|
|
thread_source: &str,
|
|
originator: &str,
|
|
) -> Result<()> {
|
|
let path = rollout_path(codex_home, filename_ts, thread_id);
|
|
let contents = std::fs::read_to_string(&path)?;
|
|
let mut lines = contents.lines();
|
|
let session_meta = lines
|
|
.next()
|
|
.ok_or_else(|| anyhow::anyhow!("fake rollout missing session meta"))?;
|
|
let mut session_meta: serde_json::Value = serde_json::from_str(session_meta)?;
|
|
session_meta["payload"]["thread_source"] = serde_json::json!(thread_source);
|
|
session_meta["payload"]["originator"] = serde_json::json!(originator);
|
|
let remaining = lines.collect::<Vec<_>>().join("\n");
|
|
std::fs::write(&path, format!("{session_meta}\n{remaining}\n"))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_returns_rollout_history() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let saved_cwd = normalized_existing_path(codex_home.path())?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let preview = "Saved user message";
|
|
let text_elements = vec![TextElement::new(
|
|
ByteRange { start: 0, end: 5 },
|
|
Some("<note>".into()),
|
|
)];
|
|
let conversation_id = create_fake_rollout_with_text_elements(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
preview,
|
|
text_elements
|
|
.iter()
|
|
.map(|elem| serde_json::to_value(elem).expect("serialize text element"))
|
|
.collect(),
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
// Old snapshots have no owner ID: keep them readable without adopting their cwd.
|
|
let settings: ThreadSettingsAppliedEvent = serde_json::from_value(json!({
|
|
"thread_settings": {
|
|
"model": "gpt-5.4",
|
|
"model_provider_id": "mock_provider",
|
|
"cwd": saved_cwd,
|
|
"approval_policy": "never",
|
|
"approvals_reviewer": "user",
|
|
"permission_profile": PermissionProfile::read_only(),
|
|
"collaboration_mode": { "mode": "default", "settings": { "model": "gpt-5.4" } },
|
|
},
|
|
}))?;
|
|
append_rollout_item_to_path(
|
|
&rollout_path(codex_home.path(), "2025-01-05T12-00-00", &conversation_id),
|
|
&RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(settings)),
|
|
)
|
|
.await?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, cwd, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(thread.id, conversation_id);
|
|
assert_eq!(thread.preview, preview);
|
|
assert_eq!(thread.model_provider, "mock_provider");
|
|
assert!(thread.path.as_ref().expect("thread path").is_absolute());
|
|
assert_eq!(thread.cwd.as_path(), cwd.as_path());
|
|
assert_eq!(cwd, test_absolute_path("/"));
|
|
assert_eq!(thread.cli_version, "0.0.0");
|
|
assert_eq!(thread.source, SessionSource::Cli);
|
|
assert_eq!(thread.git_info, None);
|
|
assert_eq!(thread.status, ThreadStatus::Idle);
|
|
|
|
assert_eq!(
|
|
thread.turns.len(),
|
|
1,
|
|
"expected rollouts to include one turn"
|
|
);
|
|
let turn = &thread.turns[0];
|
|
assert_eq!(turn.status, TurnStatus::Completed);
|
|
assert_eq!(turn.items.len(), 1, "expected user message item");
|
|
match &turn.items[0] {
|
|
ThreadItem::UserMessage { content, .. } => {
|
|
assert_eq!(
|
|
content,
|
|
&vec![UserInput::Text {
|
|
text: preview.to_string(),
|
|
text_elements: text_elements.clone().into_iter().map(Into::into).collect(),
|
|
}]
|
|
);
|
|
}
|
|
other => panic!("expected user message item, got {other:?}"),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<()> {
|
|
for client_name in ["codex_chatgpt_android_remote", "codex_chatgpt_ios_remote"] {
|
|
let remote_resume = resume_redaction_fixture(Some(client_name)).await?;
|
|
let remote_turn = remote_resume
|
|
.thread
|
|
.turns
|
|
.first()
|
|
.expect("remote resume should include a turn");
|
|
let remote_page_turn = remote_resume
|
|
.initial_turns_page
|
|
.as_ref()
|
|
.expect("remote resume should include the requested initial turns page")
|
|
.data
|
|
.first()
|
|
.expect("remote initial turns page should include a turn");
|
|
for remote_turn in [remote_turn, remote_page_turn] {
|
|
let remote_mcp_item = remote_turn
|
|
.items
|
|
.iter()
|
|
.find(|item| matches!(item, ThreadItem::McpToolCall { .. }))
|
|
.expect("remote resume should include redacted MCP item");
|
|
let ThreadItem::McpToolCall {
|
|
arguments,
|
|
app_context,
|
|
read_only_hint,
|
|
result,
|
|
error,
|
|
..
|
|
} = remote_mcp_item
|
|
else {
|
|
unreachable!("matched MCP item");
|
|
};
|
|
assert_eq!(arguments, &json!("[redacted]"));
|
|
assert_eq!(
|
|
app_context,
|
|
&Some(McpToolCallAppContext {
|
|
connector_id: "calendar".to_string(),
|
|
link_id: Some("link_calendar".to_string()),
|
|
resource_uri: Some("ui://widget/lookup.html".to_string()),
|
|
app_name: Some("Calendar".to_string()),
|
|
action_name: Some("lookup".to_string()),
|
|
})
|
|
);
|
|
assert_eq!(read_only_hint, &Some(false));
|
|
let result = result.as_ref().expect("redacted MCP result");
|
|
assert_eq!(
|
|
result.content,
|
|
vec![json!({
|
|
"type": "text",
|
|
"text": "[redacted]",
|
|
})]
|
|
);
|
|
assert_eq!(result.structured_content, None);
|
|
assert_eq!(result.meta, None);
|
|
assert_eq!(error, &None);
|
|
assert!(
|
|
!remote_turn
|
|
.items
|
|
.iter()
|
|
.any(|item| matches!(item, ThreadItem::ImageGeneration(_))),
|
|
"remote resume should drop image generation items for {client_name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
let normal_resume = resume_redaction_fixture(Some("some_other_client")).await?;
|
|
let normal_turn = normal_resume
|
|
.thread
|
|
.turns
|
|
.first()
|
|
.expect("normal resume should include a turn");
|
|
let normal_mcp_item = normal_turn
|
|
.items
|
|
.iter()
|
|
.find(|item| matches!(item, ThreadItem::McpToolCall { .. }))
|
|
.expect("normal resume should include MCP item");
|
|
let ThreadItem::McpToolCall {
|
|
arguments,
|
|
app_context,
|
|
read_only_hint,
|
|
result,
|
|
..
|
|
} = normal_mcp_item
|
|
else {
|
|
unreachable!("matched MCP item");
|
|
};
|
|
assert_eq!(arguments, &json!({"secret":"argument"}));
|
|
assert_eq!(
|
|
app_context,
|
|
&Some(McpToolCallAppContext {
|
|
connector_id: "calendar".to_string(),
|
|
link_id: Some("link_calendar".to_string()),
|
|
resource_uri: Some("ui://widget/lookup.html".to_string()),
|
|
app_name: Some("Calendar".to_string()),
|
|
action_name: Some("lookup".to_string()),
|
|
})
|
|
);
|
|
assert_eq!(read_only_hint, &Some(false));
|
|
let result = result.as_ref().expect("normal MCP result");
|
|
assert_eq!(
|
|
result.content,
|
|
vec![json!({
|
|
"type": "text",
|
|
"text": "secret result",
|
|
})]
|
|
);
|
|
assert_eq!(
|
|
result.structured_content,
|
|
Some(json!({"secret":"structured"}))
|
|
);
|
|
assert_eq!(result.meta, Some(json!({"secret":"meta"})));
|
|
assert!(
|
|
normal_turn.items.iter().any(|item| matches!(
|
|
item,
|
|
ThreadItem::ImageGeneration(item)
|
|
if item.result == "base64-image-result"
|
|
&& item.revised_prompt.as_deref() == Some("secret revised prompt")
|
|
)),
|
|
"normal resume should keep image generation items"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn resume_redaction_fixture(client_name: Option<&str>) -> Result<ThreadResumeResponse> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let filename_ts = "2025-01-05T12-00-00";
|
|
let meta_rfc3339 = "2025-01-05T12:00:00Z";
|
|
let conversation_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
filename_ts,
|
|
meta_rfc3339,
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
append_resume_redaction_history(
|
|
codex_home.path(),
|
|
filename_ts,
|
|
meta_rfc3339,
|
|
&conversation_id,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build()
|
|
.await?;
|
|
if let Some(client_name) = client_name {
|
|
let _ = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.initialize_with_client_info(ClientInfo {
|
|
name: client_name.to_string(),
|
|
title: None,
|
|
version: "0.1.0".to_string(),
|
|
}),
|
|
)
|
|
.await??;
|
|
} else {
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
}
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
initial_turns_page: Some(ThreadResumeInitialTurnsPageParams {
|
|
limit: None,
|
|
sort_direction: None,
|
|
items_view: Some(TurnItemsView::Full),
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let resume_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
|
|
)
|
|
.await??;
|
|
to_response::<ThreadResumeResponse>(resume_resp)
|
|
}
|
|
|
|
fn append_resume_redaction_history(
|
|
codex_home: &Path,
|
|
filename_ts: &str,
|
|
meta_rfc3339: &str,
|
|
conversation_id: &str,
|
|
) -> Result<()> {
|
|
let rollout_file_path = rollout_path(codex_home, filename_ts, conversation_id);
|
|
let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?;
|
|
let appended_rollout = [
|
|
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
|
call_id: "mcp-1".to_string(),
|
|
invocation: McpInvocation {
|
|
server: "docs".to_string(),
|
|
tool: "lookup".to_string(),
|
|
arguments: Some(json!({"secret":"argument"})),
|
|
},
|
|
connector_id: Some("calendar".to_string()),
|
|
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
|
|
link_id: Some("link_calendar".to_string()),
|
|
app_name: Some("Calendar".to_string()),
|
|
action_name: Some("lookup".to_string()),
|
|
plugin_id: None,
|
|
read_only_hint: Some(false),
|
|
duration: Duration::from_millis(8),
|
|
result: Ok(CallToolResult {
|
|
content: vec![json!({
|
|
"type": "text",
|
|
"text": "secret result",
|
|
})],
|
|
structured_content: Some(json!({"secret":"structured"})),
|
|
is_error: Some(false),
|
|
meta: Some(json!({"secret":"meta"})),
|
|
}),
|
|
}),
|
|
EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {
|
|
call_id: "ig-1".to_string(),
|
|
status: "completed".to_string(),
|
|
revised_prompt: Some("secret revised prompt".to_string()),
|
|
result: "base64-image-result".to_string(),
|
|
transparent_background: None,
|
|
failure: None,
|
|
saved_path: Some(test_absolute_path("/tmp/ig-1.png")),
|
|
}),
|
|
]
|
|
.into_iter()
|
|
.map(|payload| {
|
|
Ok(json!({
|
|
"timestamp": meta_rfc3339,
|
|
"type": "event_msg",
|
|
"payload": serde_json::to_value(payload)?,
|
|
})
|
|
.to_string())
|
|
})
|
|
.collect::<Result<Vec<_>>>()?
|
|
.join("\n");
|
|
std::fs::write(
|
|
&rollout_file_path,
|
|
format!("{persisted_rollout}{appended_rollout}\n"),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let conversation_id = create_fake_rollout_with_text_elements(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Vec::new(),
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id.clone(),
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(thread.id, conversation_id);
|
|
assert!(thread.turns.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_warns_for_paginated_full_history_hydration() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let conversation_id = create_fake_paginated_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let cold_resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let notice: DeprecationNoticeNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_notification("deprecationNotice"),
|
|
)
|
|
.await??;
|
|
assert_eq!(
|
|
notice,
|
|
DeprecationNoticeNotification {
|
|
summary: "Full-history hydration is deprecated for paginated threads; use `excludeTurns: true`, then page with `thread/turns/list` and `thread/items/list`.".to_string(),
|
|
details: None,
|
|
}
|
|
);
|
|
let _: ThreadResumeResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(cold_resume_id)).await??;
|
|
|
|
mcp.clear_message_buffer();
|
|
let loaded_resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _: DeprecationNoticeNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_notification("deprecationNotice"),
|
|
)
|
|
.await??;
|
|
let _: ThreadResumeResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(loaded_resume_id)).await??;
|
|
|
|
mcp.clear_message_buffer();
|
|
let metadata_resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _: ThreadResumeResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(metadata_resume_id)).await??;
|
|
assert!(
|
|
!mcp.pending_notification_methods()
|
|
.contains(&"deprecationNotice".to_string())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_rejects_archived_session_by_id() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let filename_ts = "2025-01-05T12-00-00";
|
|
let conversation_id = create_fake_rollout_with_text_elements(
|
|
codex_home.path(),
|
|
filename_ts,
|
|
"2025-01-05T12:00:00Z",
|
|
"Archived saved user message",
|
|
Vec::new(),
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let active_rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
|
|
let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR);
|
|
std::fs::create_dir_all(&archived_dir)?;
|
|
std::fs::rename(
|
|
&active_rollout_path,
|
|
archived_dir.join(active_rollout_path.file_name().expect("rollout file name")),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let resume_err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
|
)
|
|
.await??;
|
|
|
|
let message = resume_err.error.message;
|
|
assert!(
|
|
message.contains(&format!("session {conversation_id} is archived"))
|
|
&& message.contains(&format!(
|
|
"codex unarchive {conversation_id}` to unarchive it first"
|
|
)),
|
|
"unexpected resume error: {message}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_keeps_paused_goal_paused() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
&config_path,
|
|
config.replace("personality = true\n", "personality = true\ngoals = true\n"),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.2-codex".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize this thread".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _turn_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let goal_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"objective": "keep polishing",
|
|
"status": "paused",
|
|
})),
|
|
)
|
|
.await?;
|
|
let _goal: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
|
)
|
|
.await??;
|
|
mcp.clear_message_buffer();
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _resume: ThreadResumeResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
let notification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
|
)
|
|
.await??;
|
|
let notification: ServerNotification = notification.try_into()?;
|
|
let ServerNotification::ThreadGoalUpdated(notification) = notification else {
|
|
anyhow::bail!("expected thread goal update notification");
|
|
};
|
|
assert_eq!(notification.goal.status, ThreadGoalStatus::Paused);
|
|
assert!(
|
|
!mcp.pending_notification_methods()
|
|
.iter()
|
|
.any(|method| method == "turn/started"),
|
|
"paused goal should not continue after thread resume"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_goal_set_enforces_configured_maximum_token_budget() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
let config = config.replace("personality = true\n", "personality = true\ngoals = true\n");
|
|
std::fs::write(
|
|
config_path,
|
|
format!("{config}\n[goals]\nmax_goal_token_budget = 200\n"),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.2-codex".to_string()),
|
|
config: Some(
|
|
[("goals.max_goal_token_budget".to_string(), json!(100))]
|
|
.into_iter()
|
|
.collect(),
|
|
),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let oversized_creation_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"objective": "oversized goal",
|
|
"tokenBudget": 101,
|
|
})),
|
|
)
|
|
.await?;
|
|
let creation_error: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(oversized_creation_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(
|
|
creation_error.error.message,
|
|
"goal token budget 101 exceeds the maximum allowed goal token budget of 100"
|
|
);
|
|
|
|
let creation_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"objective": "bounded goal",
|
|
})),
|
|
)
|
|
.await?;
|
|
let creation: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(creation_id)).await??;
|
|
assert_eq!(creation.goal.token_budget, Some(100));
|
|
|
|
let clear_budget_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({ "threadId": thread.id, "tokenBudget": null })),
|
|
)
|
|
.await?;
|
|
let clear_budget: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(clear_budget_id)).await??;
|
|
assert_eq!(clear_budget.goal.token_budget, Some(100));
|
|
|
|
let oversized_update_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"tokenBudget": 101,
|
|
})),
|
|
)
|
|
.await?;
|
|
let update_error: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(oversized_update_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(
|
|
update_error.error.message,
|
|
"goal token budget 101 exceeds the maximum allowed goal token budget of 100"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
&config_path,
|
|
config.replace("personality = true\n", "personality = true\ngoals = true\n"),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.2-codex".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize this thread".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _turn_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let goal_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"objective": "keep polishing",
|
|
"status": "budgetLimited",
|
|
"tokenBudget": 10,
|
|
})),
|
|
)
|
|
.await?;
|
|
let goal: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??;
|
|
assert_eq!(goal.goal.status, ThreadGoalStatus::BudgetLimited);
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
|
)
|
|
.await??;
|
|
|
|
let replacement_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"objective": "keep polishing",
|
|
})),
|
|
)
|
|
.await?;
|
|
let replacement: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(replacement_id)).await??;
|
|
|
|
assert_eq!(replacement.goal.status, ThreadGoalStatus::BudgetLimited);
|
|
assert_eq!(replacement.goal.token_budget, Some(10));
|
|
assert_eq!(replacement.goal.tokens_used, 0);
|
|
assert_eq!(replacement.goal.time_used_seconds, 0);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_goal_set_persists_resumable_stopped_statuses() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
&config_path,
|
|
config.replace("personality = true\n", "personality = true\ngoals = true\n"),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.2-codex".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize this thread".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _turn_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
for (wire_status, expected_status) in [
|
|
("blocked", ThreadGoalStatus::Blocked),
|
|
("usageLimited", ThreadGoalStatus::UsageLimited),
|
|
] {
|
|
let goal_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id.clone(),
|
|
"objective": "keep polishing",
|
|
"status": wire_status,
|
|
})),
|
|
)
|
|
.await?;
|
|
let goal: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??;
|
|
assert_eq!(goal.goal.status, expected_status);
|
|
|
|
let notification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
|
)
|
|
.await??;
|
|
let notification: ServerNotification = notification.try_into()?;
|
|
let ServerNotification::ThreadGoalUpdated(notification) = notification else {
|
|
anyhow::bail!("expected thread goal update notification");
|
|
};
|
|
assert_eq!(notification.goal.status, expected_status);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
&config_path,
|
|
config.replace("personality = true\n", "personality = true\ngoals = true\n"),
|
|
)?;
|
|
let thread_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let goal_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread_id,
|
|
"objective": "keep polishing",
|
|
"status": "active",
|
|
"tokenBudget": 40,
|
|
})),
|
|
)
|
|
.await?;
|
|
let goal: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
|
)
|
|
.await??;
|
|
|
|
let state_db = StateRuntime::init(
|
|
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
|
|
"mock_provider".into(),
|
|
)
|
|
.await?;
|
|
let thread_id = ThreadId::from_string(&thread_id)?;
|
|
let thread_metadata = state_db
|
|
.get_thread(thread_id)
|
|
.await?
|
|
.expect("thread metadata should exist");
|
|
assert_eq!(thread_metadata.preview.as_deref(), Some("keep polishing"));
|
|
let persisted_goal = state_db
|
|
.thread_goals()
|
|
.get_thread_goal(thread_id)
|
|
.await?
|
|
.expect("goal should exist");
|
|
state_db
|
|
.thread_goals()
|
|
.account_thread_goal_usage(
|
|
thread_id,
|
|
/*time_delta_seconds*/ 12,
|
|
/*token_delta*/ 50,
|
|
codex_state::GoalAccountingMode::ActiveOnly,
|
|
Some(persisted_goal.goal_id.as_str()),
|
|
)
|
|
.await?;
|
|
|
|
let edit_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread_id.to_string(),
|
|
"objective": "keep polishing with clearer wording",
|
|
"status": "active",
|
|
"tokenBudget": 40,
|
|
})),
|
|
)
|
|
.await?;
|
|
let edit: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(edit_id)).await??;
|
|
let updated_goal = state_db
|
|
.thread_goals()
|
|
.get_thread_goal(thread_id)
|
|
.await?
|
|
.expect("goal should still exist");
|
|
let thread_metadata = state_db
|
|
.get_thread(thread_id)
|
|
.await?
|
|
.expect("thread metadata should still exist");
|
|
|
|
assert_eq!(persisted_goal.goal_id, updated_goal.goal_id);
|
|
assert_eq!(thread_metadata.preview.as_deref(), Some("keep polishing"));
|
|
assert_eq!(edit.goal.objective, "keep polishing with clearer wording");
|
|
assert_eq!(edit.goal.status, ThreadGoalStatus::BudgetLimited);
|
|
assert_eq!(edit.goal.token_budget, Some(40));
|
|
assert_eq!(edit.goal.tokens_used, 50);
|
|
assert_eq!(edit.goal.time_used_seconds, 12);
|
|
assert_eq!(edit.goal.created_at, goal.goal.created_at);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_goal_keeps_original_root_until_external_objective_edit() -> Result<()> {
|
|
let (release_original_turn, original_turn_gate) = oneshot::channel();
|
|
let (release_edited_turn, edited_turn_gate) = oneshot::channel();
|
|
let (server, _response_completions) = start_streaming_sse_server(vec![
|
|
ungated_goal_response(responses::sse(vec![
|
|
responses::ev_response_created("create-original-goal"),
|
|
responses::ev_function_call(
|
|
"create-original-goal-call",
|
|
"create_goal",
|
|
r#"{"objective":"keep its original owner","token_budget":100}"#,
|
|
),
|
|
responses::ev_completed_with_tokens("create-original-goal", /*total_tokens*/ 5),
|
|
])),
|
|
vec![StreamingSseChunk {
|
|
gate: Some(original_turn_gate),
|
|
body: responses::sse_completed("finish-original-user-turn"),
|
|
}],
|
|
ungated_goal_response(responses::sse_completed("reopen-original-user-turn")),
|
|
ungated_goal_response(responses::sse_completed("finish-intervening-user-turn")),
|
|
ungated_goal_response(responses::sse(vec![
|
|
responses::ev_response_created("goal-continuation-after-intervening-turn"),
|
|
responses::ev_completed_with_tokens(
|
|
"goal-continuation-after-intervening-turn",
|
|
/*total_tokens*/ 40,
|
|
),
|
|
])),
|
|
vec![StreamingSseChunk {
|
|
gate: Some(edited_turn_gate),
|
|
body: responses::sse_completed("second-goal-continuation"),
|
|
}],
|
|
ungated_goal_response(responses::sse_completed("reopened-goal-turn")),
|
|
ungated_goal_response(responses::sse(vec![
|
|
responses::ev_response_created("rootless-goal-continuation"),
|
|
responses::ev_completed_with_tokens(
|
|
"rootless-goal-continuation",
|
|
/*total_tokens*/ 100,
|
|
),
|
|
])),
|
|
])
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(server.uri())
|
|
.enable_feature(Feature::Goals)
|
|
.write(codex_home.path())?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let thread = mcp.start_thread(ThreadStartParams::default()).await?.thread;
|
|
|
|
let start_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "create the original goal".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let original_turn: TurnStartResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
server.wait_for_request_count(/*count*/ 2),
|
|
)
|
|
.await?;
|
|
|
|
let injection_id = mcp
|
|
.send_raw_request(
|
|
"thread/inject_items",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"items": [{
|
|
"type": "message",
|
|
"role": "developer",
|
|
"content": [{
|
|
"type": "input_text",
|
|
"text": "externally injected context",
|
|
}],
|
|
}],
|
|
})),
|
|
)
|
|
.await?;
|
|
let _: serde_json::Value =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(injection_id)).await??;
|
|
|
|
let queue_id = mcp
|
|
.send_raw_request(
|
|
"thread/queue/add",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"input": [{
|
|
"type": "text",
|
|
"text": "an intervening user message",
|
|
"textElements": [],
|
|
}],
|
|
"clientUserMessageId": "intervening-goal-message",
|
|
})),
|
|
)
|
|
.await?;
|
|
let _: serde_json::Value = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(queue_id)).await??;
|
|
release_original_turn
|
|
.send(())
|
|
.expect("original turn should remain open until the user message is queued");
|
|
|
|
for _ in 0..3 {
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
}
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
server.wait_for_request_count(/*count*/ 6),
|
|
)
|
|
.await?;
|
|
|
|
let edit_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"objective": "externally updated goal",
|
|
"status": "active",
|
|
})),
|
|
)
|
|
.await?;
|
|
let edited_goal: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(edit_id)).await??;
|
|
assert_eq!(edited_goal.goal.objective, "externally updated goal");
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
|
)
|
|
.await??;
|
|
|
|
let get_id = mcp
|
|
.send_raw_request("thread/goal/get", Some(json!({ "threadId": thread.id })))
|
|
.await?;
|
|
let _: codex_app_server_protocol::ThreadGoalGetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(get_id)).await??;
|
|
release_edited_turn
|
|
.send(())
|
|
.expect("goal turn should remain open until its external edit");
|
|
|
|
for _ in 0..2 {
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
}
|
|
|
|
let requests = server.requests().await;
|
|
assert_eq!(requests.len(), 8);
|
|
let reopened_original_request = serde_json::from_slice::<serde_json::Value>(&requests[2])?;
|
|
assert_eq!(
|
|
reopened_original_request["client_metadata"]["turn_id"].as_str(),
|
|
Some(original_turn.turn.id.as_str())
|
|
);
|
|
responses::assert_root_turn(
|
|
&reopened_original_request,
|
|
Some(original_turn.turn.id.as_str()),
|
|
)?;
|
|
let intervening_request = serde_json::from_slice::<serde_json::Value>(&requests[3])?;
|
|
let intervening_turn_id = intervening_request["client_metadata"]["turn_id"]
|
|
.as_str()
|
|
.expect("intervening user turn ID");
|
|
responses::assert_root_turn(&intervening_request, Some(intervening_turn_id))?;
|
|
let first_continuation = serde_json::from_slice::<serde_json::Value>(&requests[4])?;
|
|
let first_continuation_turn_id = first_continuation["client_metadata"]["turn_id"]
|
|
.as_str()
|
|
.expect("first continuation turn ID");
|
|
let second_continuation = serde_json::from_slice::<serde_json::Value>(&requests[5])?;
|
|
for (request, parent_turn_id) in [
|
|
(&first_continuation, intervening_turn_id),
|
|
(&second_continuation, first_continuation_turn_id),
|
|
] {
|
|
responses::assert_root_turn(request, Some(original_turn.turn.id.as_str()))?;
|
|
responses::assert_parent_turn(request, Some(parent_turn_id))?;
|
|
}
|
|
let edited_turn_id = second_continuation["client_metadata"]["turn_id"]
|
|
.as_str()
|
|
.expect("second continuation turn ID");
|
|
|
|
let reopened_request = serde_json::from_slice::<serde_json::Value>(&requests[6])?;
|
|
assert_eq!(
|
|
reopened_request["client_metadata"]["turn_id"].as_str(),
|
|
Some(edited_turn_id)
|
|
);
|
|
responses::assert_root_turn(&reopened_request, Some(original_turn.turn.id.as_str()))?;
|
|
let continuation_request = serde_json::from_slice::<serde_json::Value>(&requests[7])?;
|
|
let continuation_turn_id = continuation_request["client_metadata"]["turn_id"]
|
|
.as_str()
|
|
.expect("independent continuation turn ID");
|
|
assert_ne!(continuation_turn_id, edited_turn_id);
|
|
responses::assert_root_turn(&continuation_request, Some(continuation_turn_id))?;
|
|
responses::assert_parent_turn(&continuation_request, /*expected*/ None)?;
|
|
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
fn ungated_goal_response(body: String) -> Vec<StreamingSseChunk> {
|
|
vec![StreamingSseChunk { gate: None, body }]
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_goal_lifecycle_emits_analytics_and_clear_deletes_goal() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(vec![
|
|
responses::sse(vec![
|
|
responses::ev_response_created("materialize-thread"),
|
|
responses::ev_completed("materialize-thread"),
|
|
]),
|
|
responses::sse(vec![
|
|
responses::ev_response_created("goal-continuation"),
|
|
responses::ev_completed_with_tokens("goal-continuation", /*total_tokens*/ 200),
|
|
]),
|
|
])
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.with_root_config(&format!(r#"chatgpt_base_url = "{}""#, server.uri()))
|
|
.write(codex_home.path())?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
&config_path,
|
|
config.replace("personality = true\n", "personality = true\ngoals = true\n"),
|
|
)?;
|
|
mount_analytics_capture(&server, codex_home.path()).await?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT.saturating_mul(2))
|
|
.await?;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.2-codex".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize this thread".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _turn_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let goal_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/set",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
"objective": "do not serialize this objective",
|
|
"tokenBudget": 100,
|
|
})),
|
|
)
|
|
.await?;
|
|
let _goal: ThreadGoalSetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
|
)
|
|
.await??;
|
|
|
|
let created = wait_for_goal_event(&server, DEFAULT_READ_TIMEOUT, "created", "active").await?;
|
|
let persisted_goal_id = created["event_params"]["goal_id"]
|
|
.as_str()
|
|
.expect("created goal id");
|
|
assert_eq!(created["event_params"]["thread_id"], thread.id);
|
|
assert_eq!(created["event_params"]["turn_id"], serde_json::Value::Null);
|
|
assert_eq!(created["event_params"]["has_token_budget"], true);
|
|
assert!(created["event_params"]["session_id"].is_string());
|
|
assert!(created["event_params"]["app_server_client"].is_object());
|
|
assert!(created["event_params"]["runtime"].is_object());
|
|
assert!(created["event_params"].get("objective").is_none());
|
|
assert!(created["event_params"].get("token_budget").is_none());
|
|
|
|
let usage = wait_for_goal_event(
|
|
&server,
|
|
DEFAULT_READ_TIMEOUT,
|
|
"usage_accounted",
|
|
"budget_limited",
|
|
)
|
|
.await?;
|
|
let causal_turn_id = usage["event_params"]["turn_id"]
|
|
.as_str()
|
|
.expect("accounted usage turn id");
|
|
assert_eq!(usage["event_params"]["goal_id"], persisted_goal_id);
|
|
assert_eq!(usage["event_params"]["cumulative_tokens_accounted"], 200);
|
|
assert!(
|
|
usage["event_params"]["cumulative_time_accounted_seconds"]
|
|
.as_i64()
|
|
.is_some()
|
|
);
|
|
|
|
let requests = server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record response requests");
|
|
let response_requests = requests
|
|
.iter()
|
|
.filter(|request| request.url.path().ends_with("/responses"))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(response_requests.len(), 2);
|
|
let metadata_header = response_requests[1]
|
|
.headers
|
|
.get("x-codex-turn-metadata")
|
|
.expect("goal continuation should include turn metadata")
|
|
.to_str()?;
|
|
let metadata: serde_json::Value = serde_json::from_str(metadata_header)?;
|
|
assert_eq!(metadata["turn_trigger"].as_str(), Some("goal"));
|
|
|
|
let status = wait_for_goal_event(
|
|
&server,
|
|
DEFAULT_READ_TIMEOUT,
|
|
"status_changed",
|
|
"budget_limited",
|
|
)
|
|
.await?;
|
|
assert_eq!(status["event_params"]["goal_id"], persisted_goal_id);
|
|
assert_eq!(status["event_params"]["turn_id"], causal_turn_id);
|
|
assert_eq!(
|
|
status["event_params"]["cumulative_tokens_accounted"],
|
|
serde_json::Value::Null
|
|
);
|
|
assert_eq!(
|
|
status["event_params"]["cumulative_time_accounted_seconds"],
|
|
serde_json::Value::Null
|
|
);
|
|
|
|
let requests = server.received_requests().await.expect("wiremock requests");
|
|
let goal_request = requests
|
|
.iter()
|
|
.filter(|request| request.url.path().ends_with("/responses"))
|
|
.nth(1)
|
|
.expect("externally created goal continuation request");
|
|
let goal_request_body = goal_request.body_json::<serde_json::Value>()?;
|
|
assert_eq!(
|
|
goal_request_body["client_metadata"]["turn_id"],
|
|
causal_turn_id
|
|
);
|
|
responses::assert_root_turn(&goal_request_body, Some(causal_turn_id))?;
|
|
responses::assert_parent_turn(&goal_request_body, /*expected*/ None)?;
|
|
|
|
let clear_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/clear",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
})),
|
|
)
|
|
.await?;
|
|
let clear: ThreadGoalClearResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(clear_id)).await??;
|
|
assert!(clear.cleared);
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/goal/cleared"),
|
|
)
|
|
.await??;
|
|
|
|
let cleared =
|
|
wait_for_goal_event(&server, DEFAULT_READ_TIMEOUT, "cleared", "budget_limited").await?;
|
|
assert_eq!(cleared["event_params"]["goal_id"], persisted_goal_id);
|
|
assert_eq!(cleared["event_params"]["turn_id"], serde_json::Value::Null);
|
|
|
|
let get_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/get",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
})),
|
|
)
|
|
.await?;
|
|
let get: codex_app_server_protocol::ThreadGoalGetResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(get_id)).await??;
|
|
assert_eq!(None, get.goal);
|
|
|
|
let clear_again_id = mcp
|
|
.send_raw_request(
|
|
"thread/goal/clear",
|
|
Some(json!({
|
|
"threadId": thread.id,
|
|
})),
|
|
)
|
|
.await?;
|
|
let clear_again: ThreadGoalClearResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(clear_again_id)).await??;
|
|
assert!(!clear_again.cleared);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_emits_restored_token_usage_before_next_turn() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let conversation_id = create_fake_rollout_with_token_usage(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
let note = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/tokenUsage/updated"),
|
|
)
|
|
.await??;
|
|
let parsed: ServerNotification = note.try_into()?;
|
|
let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else {
|
|
panic!("expected thread/tokenUsage/updated notification");
|
|
};
|
|
|
|
assert_eq!(notification.thread_id, thread.id);
|
|
assert_eq!(notification.turn_id, thread.turns[0].id);
|
|
assert_eq!(notification.token_usage.total.total_tokens, 150);
|
|
assert_eq!(notification.token_usage.total.input_tokens, 120);
|
|
assert_eq!(notification.token_usage.total.cached_input_tokens, 20);
|
|
assert_eq!(notification.token_usage.total.output_tokens, 30);
|
|
assert_eq!(notification.token_usage.total.reasoning_output_tokens, 10);
|
|
assert_eq!(notification.token_usage.last.total_tokens, 90);
|
|
assert_eq!(notification.token_usage.model_context_window, Some(200_000));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_paginated_resume_restores_usage_without_loading_turns() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let conversation_id = create_fake_paginated_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &conversation_id);
|
|
let canonical_turn_id = "persisted-token-usage-turn";
|
|
append_rollout_item_to_path(
|
|
&path,
|
|
&RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: canonical_turn_id.to_string(),
|
|
trace_id: None,
|
|
started_at: None,
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
})),
|
|
)
|
|
.await?;
|
|
append_rollout_item_to_path(
|
|
&path,
|
|
&RolloutItem::EventMsg(EventMsg::TokenCount(TokenCountEvent {
|
|
info: Some(TokenUsageInfo {
|
|
total_token_usage: TokenUsage {
|
|
input_tokens: 120,
|
|
output_tokens: 30,
|
|
total_tokens: 150,
|
|
..Default::default()
|
|
},
|
|
last_token_usage: TokenUsage {
|
|
input_tokens: 70,
|
|
output_tokens: 20,
|
|
total_tokens: 90,
|
|
..Default::default()
|
|
},
|
|
model_context_window: Some(200_000),
|
|
}),
|
|
rate_limits: None,
|
|
})),
|
|
)
|
|
.await?;
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = app_server
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(resume_id)).await??;
|
|
assert!(thread.turns.is_empty());
|
|
|
|
let notification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
app_server.read_stream_until_notification_message("thread/tokenUsage/updated"),
|
|
)
|
|
.await??;
|
|
let ServerNotification::ThreadTokenUsageUpdated(notification) = notification.try_into()? else {
|
|
panic!("expected thread/tokenUsage/updated notification");
|
|
};
|
|
assert_eq!(notification.thread_id, thread.id);
|
|
assert_eq!(notification.turn_id, canonical_turn_id);
|
|
assert_eq!(notification.token_usage.total.total_tokens, 150);
|
|
|
|
let turns_id = app_server
|
|
.send_thread_turns_list_request(ThreadTurnsListParams {
|
|
thread_id: thread.id,
|
|
cursor: None,
|
|
limit: Some(1),
|
|
sort_direction: Some(SortDirection::Desc),
|
|
items_view: Some(TurnItemsView::NotLoaded),
|
|
})
|
|
.await?;
|
|
let turns: ThreadTurnsListResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(turns_id)).await??;
|
|
assert_eq!(notification.turn_id, turns.data[0].id);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_paginated_resume_omits_usage_when_its_turn_is_ambiguous() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let filename_ts = "2025-01-05T12-00-00";
|
|
let conversation_id = create_fake_paginated_rollout(
|
|
codex_home.path(),
|
|
filename_ts,
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
|
|
append_rollout_item_to_path(
|
|
&path,
|
|
&RolloutItem::EventMsg(EventMsg::TokenCount(TokenCountEvent {
|
|
info: Some(TokenUsageInfo {
|
|
total_token_usage: TokenUsage {
|
|
total_tokens: 150,
|
|
..Default::default()
|
|
},
|
|
last_token_usage: TokenUsage {
|
|
total_tokens: 90,
|
|
..Default::default()
|
|
},
|
|
model_context_window: Some(200_000),
|
|
}),
|
|
rate_limits: None,
|
|
})),
|
|
)
|
|
.await?;
|
|
let interrupted_turn_id = "interrupted-turn-after-token-usage";
|
|
append_rollout_item_to_path(
|
|
&path,
|
|
&RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: interrupted_turn_id.to_string(),
|
|
trace_id: None,
|
|
started_at: None,
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
})),
|
|
)
|
|
.await?;
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = app_server
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(resume_id)).await??;
|
|
assert!(thread.turns.is_empty());
|
|
let turns_id = app_server
|
|
.send_thread_turns_list_request(ThreadTurnsListParams {
|
|
thread_id: thread.id,
|
|
cursor: None,
|
|
limit: Some(1),
|
|
sort_direction: Some(SortDirection::Desc),
|
|
items_view: Some(TurnItemsView::NotLoaded),
|
|
})
|
|
.await?;
|
|
let turns: ThreadTurnsListResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(turns_id)).await??;
|
|
assert_eq!(turns.data[0].id, interrupted_turn_id);
|
|
assert!(
|
|
timeout(
|
|
Duration::from_millis(100),
|
|
app_server.read_stream_until_notification_message("thread/tokenUsage/updated"),
|
|
)
|
|
.await
|
|
.is_err(),
|
|
"usage owned by an implicit turn must not be attributed to {interrupted_turn_id}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_skips_restored_token_usage_when_turns_are_excluded() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let conversation_id = create_fake_rollout_with_token_usage(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let first_resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let first_resume_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(first_resume_id)),
|
|
)
|
|
.await??;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
to_response::<ThreadResumeResponse>(first_resume_resp)?;
|
|
let expected_turn_id = thread.turns[0].id.clone();
|
|
|
|
let first_note = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/tokenUsage/updated"),
|
|
)
|
|
.await??;
|
|
let parsed: ServerNotification = first_note.try_into()?;
|
|
let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else {
|
|
panic!("expected thread/tokenUsage/updated notification");
|
|
};
|
|
assert_eq!(notification.turn_id, expected_turn_id);
|
|
|
|
let second_resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed_again,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(second_resume_id)).await??;
|
|
assert!(resumed_again.turns.is_empty());
|
|
|
|
let second_note = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/tokenUsage/updated"),
|
|
)
|
|
.await;
|
|
assert!(
|
|
second_note.is_err(),
|
|
"excludeTurns=true should not replay token usage"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let filename_ts = "2025-01-05T12-00-00";
|
|
let meta_rfc3339 = "2025-01-05T12:00:00Z";
|
|
let conversation_id = create_fake_rollout_with_token_usage(
|
|
codex_home.path(),
|
|
filename_ts,
|
|
meta_rfc3339,
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
)?;
|
|
let rollout_file_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
|
|
let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?;
|
|
let stale_turn_id = "incomplete-turn-after-token-usage";
|
|
let appended_rollout = [
|
|
json!({
|
|
"timestamp": meta_rfc3339,
|
|
"type": "event_msg",
|
|
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: stale_turn_id.to_string(),
|
|
trace_id: None,
|
|
started_at: None,
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
}))?,
|
|
})
|
|
.to_string(),
|
|
json!({
|
|
"timestamp": meta_rfc3339,
|
|
"type": "event_msg",
|
|
"payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent {
|
|
message: "Still running".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
}))?,
|
|
})
|
|
.to_string(),
|
|
]
|
|
.join("\n");
|
|
std::fs::write(
|
|
&rollout_file_path,
|
|
format!("{persisted_rollout}{appended_rollout}\n"),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(thread.turns.len(), 2);
|
|
assert_eq!(thread.turns[0].status, TurnStatus::Completed);
|
|
assert_eq!(thread.turns[1].id, stale_turn_id);
|
|
assert_eq!(thread.turns[1].status, TurnStatus::Interrupted);
|
|
|
|
let note = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/tokenUsage/updated"),
|
|
)
|
|
.await??;
|
|
let parsed: ServerNotification = note.try_into()?;
|
|
let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else {
|
|
panic!("expected thread/tokenUsage/updated notification");
|
|
};
|
|
|
|
assert_eq!(notification.thread_id, thread.id);
|
|
assert_eq!(notification.turn_id, thread.turns[0].id);
|
|
assert_ne!(notification.turn_id, stale_turn_id);
|
|
assert_eq!(notification.token_usage.total.total_tokens, 150);
|
|
assert_eq!(notification.token_usage.last.total_tokens, 90);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let filename_ts = "2025-01-05T12-00-00";
|
|
let meta_rfc3339 = "2025-01-05T12:00:00Z";
|
|
let conversation_id = create_fake_rollout_with_token_usage(
|
|
codex_home.path(),
|
|
filename_ts,
|
|
meta_rfc3339,
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
)?;
|
|
let rollout_file_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
|
|
let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?;
|
|
let interrupted_turn_id = "interrupted-turn-with-token-usage";
|
|
let appended_rollout = [
|
|
json!({
|
|
"timestamp": meta_rfc3339,
|
|
"type": "event_msg",
|
|
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: interrupted_turn_id.to_string(),
|
|
trace_id: None,
|
|
started_at: None,
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
}))?,
|
|
})
|
|
.to_string(),
|
|
json!({
|
|
"timestamp": meta_rfc3339,
|
|
"type": "event_msg",
|
|
"payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent {
|
|
message: "Interrupted after usage".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
}))?,
|
|
})
|
|
.to_string(),
|
|
json!({
|
|
"timestamp": meta_rfc3339,
|
|
"type": "event_msg",
|
|
"payload": serde_json::to_value(EventMsg::TokenCount(TokenCountEvent {
|
|
info: Some(TokenUsageInfo {
|
|
total_token_usage: TokenUsage {
|
|
input_tokens: 180,
|
|
cached_input_tokens: 40,
|
|
cache_write_input_tokens: 0,
|
|
output_tokens: 50,
|
|
reasoning_output_tokens: 15,
|
|
total_tokens: 230,
|
|
codex_rollout_budget_units: None,
|
|
},
|
|
last_token_usage: TokenUsage {
|
|
input_tokens: 90,
|
|
cached_input_tokens: 30,
|
|
cache_write_input_tokens: 0,
|
|
output_tokens: 40,
|
|
reasoning_output_tokens: 12,
|
|
total_tokens: 130,
|
|
codex_rollout_budget_units: None,
|
|
},
|
|
model_context_window: Some(200_000),
|
|
}),
|
|
rate_limits: None,
|
|
}))?,
|
|
})
|
|
.to_string(),
|
|
json!({
|
|
"timestamp": meta_rfc3339,
|
|
"type": "event_msg",
|
|
"payload": serde_json::to_value(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
turn_id: Some(interrupted_turn_id.to_string()),
|
|
started_at: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
}))?,
|
|
})
|
|
.to_string(),
|
|
]
|
|
.join("\n");
|
|
std::fs::write(
|
|
&rollout_file_path,
|
|
format!("{persisted_rollout}{appended_rollout}\n"),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(thread.turns.len(), 2);
|
|
assert_eq!(thread.turns[0].status, TurnStatus::Completed);
|
|
assert_eq!(thread.turns[1].id, interrupted_turn_id);
|
|
assert_eq!(thread.turns[1].status, TurnStatus::Interrupted);
|
|
|
|
let note = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/tokenUsage/updated"),
|
|
)
|
|
.await??;
|
|
let parsed: ServerNotification = note.try_into()?;
|
|
let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else {
|
|
panic!("expected thread/tokenUsage/updated notification");
|
|
};
|
|
|
|
assert_eq!(notification.thread_id, thread.id);
|
|
assert_eq!(notification.turn_id, interrupted_turn_id);
|
|
assert_eq!(notification.token_usage.total.total_tokens, 230);
|
|
assert_eq!(notification.token_usage.last.total_tokens, 130);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.enable_feature(Feature::Sqlite)
|
|
.write(codex_home.path())?;
|
|
|
|
let repo_path = codex_home.path().join("repo");
|
|
std::fs::create_dir_all(&repo_path)?;
|
|
assert!(
|
|
Command::new("git")
|
|
.args(["init"])
|
|
.arg(&repo_path)
|
|
.status()?
|
|
.success()
|
|
);
|
|
assert!(
|
|
Command::new("git")
|
|
.current_dir(&repo_path)
|
|
.args(["checkout", "-B", "master"])
|
|
.status()?
|
|
.success()
|
|
);
|
|
assert!(
|
|
Command::new("git")
|
|
.current_dir(&repo_path)
|
|
.args(["config", "user.name", "Test User"])
|
|
.status()?
|
|
.success()
|
|
);
|
|
assert!(
|
|
Command::new("git")
|
|
.current_dir(&repo_path)
|
|
.args(["config", "user.email", "test@example.com"])
|
|
.status()?
|
|
.success()
|
|
);
|
|
std::fs::write(repo_path.join("README.md"), "test\n")?;
|
|
assert!(
|
|
Command::new("git")
|
|
.current_dir(&repo_path)
|
|
.args(["add", "README.md"])
|
|
.status()?
|
|
.success()
|
|
);
|
|
assert!(
|
|
Command::new("git")
|
|
.current_dir(&repo_path)
|
|
.args(["commit", "-m", "initial"])
|
|
.status()?
|
|
.success()
|
|
);
|
|
let head_branch = Command::new("git")
|
|
.current_dir(&repo_path)
|
|
.args(["branch", "--show-current"])
|
|
.output()?;
|
|
assert_eq!(
|
|
String::from_utf8(head_branch.stdout)?.trim(),
|
|
"master",
|
|
"test repo should stay on master to verify resume ignores live HEAD"
|
|
);
|
|
|
|
let thread_id = Uuid::new_v4().to_string();
|
|
let conversation_id = ThreadId::from_string(&thread_id)?;
|
|
let rollout_path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &thread_id);
|
|
let rollout_dir = rollout_path.parent().expect("rollout parent directory");
|
|
std::fs::create_dir_all(rollout_dir)?;
|
|
let session_meta = SessionMeta {
|
|
session_id: conversation_id.into(),
|
|
id: conversation_id,
|
|
forked_from_id: None,
|
|
forked_from_ordinal_exclusive: None,
|
|
parent_thread_id: None,
|
|
timestamp: "2025-01-05T12:00:00Z".to_string(),
|
|
cwd: repo_path.clone(),
|
|
runtime_workspace_roots: None,
|
|
originator: "codex".to_string(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: RolloutSessionSource::Cli,
|
|
thread_source: None,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
model_provider: Some("mock_provider".to_string()),
|
|
base_instructions: None,
|
|
dynamic_tools: None,
|
|
selected_capability_roots: Vec::new(),
|
|
memory_mode: None,
|
|
history_mode: Default::default(),
|
|
history_base: None,
|
|
subagent_history_start_ordinal: None,
|
|
multi_agent_version: None,
|
|
context_window: None,
|
|
};
|
|
std::fs::write(
|
|
&rollout_path,
|
|
[
|
|
json!({
|
|
"timestamp": "2025-01-05T12:00:00Z",
|
|
"type": "session_meta",
|
|
"payload": serde_json::to_value(SessionMetaLine {
|
|
meta: session_meta,
|
|
git: None,
|
|
})?,
|
|
})
|
|
.to_string(),
|
|
json!({
|
|
"timestamp": "2025-01-05T12:00:00Z",
|
|
"type": "response_item",
|
|
"payload": {
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Saved user message"}]
|
|
}
|
|
})
|
|
.to_string(),
|
|
json!({
|
|
"timestamp": "2025-01-05T12:00:00Z",
|
|
"type": "event_msg",
|
|
"payload": {
|
|
"type": "user_message",
|
|
"message": "Saved user message",
|
|
"kind": "plain"
|
|
}
|
|
})
|
|
.to_string(),
|
|
]
|
|
.join("\n")
|
|
+ "\n",
|
|
)?;
|
|
let state_db = StateRuntime::init(
|
|
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
|
|
"mock_provider".into(),
|
|
)
|
|
.await?;
|
|
state_db
|
|
.mark_backfill_complete(/*last_watermark*/ None)
|
|
.await?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let update_id = mcp
|
|
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
|
|
thread_id: thread_id.clone(),
|
|
project_id: None,
|
|
daybreak_enabled: None,
|
|
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
|
sha: None,
|
|
branch: Some(Some("feature/pr-branch".to_string())),
|
|
origin_url: None,
|
|
}),
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(update_id)),
|
|
)
|
|
.await??;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(
|
|
thread
|
|
.git_info
|
|
.as_ref()
|
|
.and_then(|git| git.branch.as_deref()),
|
|
Some("feature/pr-branch")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is_idle() -> Result<()>
|
|
{
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let filename_ts = "2025-01-05T12-00-00";
|
|
let meta_rfc3339 = "2025-01-05T12:00:00Z";
|
|
let conversation_id = create_fake_rollout_with_text_elements(
|
|
codex_home.path(),
|
|
filename_ts,
|
|
meta_rfc3339,
|
|
"Saved user message",
|
|
Vec::new(),
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let rollout_file_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
|
|
let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?;
|
|
let turn_id = "incomplete-turn";
|
|
let appended_rollout = [
|
|
json!({
|
|
"timestamp": meta_rfc3339,
|
|
"type": "event_msg",
|
|
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: turn_id.to_string(),
|
|
trace_id: None,
|
|
started_at: None,
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
}))?,
|
|
})
|
|
.to_string(),
|
|
json!({
|
|
"timestamp": meta_rfc3339,
|
|
"type": "event_msg",
|
|
"payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent {
|
|
message: "Still running".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
}))?,
|
|
})
|
|
.to_string(),
|
|
]
|
|
.join("\n");
|
|
std::fs::write(
|
|
&rollout_file_path,
|
|
format!("{persisted_rollout}{appended_rollout}\n"),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(thread.status, ThreadStatus::Idle);
|
|
assert_eq!(thread.turns.len(), 2);
|
|
assert_eq!(thread.turns[0].status, TurnStatus::Completed);
|
|
assert_eq!(thread.turns[1].id, turn_id);
|
|
assert_eq!(thread.turns[1].status, TurnStatus::Interrupted);
|
|
|
|
let second_resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed_again,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(second_resume_id)).await??;
|
|
|
|
assert_eq!(resumed_again.status, ThreadStatus::Idle);
|
|
assert_eq!(resumed_again.turns.len(), 2);
|
|
assert_eq!(resumed_again.turns[1].id, turn_id);
|
|
assert_eq!(resumed_again.turns[1].status, TurnStatus::Interrupted);
|
|
|
|
let read_id = mcp
|
|
.send_thread_read_request(ThreadReadParams {
|
|
thread_id: resumed_again.id,
|
|
include_turns: true,
|
|
})
|
|
.await?;
|
|
let ThreadReadResponse {
|
|
thread: read_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
|
|
|
|
assert_eq!(read_thread.status, ThreadStatus::Idle);
|
|
assert_eq!(read_thread.turns.len(), 2);
|
|
assert_eq!(read_thread.turns[1].id, turn_id);
|
|
assert_eq!(read_thread.turns[1].status, TurnStatus::Interrupted);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_checkpoints_settings_without_advancing_recency() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?;
|
|
let thread_id = rollout.conversation_id.clone();
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let read_id = mcp
|
|
.send_thread_read_request(ThreadReadParams {
|
|
thread_id: thread_id.clone(),
|
|
include_turns: false,
|
|
})
|
|
.await?;
|
|
let ThreadReadResponse {
|
|
thread: before_resume,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(thread.recency_at, before_resume.recency_at);
|
|
assert_eq!(thread.status, ThreadStatus::Idle);
|
|
|
|
let expected_thread_id = ThreadId::from_string(&thread_id)?;
|
|
let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout.rollout_file_path).await?;
|
|
assert!(items.iter().any(|item| matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event))
|
|
if event.thread_id == Some(expected_thread_id)
|
|
)));
|
|
|
|
let unsubscribe_id = mcp
|
|
.send_thread_unsubscribe_request(ThreadUnsubscribeParams {
|
|
thread_id: thread_id.clone(),
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(unsubscribe_id)),
|
|
)
|
|
.await??;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: "not-a-valid-thread-id".to_string(),
|
|
path: Some(normalized_existing_path(&rollout.rollout_file_path)?),
|
|
cwd: Some(codex_home.path().to_string_lossy().to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse { cwd, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
assert_eq!(cwd, AbsolutePathBuf::from_absolute_path(codex_home.path())?);
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread_id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "Hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/started"),
|
|
)
|
|
.await??;
|
|
|
|
let read_id = mcp
|
|
.send_thread_read_request(ThreadReadParams {
|
|
thread_id: thread_id.clone(),
|
|
include_turns: false,
|
|
})
|
|
.await?;
|
|
let ThreadReadResponse {
|
|
thread: after_turn_start,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
|
|
assert!(after_turn_start.recency_at > before_resume.recency_at);
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut primary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = primary
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.read_response(start_id)).await??;
|
|
|
|
let seed_turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "seed history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
primary.clear_message_buffer();
|
|
|
|
let turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "respond with docs".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/started"),
|
|
)
|
|
.await??;
|
|
|
|
let resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, primary.read_response(resume_id)).await??;
|
|
assert_ne!(resumed_thread.status, ThreadStatus::NotLoaded);
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
|
|
let server = responses::start_mock_server().await;
|
|
let first_body = responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_assistant_message("msg-1", "Done"),
|
|
responses::ev_completed("resp-1"),
|
|
]);
|
|
let second_response = responses::sse_response(responses::sse(vec![
|
|
responses::ev_response_created("resp-2"),
|
|
responses::ev_assistant_message("msg-2", "Done"),
|
|
responses::ev_completed("resp-2"),
|
|
]))
|
|
.set_delay(std::time::Duration::from_millis(500));
|
|
let _first_response_mock = responses::mount_sse_once(&server, first_body).await;
|
|
let _second_response_mock = responses::mount_response_once(&server, second_response).await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut primary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = primary
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.read_response(start_id)).await??;
|
|
|
|
let seed_turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "seed history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
primary.clear_message_buffer();
|
|
|
|
let thread_id = thread.id.clone();
|
|
let running_turn_request_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread_id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "keep running".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let running_turn_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(running_turn_request_id)),
|
|
)
|
|
.await??;
|
|
let TurnStartResponse { turn: running_turn } =
|
|
to_response::<TurnStartResponse>(running_turn_resp)?;
|
|
assert_eq!(running_turn.items_view, TurnItemsView::NotLoaded);
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/started"),
|
|
)
|
|
.await??;
|
|
|
|
let resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
history: Some(vec![ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "history override".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}]),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let resume_err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
resume_err.error.message.contains("cannot resume thread")
|
|
&& resume_err.error.message.contains("with history")
|
|
&& resume_err.error.message.contains("running"),
|
|
"unexpected resume error: {}",
|
|
resume_err.error.message
|
|
);
|
|
|
|
primary
|
|
.interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result<()> {
|
|
let server = responses::start_mock_server().await;
|
|
let first_body = responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_assistant_message("msg-1", "Done"),
|
|
responses::ev_completed("resp-1"),
|
|
]);
|
|
let second_response = responses::sse_response(responses::sse(vec![
|
|
responses::ev_response_created("resp-2"),
|
|
responses::ev_assistant_message("msg-2", "Done"),
|
|
responses::ev_completed("resp-2"),
|
|
]))
|
|
.set_delay(std::time::Duration::from_millis(500));
|
|
let _first_response_mock = responses::mount_sse_once(&server, first_body).await;
|
|
let _second_response_mock = responses::mount_response_once(&server, second_response).await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut primary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = primary
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.read_response(start_id)).await??;
|
|
|
|
let seed_turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "seed history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
primary.clear_message_buffer();
|
|
|
|
let thread_id = thread.id.clone();
|
|
let running_turn_request_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread_id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "keep running".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let running_turn_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(running_turn_request_id)),
|
|
)
|
|
.await??;
|
|
let TurnStartResponse { turn: running_turn } =
|
|
to_response::<TurnStartResponse>(running_turn_resp)?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/started"),
|
|
)
|
|
.await??;
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
let active_path = thread.path.as_ref().expect("thread should have path");
|
|
let active_path_display = active_path.as_os_str().to_string_lossy();
|
|
let equivalent_path = if let Some(path) = active_path_display.strip_prefix(r"\\?\UNC\") {
|
|
PathBuf::from(format!(r"\\{path}"))
|
|
} else if let Some(path) = active_path_display.strip_prefix(r"\\?\") {
|
|
PathBuf::from(path)
|
|
} else if let Some(path) = active_path_display.strip_prefix(r"\\") {
|
|
PathBuf::from(format!(r"\\?\UNC\{path}"))
|
|
} else {
|
|
PathBuf::from(format!(r"\\?\{active_path_display}"))
|
|
};
|
|
let normalized_resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
path: Some(equivalent_path),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let normalized_resume_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(normalized_resume_id)),
|
|
)
|
|
.await??;
|
|
let ThreadResumeResponse { thread, .. } =
|
|
to_response::<ThreadResumeResponse>(normalized_resume_resp)?;
|
|
assert_eq!(thread.id, thread_id);
|
|
}
|
|
|
|
let stale_thread_id = Uuid::new_v4().to_string();
|
|
let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &stale_thread_id);
|
|
std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?;
|
|
let thread_uuid = Uuid::parse_str(&stale_thread_id)?;
|
|
let mut stale_file = std::fs::File::create(&stale_path)?;
|
|
let stale_meta = json!({
|
|
"timestamp": "2025-01-01T00:00:00Z",
|
|
"type": "session_meta",
|
|
"payload": {
|
|
"session_id": thread_uuid,
|
|
"id": thread_uuid,
|
|
"timestamp": "2025-01-01T00:00:00Z",
|
|
"cwd": codex_home.path(),
|
|
"originator": "test_originator",
|
|
"cli_version": "test_version",
|
|
"source": "cli",
|
|
"model_provider": "test-provider",
|
|
},
|
|
});
|
|
writeln!(stale_file, "{stale_meta}")?;
|
|
let stale_user_event = json!({
|
|
"timestamp": "2025-01-01T00:00:00Z",
|
|
"type": "event_msg",
|
|
"payload": {
|
|
"type": "user_message",
|
|
"message": "stale history",
|
|
"kind": "plain",
|
|
},
|
|
});
|
|
writeln!(stale_file, "{stale_user_event}")?;
|
|
|
|
let stale_resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
path: Some(stale_path),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let stale_resume_err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_error_message(RequestId::Integer(stale_resume_id)),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
stale_resume_err.error.message.contains("stale path"),
|
|
"unexpected resume error: {}",
|
|
stale_resume_err.error.message
|
|
);
|
|
|
|
primary
|
|
.interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_rejoins_running_paginated_thread_with_initial_page() -> Result<()> {
|
|
let (release_running_turn, running_turn_gate) = oneshot::channel();
|
|
let (server, _response_completions) = start_streaming_sse_server(vec![
|
|
vec![StreamingSseChunk {
|
|
gate: None,
|
|
body: responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_assistant_message("msg-1", "Done"),
|
|
responses::ev_completed("resp-1"),
|
|
]),
|
|
}],
|
|
vec![StreamingSseChunk {
|
|
gate: Some(running_turn_gate),
|
|
body: responses::sse(vec![
|
|
responses::ev_response_created("resp-2"),
|
|
responses::ev_assistant_message("msg-2", "Done"),
|
|
responses::ev_completed("resp-2"),
|
|
]),
|
|
}],
|
|
])
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(server.uri()).write(codex_home.path())?;
|
|
|
|
let mut primary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = primary
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
history_mode: Some(ThreadHistoryMode::Paginated),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.read_response(start_id)).await??;
|
|
|
|
let seed_turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "seed history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let TurnStartResponse { turn: seed_turn } =
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.read_response(seed_turn_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
primary.clear_message_buffer();
|
|
|
|
let running_turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "keep running".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let running_turn_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)),
|
|
)
|
|
.await??;
|
|
let TurnStartResponse { turn: running_turn } =
|
|
to_response::<TurnStartResponse>(running_turn_resp)?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/started"),
|
|
)
|
|
.await??;
|
|
|
|
let resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
model: Some("not-the-running-model".to_string()),
|
|
cwd: Some("/tmp".to_string()),
|
|
initial_turns_page: Some(ThreadResumeInitialTurnsPageParams {
|
|
limit: Some(1),
|
|
sort_direction: Some(SortDirection::Desc),
|
|
items_view: Some(TurnItemsView::NotLoaded),
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread,
|
|
model,
|
|
initial_turns_page,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, primary.read_response(resume_id)).await??;
|
|
assert_eq!(model, "gpt-5.4");
|
|
let initial_turns_page = initial_turns_page.expect("resume should include initial turns page");
|
|
assert_eq!(initial_turns_page.data.len(), 1);
|
|
let resumed_running_turn = initial_turns_page
|
|
.data
|
|
.first()
|
|
.expect("resume page should include the running turn");
|
|
assert_eq!(resumed_running_turn.id, running_turn.id);
|
|
assert_eq!(resumed_running_turn.items_view, TurnItemsView::NotLoaded);
|
|
assert!(resumed_running_turn.items.is_empty());
|
|
assert_eq!(resumed_running_turn.status, TurnStatus::InProgress);
|
|
assert!(initial_turns_page.backwards_cursor.is_some());
|
|
assert!(initial_turns_page.next_cursor.is_some());
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("thread/tokenUsage/updated"),
|
|
)
|
|
.await??;
|
|
|
|
let metadata_resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let metadata_resume: ThreadResumeResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_response(metadata_resume_id),
|
|
)
|
|
.await??;
|
|
assert!(metadata_resume.thread.turns.is_empty());
|
|
assert!(metadata_resume.initial_turns_page.is_none());
|
|
assert!(
|
|
timeout(
|
|
Duration::from_millis(100),
|
|
primary.read_stream_until_notification_message("thread/tokenUsage/updated"),
|
|
)
|
|
.await
|
|
.is_err(),
|
|
"hot paginated resume should wait for a real token usage update"
|
|
);
|
|
|
|
let asc_resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
exclude_turns: true,
|
|
initial_turns_page: Some(ThreadResumeInitialTurnsPageParams {
|
|
limit: Some(1),
|
|
sort_direction: Some(SortDirection::Asc),
|
|
items_view: Some(TurnItemsView::NotLoaded),
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
initial_turns_page, ..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, primary.read_response(asc_resume_id)).await??;
|
|
let initial_turns_page = initial_turns_page.expect("resume should include initial turns page");
|
|
assert_eq!(initial_turns_page.data.len(), 1);
|
|
assert_eq!(initial_turns_page.data[0].id, seed_turn.id);
|
|
// The running-thread resume response is queued onto the thread listener task.
|
|
// If the in-flight turn completes before that queued command runs, the response
|
|
// can legitimately observe the thread as idle.
|
|
match &thread.status {
|
|
ThreadStatus::Active { active_flags } => assert!(active_flags.is_empty()),
|
|
ThreadStatus::Idle => {}
|
|
status => panic!("unexpected thread status after running resume: {status:?}"),
|
|
}
|
|
|
|
release_running_turn
|
|
.send(())
|
|
.expect("release the running model response");
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
server.shutdown().await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> {
|
|
let server = responses::start_mock_server().await;
|
|
let _response_mock = responses::mount_sse_once(
|
|
&server,
|
|
responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_assistant_message("msg-1", "Done"),
|
|
responses::ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut primary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = primary
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.read_response(start_id)).await??;
|
|
|
|
let turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "seed history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed, ..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, primary.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(resumed.id, thread.id);
|
|
assert_eq!(resumed.status, ThreadStatus::Idle);
|
|
assert!(resumed.turns.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_replays_pending_command_execution_request_approval() -> Result<()> {
|
|
// TODO(anp): Remove after shell approval replay can route target-native cwd across host OSes.
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"shell approval replay rejects the Windows cwd on the Linux host"
|
|
);
|
|
|
|
let responses = vec![
|
|
create_final_assistant_message_sse_response("seeded")?,
|
|
create_command_execution_sse_response(
|
|
vec![
|
|
"python3".to_string(),
|
|
"-c".to_string(),
|
|
"print(42)".to_string(),
|
|
],
|
|
/*workdir*/ None,
|
|
Some(5000),
|
|
"call-1",
|
|
)?,
|
|
create_final_assistant_message_sse_response("done")?,
|
|
];
|
|
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut primary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = primary
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.read_response(start_id)).await??;
|
|
|
|
let seed_turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "seed history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
primary.clear_message_buffer();
|
|
|
|
let running_turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "run command".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
approval_policy: Some(AskForApproval::UnlessTrusted),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)),
|
|
)
|
|
.await??;
|
|
|
|
let original_request = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_request_message(),
|
|
)
|
|
.await??;
|
|
let ServerRequest::CommandExecutionRequestApproval { .. } = &original_request else {
|
|
panic!("expected CommandExecutionRequestApproval request, got {original_request:?}");
|
|
};
|
|
|
|
let resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, primary.read_response(resume_id)).await??;
|
|
assert_eq!(resumed_thread.id, thread.id);
|
|
assert!(
|
|
resumed_thread
|
|
.turns
|
|
.iter()
|
|
.any(|turn| matches!(turn.status, TurnStatus::InProgress))
|
|
);
|
|
|
|
let replayed_request = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_request_message(),
|
|
)
|
|
.await??;
|
|
pretty_assertions::assert_eq!(replayed_request, original_request);
|
|
|
|
let ServerRequest::CommandExecutionRequestApproval { request_id, .. } = replayed_request else {
|
|
panic!("expected CommandExecutionRequestApproval request");
|
|
};
|
|
primary
|
|
.send_response(
|
|
request_id,
|
|
serde_json::to_value(CommandExecutionRequestApprovalResponse {
|
|
decision: CommandExecutionApprovalDecision::Accept,
|
|
})?,
|
|
)
|
|
.await?;
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
wait_for_responses_request_count(&server, /*expected_count*/ 3).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_replays_pending_file_change_request_approval() -> Result<()> {
|
|
// TODO(anp): Remove after apply-patch approval fixtures use a target-native workspace.
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"apply-patch approval fixture is only materialized on the host"
|
|
);
|
|
|
|
let tmp = TempDir::new()?;
|
|
let codex_home = tmp.path().join("codex_home");
|
|
std::fs::create_dir(&codex_home)?;
|
|
let workspace = tmp.path().join("workspace");
|
|
std::fs::create_dir(&workspace)?;
|
|
|
|
let patch = r#"*** Begin Patch
|
|
*** Add File: README.md
|
|
+new line
|
|
*** End Patch
|
|
"#;
|
|
let responses = vec![
|
|
create_final_assistant_message_sse_response("seeded")?,
|
|
create_apply_patch_sse_response(patch, "patch-call")?,
|
|
create_final_assistant_message_sse_response("done")?,
|
|
];
|
|
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
|
mock_responses_config(&server.uri())
|
|
.disable_feature(Feature::ShellSnapshot)
|
|
.write(&codex_home)?;
|
|
|
|
let mut primary = TestAppServer::builder()
|
|
.with_codex_home(&codex_home)
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = primary
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
cwd: Some(workspace.to_string_lossy().into_owned()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.read_response(start_id)).await??;
|
|
|
|
let seed_turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "seed history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
cwd: Some(workspace.clone()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
primary.clear_message_buffer();
|
|
|
|
let running_turn_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "apply patch".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
cwd: Some(workspace.clone()),
|
|
approval_policy: Some(AskForApproval::UnlessTrusted),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)),
|
|
)
|
|
.await??;
|
|
|
|
let original_started = timeout(DEFAULT_READ_TIMEOUT, async {
|
|
loop {
|
|
let notification = primary
|
|
.read_stream_until_notification_message("item/started")
|
|
.await?;
|
|
let started: ItemStartedNotification =
|
|
serde_json::from_value(notification.params.clone().expect("item/started params"))?;
|
|
if let ThreadItem::FileChange { .. } = started.item {
|
|
return Ok::<ThreadItem, anyhow::Error>(started.item);
|
|
}
|
|
}
|
|
})
|
|
.await??;
|
|
let expected_readme_path = workspace.join("README.md");
|
|
let expected_file_change = ThreadItem::FileChange {
|
|
id: "patch-call".to_string(),
|
|
changes: vec![codex_app_server_protocol::FileUpdateChange {
|
|
path: expected_readme_path.to_string_lossy().into_owned(),
|
|
kind: PatchChangeKind::Add,
|
|
diff: "new line\n".to_string(),
|
|
}],
|
|
status: PatchApplyStatus::InProgress,
|
|
};
|
|
assert_eq!(original_started, expected_file_change);
|
|
|
|
let original_request = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_request_message(),
|
|
)
|
|
.await??;
|
|
let ServerRequest::FileChangeRequestApproval { .. } = &original_request else {
|
|
panic!("expected FileChangeRequestApproval request, got {original_request:?}");
|
|
};
|
|
|
|
timeout(DEFAULT_READ_TIMEOUT, async {
|
|
loop {
|
|
let notification: ThreadStatusChangedNotification =
|
|
primary.read_notification("thread/status/changed").await?;
|
|
if notification.thread_id == thread.id
|
|
&& matches!(
|
|
notification.status,
|
|
ThreadStatus::Active { active_flags }
|
|
if active_flags.contains(&ThreadActiveFlag::WaitingOnApproval)
|
|
)
|
|
{
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
}
|
|
})
|
|
.await??;
|
|
|
|
let resume_id = primary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, primary.read_response(resume_id)).await??;
|
|
assert_eq!(resumed_thread.id, thread.id);
|
|
assert!(
|
|
resumed_thread
|
|
.turns
|
|
.iter()
|
|
.any(|turn| matches!(turn.status, TurnStatus::InProgress))
|
|
);
|
|
|
|
let replayed_request = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_request_message(),
|
|
)
|
|
.await??;
|
|
assert_eq!(replayed_request, original_request);
|
|
|
|
let ServerRequest::FileChangeRequestApproval { request_id, .. } = replayed_request else {
|
|
panic!("expected FileChangeRequestApproval request");
|
|
};
|
|
primary
|
|
.send_response(
|
|
request_id,
|
|
serde_json::to_value(FileChangeRequestApprovalResponse {
|
|
decision: FileChangeApprovalDecision::Accept,
|
|
})?,
|
|
)
|
|
.await?;
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
wait_for_responses_request_count(&server, /*expected_count*/ 3).await?;
|
|
let status = timeout(DEFAULT_READ_TIMEOUT, primary.shutdown_gracefully()).await??;
|
|
anyhow::ensure!(
|
|
status.success(),
|
|
"app-server exited unsuccessfully: {status}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_with_overrides_preserves_recency_and_checkpoints_model() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let RestartedThreadFixture {
|
|
mut mcp,
|
|
thread_id,
|
|
rollout_file_path,
|
|
recency_at,
|
|
} = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
|
|
assert_eq!(resumed_thread.recency_at, recency_at);
|
|
assert_eq!(resumed_thread.status, ThreadStatus::Idle);
|
|
let expected_thread_id = ThreadId::from_string(&resumed_thread.id)?;
|
|
let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_file_path).await?;
|
|
let persisted_model = items.iter().rev().find_map(|item| match item {
|
|
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event))
|
|
if event.thread_id == Some(expected_thread_id) =>
|
|
{
|
|
Some(event.thread_settings.model.as_str())
|
|
}
|
|
_ => None,
|
|
});
|
|
assert_eq!(persisted_model, Some("mock-model"));
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: resumed_thread.id,
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "Hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?;
|
|
mock_responses_config(&server.uri())
|
|
.with_extra_config(
|
|
r#"[mcp_servers.required_broken]
|
|
command = "codex-definitely-not-a-real-binary"
|
|
required = true"#,
|
|
)
|
|
.write(codex_home.path())?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: rollout.conversation_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("required MCP servers failed to initialize"),
|
|
"unexpected error message: {}",
|
|
err.error.message
|
|
);
|
|
assert!(
|
|
err.error.message.contains("required_broken"),
|
|
"unexpected error message: {}",
|
|
err.error.message
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_surfaces_cloud_config_bundle_load_errors() -> Result<()> {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/wham/config/bundle"))
|
|
.respond_with(
|
|
ResponseTemplate::new(401)
|
|
.insert_header("content-type", "text/html")
|
|
.set_body_string("<html>nope</html>"),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
|
"error": { "code": "refresh_token_invalidated" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
let model_server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let chatgpt_base_url = format!("{}/backend-api", server.uri());
|
|
mock_responses_config(&model_server.uri())
|
|
.with_root_config(&format!(r#"chatgpt_base_url = "{chatgpt_base_url}""#))
|
|
.write(codex_home.path())?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.refresh_token("stale-refresh-token")
|
|
.plan_type("business")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123")
|
|
.account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
let conversation_id = create_fake_rollout_with_text_elements(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Vec::new(),
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let refresh_token_url = format!("{}/oauth/token", server.uri());
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[
|
|
("OPENAI_API_KEY", None),
|
|
(
|
|
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
|
|
Some(refresh_token_url.as_str()),
|
|
),
|
|
])
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert!(
|
|
err.error.message.contains("failed to load configuration"),
|
|
"unexpected error message: {}",
|
|
err.error.message
|
|
);
|
|
assert_eq!(
|
|
err.error.data,
|
|
Some(json!({
|
|
"reason": "cloudConfigBundle",
|
|
"errorCode": "Auth",
|
|
"action": "relogin",
|
|
"statusCode": 401,
|
|
"detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.",
|
|
}))
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_uses_path_over_non_running_thread_id() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let RestartedThreadFixture {
|
|
mut mcp,
|
|
thread_id,
|
|
rollout_file_path,
|
|
..
|
|
} = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?;
|
|
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: ThreadId::new().to_string(),
|
|
path: Some(rollout_file_path),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
|
|
let ThreadResumeResponse {
|
|
thread: resumed, ..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
assert_eq!(resumed.id, thread_id);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_can_load_source_by_external_path() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let external_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
let thread_id = create_fake_rollout(
|
|
external_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"external path history",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let thread_path = rollout_path(external_home.path(), "2025-01-05T12-00-00", &thread_id);
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: "not-a-valid-thread-id".to_string(),
|
|
path: Some(thread_path.clone()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
|
|
let ThreadResumeResponse {
|
|
thread: resumed, ..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
assert_eq!(resumed.id, thread_id);
|
|
let resumed_path = resumed.path.as_ref().expect("resumed thread path");
|
|
assert_eq!(
|
|
normalized_existing_path(resumed_path)?,
|
|
normalized_existing_path(&thread_path)?
|
|
);
|
|
assert_eq!(resumed.preview, "external path history");
|
|
assert_eq!(resumed.status, ThreadStatus::Idle);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_supports_history_and_overrides() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let RestartedThreadFixture {
|
|
mut mcp, thread_id, ..
|
|
} = start_materialized_thread_and_restart(codex_home.path(), "seed history").await?;
|
|
|
|
let history_text = "Hello from history";
|
|
let history = vec![ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: history_text.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}];
|
|
|
|
// Resume with explicit history and override the model.
|
|
let resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id,
|
|
history: Some(history),
|
|
model: Some("mock-model".to_string()),
|
|
model_provider: Some("mock_provider".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed,
|
|
model_provider,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
|
|
assert!(!resumed.id.is_empty());
|
|
assert_eq!(model_provider, "mock_provider");
|
|
assert_eq!(resumed.preview, history_text);
|
|
assert_eq!(resumed.status, ThreadStatus::Idle);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
struct RestartedThreadFixture {
|
|
mcp: TestAppServer,
|
|
thread_id: String,
|
|
rollout_file_path: PathBuf,
|
|
recency_at: Option<i64>,
|
|
}
|
|
|
|
async fn start_materialized_thread_and_restart(
|
|
codex_home: &Path,
|
|
seed_text: &str,
|
|
) -> Result<RestartedThreadFixture> {
|
|
let mut first_mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home)
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = first_mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, first_mcp.read_response(start_id)).await??;
|
|
|
|
let materialize_turn_id = first_mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: seed_text.to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
first_mcp.read_stream_until_response_message(RequestId::Integer(materialize_turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
first_mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let read_id = first_mcp
|
|
.send_thread_read_request(ThreadReadParams {
|
|
thread_id: thread.id.clone(),
|
|
include_turns: false,
|
|
})
|
|
.await?;
|
|
let ThreadReadResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, first_mcp.read_response(read_id)).await??;
|
|
|
|
let thread_id = thread.id;
|
|
let rollout_file_path = thread
|
|
.path
|
|
.ok_or_else(|| anyhow::anyhow!("thread path missing from thread/start response"))?;
|
|
let recency_at = thread.recency_at;
|
|
|
|
drop(first_mcp);
|
|
|
|
let second_mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home)
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
Ok(RestartedThreadFixture {
|
|
mcp: second_mcp,
|
|
thread_id,
|
|
rollout_file_path: rollout_file_path.to_path_buf(),
|
|
recency_at,
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_accepts_personality_override() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = responses::start_mock_server().await;
|
|
let first_body = responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_assistant_message("msg-1", "Done"),
|
|
responses::ev_completed("resp-1"),
|
|
]);
|
|
let second_body = responses::sse(vec![
|
|
responses::ev_response_created("resp-2"),
|
|
responses::ev_assistant_message("msg-2", "Done"),
|
|
responses::ev_completed("resp-2"),
|
|
]);
|
|
let response_mock = responses::mount_sse_sequence(&server, vec![first_body, second_body]).await;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
|
|
let mut primary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let start_id = primary
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.read_response(start_id)).await??;
|
|
|
|
let materialize_id = primary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "seed history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_response_message(RequestId::Integer(materialize_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
timeout(DEFAULT_READ_TIMEOUT, primary.shutdown_gracefully()).await??;
|
|
let mut secondary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let resume_id = secondary
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: thread.id,
|
|
model: Some("gpt-5.4".to_string()),
|
|
personality: Some(Personality::Friendly),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let resume: ThreadResumeResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, secondary.read_response(resume_id)).await??;
|
|
assert_eq!(resume.thread.status, ThreadStatus::Idle);
|
|
|
|
let turn_id = secondary
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: resume.thread.id,
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "Hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
secondary.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
|
)
|
|
.await??;
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
secondary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let requests = response_mock.requests();
|
|
let request = requests
|
|
.last()
|
|
.expect("expected request for resumed thread turn");
|
|
let developer_texts = request.message_input_texts("developer");
|
|
assert!(
|
|
developer_texts
|
|
.iter()
|
|
.any(|text| text.contains("<personality_spec>")),
|
|
"expected a personality update message in developer input, got {developer_texts:?}"
|
|
);
|
|
let instructions_text = request.instructions_text();
|
|
assert!(
|
|
instructions_text.contains(CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT),
|
|
"expected default base instructions from history, got {instructions_text:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn mock_responses_config(server_uri: &str) -> MockResponsesConfig {
|
|
MockResponsesConfig::new(server_uri)
|
|
.with_model("gpt-5.4")
|
|
.enable_feature(Feature::Personality)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> {
|
|
let parsed = chrono::DateTime::parse_from_rfc3339(updated_at_rfc3339)?.with_timezone(&Utc);
|
|
let times = FileTimes::new().set_modified(parsed.into());
|
|
std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(path)?
|
|
.set_times(times)?;
|
|
Ok(())
|
|
}
|
|
|
|
struct RolloutFixture {
|
|
conversation_id: String,
|
|
rollout_file_path: PathBuf,
|
|
}
|
|
|
|
async fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result<RolloutFixture> {
|
|
mock_responses_config(server_uri).write(codex_home)?;
|
|
|
|
let preview = "Saved user message";
|
|
let filename_ts = "2025-01-05T12-00-00";
|
|
let meta_rfc3339 = "2025-01-05T12:00:00Z";
|
|
let expected_updated_at_rfc3339 = "2025-01-07T00:00:00Z";
|
|
let conversation_id = create_fake_rollout_with_text_elements(
|
|
codex_home,
|
|
filename_ts,
|
|
meta_rfc3339,
|
|
preview,
|
|
Vec::new(),
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let rollout_file_path = rollout_path(codex_home, filename_ts, &conversation_id);
|
|
let mut session_meta = read_session_meta_line(&rollout_file_path).await?;
|
|
session_meta.meta.multi_agent_version = Some(MultiAgentVersion::V1);
|
|
append_rollout_item_to_path(&rollout_file_path, &RolloutItem::SessionMeta(session_meta))
|
|
.await?;
|
|
set_rollout_mtime(rollout_file_path.as_path(), expected_updated_at_rfc3339)?;
|
|
Ok(RolloutFixture {
|
|
conversation_id,
|
|
rollout_file_path,
|
|
})
|
|
}
|