mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
## Why Cold resumes and forks could lose the thread's active permission profile and fall back to the current configured default. ## What changed - Restore the latest persisted approval policy, approvals reviewer, and active permission-profile ID when resuming or forking a thread. - Re-resolve persisted profile IDs through current configuration and requirements. Missing or invalid profiles fall back to the configured default, while threads without a persisted profile continue to use current configuration. - Keep explicit permission and approval overrides ahead of persisted settings. ## Testing Added coverage for legacy and paginated histories, settings updates, explicit overrides, removed or changed profiles, requirement fallbacks, and forks. GitOrigin-RevId: 2289af2f3206bf131a6ffe0e8447e8d402e76c47
2385 lines
82 KiB
Rust
2385 lines
82 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_fake_paginated_rollout;
|
|
use app_test_support::create_fake_rollout;
|
|
use app_test_support::create_fake_rollout_with_token_usage;
|
|
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::to_response;
|
|
use app_test_support::write_chatgpt_auth;
|
|
use codex_app_server_protocol::ActivePermissionProfile;
|
|
use codex_app_server_protocol::ApprovalsReviewer;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::JSONRPCError;
|
|
use codex_app_server_protocol::JSONRPCMessage;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::SandboxMode;
|
|
use codex_app_server_protocol::SandboxPolicy;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::SessionSource;
|
|
use codex_app_server_protocol::ThreadForkParams;
|
|
use codex_app_server_protocol::ThreadForkResponse;
|
|
use codex_app_server_protocol::ThreadHistoryMode;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_app_server_protocol::ThreadListParams;
|
|
use codex_app_server_protocol::ThreadListResponse;
|
|
use codex_app_server_protocol::ThreadReadParams;
|
|
use codex_app_server_protocol::ThreadReadResponse;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
use codex_app_server_protocol::ThreadSearchOccurrencesParams;
|
|
use codex_app_server_protocol::ThreadSearchOccurrencesResponse;
|
|
use codex_app_server_protocol::ThreadSource;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::ThreadStartedNotification;
|
|
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::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_features::Feature;
|
|
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::items::TurnItem as CoreTurnItem;
|
|
use codex_protocol::items::UserMessageItem;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::ItemCompletedEvent;
|
|
use codex_protocol::protocol::MultiAgentVersion;
|
|
use codex_protocol::protocol::TurnCompleteEvent;
|
|
use codex_protocol::protocol::TurnStartedEvent;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
use codex_rollout::RolloutItem;
|
|
use codex_rollout::RolloutLine;
|
|
use codex_rollout::append_rollout_item_to_path;
|
|
use codex_rollout::append_thread_name;
|
|
use codex_rollout::read_session_meta_line;
|
|
use codex_state::StateRuntime;
|
|
use codex_utils_absolute_path::test_support::PathExt;
|
|
use core_test_support::responses;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
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;
|
|
|
|
#[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);
|
|
|
|
async fn list_threads(mcp: &mut TestAppServer) -> Result<ThreadListResponse> {
|
|
let list_id = mcp
|
|
.send_thread_list_request(ThreadListParams {
|
|
cursor: None,
|
|
limit: Some(50),
|
|
sort_key: None,
|
|
sort_direction: None,
|
|
model_providers: None,
|
|
source_kinds: None,
|
|
archived: None,
|
|
section_id: None,
|
|
project_id: None,
|
|
cwd: None,
|
|
use_state_db_only: false,
|
|
search_term: None,
|
|
parent_thread_id: None,
|
|
ancestor_thread_id: None,
|
|
})
|
|
.await?;
|
|
let list_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
|
|
)
|
|
.await??;
|
|
to_response::<ThreadListResponse>(list_resp)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
let preview = "Saved user message";
|
|
let conversation_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
preview,
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
|
|
let original_path = codex_home
|
|
.path()
|
|
.join("sessions")
|
|
.join("2025")
|
|
.join("01")
|
|
.join("05")
|
|
.join(format!(
|
|
"rollout-2025-01-05T12-00-00-{conversation_id}.jsonl"
|
|
));
|
|
assert!(
|
|
original_path.exists(),
|
|
"expected original rollout to exist at {}",
|
|
original_path.display()
|
|
);
|
|
let mut session_meta = read_session_meta_line(&original_path).await?;
|
|
session_meta.meta.multi_agent_version = Some(MultiAgentVersion::V1);
|
|
append_rollout_item_to_path(&original_path, &RolloutItem::SessionMeta(session_meta)).await?;
|
|
let original_contents = std::fs::read_to_string(&original_path)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id.clone(),
|
|
thread_source: Some(ThreadSource::User),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let fork_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(fork_id)),
|
|
)
|
|
.await??;
|
|
let fork_result = fork_resp.result.clone();
|
|
let ThreadForkResponse { thread, .. } = to_response::<ThreadForkResponse>(fork_resp)?;
|
|
|
|
// Wire contract: thread title field is `name`, serialized as null when unset.
|
|
let thread_json = fork_result
|
|
.get("thread")
|
|
.and_then(Value::as_object)
|
|
.expect("thread/fork result.thread must be an object");
|
|
assert_eq!(
|
|
thread_json.get("sessionId").and_then(Value::as_str),
|
|
Some(thread.session_id.as_str()),
|
|
"forked threads should serialize `sessionId` on the thread object"
|
|
);
|
|
assert_eq!(
|
|
thread_json.get("name"),
|
|
Some(&Value::Null),
|
|
"forked threads do not inherit a name; expected `name: null`"
|
|
);
|
|
assert_eq!(
|
|
fork_result.get("sessionId"),
|
|
None,
|
|
"thread/fork should not serialize a top-level `sessionId`"
|
|
);
|
|
|
|
let after_contents = std::fs::read_to_string(&original_path)?;
|
|
assert_eq!(
|
|
after_contents, original_contents,
|
|
"fork should not mutate the original rollout file"
|
|
);
|
|
|
|
assert_ne!(thread.id, conversation_id);
|
|
assert_eq!(thread.session_id, thread.id);
|
|
assert_eq!(thread.forked_from_id, Some(conversation_id.clone()));
|
|
assert_eq!(thread.preview, preview);
|
|
assert_eq!(thread.model_provider, "mock_provider");
|
|
assert_eq!(thread.status, ThreadStatus::Idle);
|
|
let thread_path = thread.path.clone().expect("thread path");
|
|
assert!(thread_path.as_path().is_absolute());
|
|
assert_ne!(thread_path.as_path(), original_path);
|
|
assert!(thread.cwd.as_path().is_absolute());
|
|
assert_eq!(thread.source, SessionSource::VsCode);
|
|
assert_eq!(thread.thread_source, Some(ThreadSource::User));
|
|
assert_eq!(thread.name, None);
|
|
|
|
assert_eq!(
|
|
thread.turns.len(),
|
|
1,
|
|
"expected forked thread to include one turn"
|
|
);
|
|
let turn = &thread.turns[0];
|
|
assert_eq!(turn.status, TurnStatus::Interrupted);
|
|
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: Vec::new(),
|
|
}]
|
|
);
|
|
}
|
|
other => panic!("expected user message item, got {other:?}"),
|
|
}
|
|
|
|
// A corresponding thread/started notification should arrive.
|
|
let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT;
|
|
let notif = loop {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
let message = timeout(remaining, mcp.read_next_message()).await??;
|
|
let JSONRPCMessage::Notification(notif) = message else {
|
|
continue;
|
|
};
|
|
if notif.method == "thread/status/changed" {
|
|
let status_changed: ThreadStatusChangedNotification =
|
|
serde_json::from_value(notif.params.expect("params must be present"))?;
|
|
if status_changed.thread_id == thread.id {
|
|
anyhow::bail!(
|
|
"thread/fork should introduce the thread without a preceding thread/status/changed"
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if notif.method == "thread/started" {
|
|
break notif;
|
|
}
|
|
};
|
|
let started_params = notif.params.clone().expect("params must be present");
|
|
let started_thread_json = started_params
|
|
.get("thread")
|
|
.and_then(Value::as_object)
|
|
.expect("thread/started params.thread must be an object");
|
|
assert_eq!(
|
|
started_thread_json.get("name"),
|
|
Some(&Value::Null),
|
|
"thread/started must serialize `name: null` when unset"
|
|
);
|
|
assert_eq!(
|
|
started_thread_json.get("turns"),
|
|
Some(&json!([])),
|
|
"thread/started must not emit copied fork turns"
|
|
);
|
|
assert_eq!(
|
|
started_thread_json
|
|
.get("threadSource")
|
|
.and_then(Value::as_str),
|
|
Some("user"),
|
|
"thread/started should preserve the caller-supplied fork origin"
|
|
);
|
|
let started: ThreadStartedNotification =
|
|
serde_json::from_value(notif.params.expect("params must be present"))?;
|
|
let mut expected_started_thread = thread;
|
|
expected_started_thread.turns.clear();
|
|
assert_eq!(started.thread, expected_started_thread);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_preserves_persisted_approvals_reviewer() -> Result<()> {
|
|
assert_thread_fork_preserves_persisted_approvals_reviewer(ThreadHistoryMode::Legacy).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn paginated_thread_fork_preserves_persisted_approvals_reviewer() -> Result<()> {
|
|
assert_thread_fork_preserves_persisted_approvals_reviewer(ThreadHistoryMode::Paginated).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_preserves_persisted_permission_profile_and_honors_overrides() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
|
|
for history_mode in [ThreadHistoryMode::Legacy, ThreadHistoryMode::Paginated] {
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri())
|
|
.with_root_config("default_permissions = \":danger-full-access\"")
|
|
.with_extra_config("[permissions.dev]\nextends = \":read-only\"")
|
|
.write(codex_home.path())?;
|
|
|
|
let source_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 {
|
|
history_mode: Some(history_mode),
|
|
approval_policy: Some(AskForApproval::OnRequest),
|
|
permissions: Some("dev".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).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??;
|
|
thread.id
|
|
};
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: source_thread_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
approval_policy,
|
|
sandbox,
|
|
active_permission_profile,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
assert!(matches!(sandbox, SandboxPolicy::ReadOnly { .. }));
|
|
assert_eq!(approval_policy, AskForApproval::OnRequest);
|
|
assert_eq!(
|
|
active_permission_profile,
|
|
Some(ActivePermissionProfile {
|
|
id: "dev".to_string(),
|
|
extends: Some(":read-only".to_string()),
|
|
})
|
|
);
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: source_thread_id.clone(),
|
|
approval_policy: Some(AskForApproval::Never),
|
|
sandbox: Some(SandboxMode::DangerFullAccess),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
approval_policy,
|
|
sandbox,
|
|
active_permission_profile,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
assert!(matches!(sandbox, SandboxPolicy::DangerFullAccess));
|
|
assert_eq!(approval_policy, AskForApproval::Never);
|
|
assert_eq!(active_permission_profile, None);
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: source_thread_id,
|
|
permissions: Some(":workspace".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
sandbox,
|
|
active_permission_profile,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
assert!(matches!(sandbox, SandboxPolicy::WorkspaceWrite { .. }));
|
|
assert_eq!(
|
|
active_permission_profile,
|
|
Some(ActivePermissionProfile::new(":workspace"))
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_thread_fork_preserves_persisted_approvals_reviewer(
|
|
history_mode: ThreadHistoryMode,
|
|
) -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
let (source_thread_id, source_turn_id) = {
|
|
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 {
|
|
history_mode: Some(history_mode),
|
|
permissions: Some(":workspace".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let start_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response(start_resp)?;
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
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??;
|
|
let TurnStartResponse { turn } = to_response(turn_resp)?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let second_turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "switch to auto-review".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
approval_policy: Some(AskForApproval::OnRequest),
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
permissions: Some(":read-only".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
if matches!(history_mode, ThreadHistoryMode::Paginated) {
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: thread.id.clone(),
|
|
last_turn_id: Some(turn.id.clone()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
active_permission_profile,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
assert_eq!(approval_policy, AskForApproval::OnRequest);
|
|
assert_eq!(approvals_reviewer, ApprovalsReviewer::AutoReview);
|
|
assert_eq!(
|
|
active_permission_profile,
|
|
Some(ActivePermissionProfile::new(":read-only"))
|
|
);
|
|
}
|
|
|
|
(thread.id, turn.id)
|
|
};
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: source_thread_id.clone(),
|
|
last_turn_id: Some(source_turn_id.clone()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let fork_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(fork_id)),
|
|
)
|
|
.await??;
|
|
let ThreadForkResponse {
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
active_permission_profile,
|
|
..
|
|
} = to_response(fork_resp)?;
|
|
|
|
assert_eq!(approval_policy, AskForApproval::OnRequest);
|
|
assert_eq!(approvals_reviewer, ApprovalsReviewer::AutoReview);
|
|
assert_eq!(
|
|
active_permission_profile,
|
|
Some(ActivePermissionProfile::new(":read-only"))
|
|
);
|
|
|
|
if matches!(history_mode, ThreadHistoryMode::Paginated) {
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: source_thread_id,
|
|
last_turn_id: Some(source_turn_id),
|
|
approval_policy: Some(AskForApproval::Never),
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
active_permission_profile,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
assert_eq!(approval_policy, AskForApproval::Never);
|
|
assert_eq!(approvals_reviewer, ApprovalsReviewer::User);
|
|
assert_eq!(
|
|
active_permission_profile,
|
|
Some(ActivePermissionProfile::new(":read-only"))
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_at_last_turn_id_keeps_only_terminal_prefix() -> Result<()> {
|
|
assert_thread_fork_at_named_boundary_keeps_only_terminal_prefix(ThreadHistoryMode::Legacy).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn paginated_thread_fork_at_named_boundaries_keeps_only_terminal_prefix() -> Result<()> {
|
|
assert_thread_fork_at_named_boundary_keeps_only_terminal_prefix(ThreadHistoryMode::Paginated)
|
|
.await
|
|
}
|
|
|
|
async fn assert_thread_fork_at_named_boundary_keeps_only_terminal_prefix(
|
|
history_mode: ThreadHistoryMode,
|
|
) -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&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 {
|
|
history_mode: Some(history_mode),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse {
|
|
thread: source_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
let source_thread_id = source_thread.id.clone();
|
|
let source_path = source_thread.path.expect("source thread path");
|
|
|
|
let mut turn_ids = Vec::new();
|
|
for text in ["first", "second", "third"] {
|
|
let turn_request_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: source_thread_id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: text.to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let TurnStartResponse { turn } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_request_id)).await??;
|
|
turn_ids.push(turn.id);
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
}
|
|
|
|
let original_contents = std::fs::read_to_string(source_path.as_path())?;
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: source_thread_id.clone(),
|
|
last_turn_id: Some(turn_ids[1].clone()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
thread: forked_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
|
|
assert_eq!(
|
|
forked_thread
|
|
.turns
|
|
.iter()
|
|
.map(|turn| turn.id.clone())
|
|
.collect::<Vec<_>>(),
|
|
turn_ids[..2]
|
|
);
|
|
assert!(
|
|
forked_thread
|
|
.turns
|
|
.iter()
|
|
.all(|turn| turn.status == TurnStatus::Completed)
|
|
);
|
|
assert_eq!(forked_thread.forked_from_id, Some(source_thread_id.clone()));
|
|
if history_mode == ThreadHistoryMode::Legacy {
|
|
assert_eq!(forked_thread.preview, "first");
|
|
}
|
|
assert_eq!(
|
|
std::fs::read_to_string(source_path.as_path())?,
|
|
original_contents,
|
|
"forking at a turn must not mutate the source rollout"
|
|
);
|
|
|
|
let forked_path = forked_thread.path.clone().expect("forked thread path");
|
|
let forked_contents = std::fs::read_to_string(forked_path.as_path())?;
|
|
if history_mode == ThreadHistoryMode::Paginated {
|
|
assert!(
|
|
read_session_meta_line(forked_path.as_path())
|
|
.await?
|
|
.meta
|
|
.history_base
|
|
.is_some()
|
|
);
|
|
assert!(!forked_contents.contains(turn_ids[1].as_str()));
|
|
} else {
|
|
assert!(forked_contents.contains(turn_ids[1].as_str()));
|
|
}
|
|
assert!(!forked_contents.contains(turn_ids[2].as_str()));
|
|
|
|
let started = loop {
|
|
let notification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/started"),
|
|
)
|
|
.await??;
|
|
let started: ThreadStartedNotification =
|
|
serde_json::from_value(notification.params.expect("params must be present"))?;
|
|
if started.thread.id == forked_thread.id {
|
|
break started;
|
|
}
|
|
};
|
|
assert!(started.thread.turns.is_empty());
|
|
|
|
if history_mode == ThreadHistoryMode::Paginated {
|
|
let before_fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: source_thread_id,
|
|
before_turn_id: Some(turn_ids[2].clone()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
thread: before_fork,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(before_fork_id)).await??;
|
|
assert_eq!(
|
|
before_fork
|
|
.turns
|
|
.iter()
|
|
.map(|turn| turn.id.clone())
|
|
.collect::<Vec<_>>(),
|
|
turn_ids[..2]
|
|
);
|
|
|
|
let completed = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: forked_thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "private child prompt".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
let ThreadForkResponse {
|
|
thread: ephemeral_fork,
|
|
..
|
|
} = mcp
|
|
.request(|request_id| ClientRequest::ThreadFork {
|
|
request_id,
|
|
params: ThreadForkParams {
|
|
thread_id: forked_thread.id,
|
|
before_turn_id: Some(completed.turn.id),
|
|
ephemeral: true,
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(ephemeral_fork.preview, "first");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_defers_inherited_active_goal_until_next_turn() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(vec![
|
|
responses::sse(vec![
|
|
responses::ev_response_created("first-source-turn"),
|
|
responses::ev_completed("first-source-turn"),
|
|
]),
|
|
responses::sse(vec![
|
|
responses::ev_response_created("second-source-turn"),
|
|
responses::ev_completed("second-source-turn"),
|
|
]),
|
|
responses::sse(vec![
|
|
responses::ev_response_created("explicit-fork-turn"),
|
|
responses::ev_completed_with_tokens("explicit-fork-turn", /*total_tokens*/ 20),
|
|
]),
|
|
responses::sse(vec![
|
|
responses::ev_response_created("goal-continuation"),
|
|
responses::ev_completed_with_tokens("goal-continuation", /*total_tokens*/ 100),
|
|
]),
|
|
])
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&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,
|
|
format!("{config}\n[features]\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::default())
|
|
.await?;
|
|
let ThreadStartResponse {
|
|
thread: source_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
let source_thread_id = ThreadId::from_string(&source_thread.id)?;
|
|
|
|
let mut turn_ids = Vec::new();
|
|
for text in ["first", "second"] {
|
|
let completed = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: source_thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: text.to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
turn_ids.push(completed.turn.id);
|
|
}
|
|
// Stop the source before its active goal exists so a late idle hook cannot continue it.
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??;
|
|
drop(mcp);
|
|
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 source_goal = state_db
|
|
.thread_goals()
|
|
.replace_thread_goal(
|
|
source_thread_id,
|
|
"continue after the retry",
|
|
codex_state::ThreadGoalStatus::Active,
|
|
/*token_budget*/ Some(150),
|
|
)
|
|
.await?;
|
|
state_db
|
|
.thread_goals()
|
|
.account_thread_goal_usage(
|
|
source_thread_id,
|
|
/*time_delta_seconds*/ 11,
|
|
/*token_delta*/ 37,
|
|
codex_state::GoalAccountingMode::ActiveOnly,
|
|
Some(source_goal.goal_id.as_str()),
|
|
)
|
|
.await?;
|
|
let source_goal = state_db
|
|
.thread_goals()
|
|
.get_thread_goal(source_thread_id)
|
|
.await?
|
|
.expect("source goal");
|
|
|
|
let mut forked_threads = Vec::new();
|
|
for (last_turn_id, before_turn_id, expected_turn_count) in [
|
|
(None, None, 2),
|
|
(Some(turn_ids[0].clone()), None, 1),
|
|
(None, Some(turn_ids[0].clone()), 0),
|
|
] {
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: source_thread.id.clone(),
|
|
last_turn_id,
|
|
before_turn_id,
|
|
defer_goal_continuation: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
thread: forked_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
let forked_thread_id = ThreadId::from_string(&forked_thread.id)?;
|
|
assert_eq!(forked_thread.turns.len(), expected_turn_count);
|
|
let mut expected_goal = source_goal.clone();
|
|
expected_goal.thread_id = forked_thread_id;
|
|
assert_eq!(
|
|
state_db
|
|
.thread_goals()
|
|
.get_thread_goal(forked_thread_id)
|
|
.await?,
|
|
Some(expected_goal)
|
|
);
|
|
assert!(
|
|
state_db
|
|
.thread_goals()
|
|
.has_thread_goal_continuation_deferral(forked_thread_id)
|
|
.await?
|
|
);
|
|
forked_threads.push(forked_thread);
|
|
}
|
|
|
|
assert_eq!(
|
|
state_db
|
|
.thread_goals()
|
|
.get_thread_goal(source_thread_id)
|
|
.await?,
|
|
Some(source_goal.clone())
|
|
);
|
|
assert!(
|
|
!mcp.pending_notification_methods()
|
|
.iter()
|
|
.any(|method| method == "turn/started"),
|
|
"deferred goal should not start a turn while forking"
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock requests")
|
|
.iter()
|
|
.filter(|request| request.url.path().ends_with("/responses"))
|
|
.count(),
|
|
2,
|
|
"deferred goal should not issue a model request while forking"
|
|
);
|
|
|
|
let forked_thread = forked_threads.pop().expect("empty-prefix fork");
|
|
let forked_thread_id = ThreadId::from_string(&forked_thread.id)?;
|
|
drop(mcp);
|
|
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: forked_thread.id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
!mcp.pending_notification_methods()
|
|
.iter()
|
|
.any(|method| method == "turn/started"),
|
|
"deferred goal should remain deferred after app-server restart"
|
|
);
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: forked_thread.id,
|
|
input: vec![UserInput::Text {
|
|
text: "retry the interrupted prompt".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
|
|
assert!(
|
|
!state_db
|
|
.thread_goals()
|
|
.has_thread_goal_continuation_deferral(forked_thread_id)
|
|
.await?,
|
|
"first explicit turn should consume the deferred-goal marker"
|
|
);
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/started"),
|
|
)
|
|
.await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let forked_goal = state_db
|
|
.thread_goals()
|
|
.get_thread_goal(forked_thread_id)
|
|
.await?
|
|
.expect("forked goal");
|
|
assert_eq!(forked_goal.goal_id, source_goal.goal_id);
|
|
assert_eq!(forked_goal.objective, source_goal.objective);
|
|
assert_eq!(forked_goal.token_budget, Some(150));
|
|
assert_eq!(forked_goal.tokens_used, 157);
|
|
assert!(forked_goal.time_used_seconds >= source_goal.time_used_seconds);
|
|
assert_eq!(
|
|
forked_goal.status,
|
|
codex_state::ThreadGoalStatus::BudgetLimited
|
|
);
|
|
assert_eq!(
|
|
state_db
|
|
.thread_goals()
|
|
.get_thread_goal(source_thread_id)
|
|
.await?,
|
|
Some(source_goal)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_inherits_explicit_source_name_from_session_index() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
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,
|
|
)?;
|
|
let source_thread_id = ThreadId::from_string(&conversation_id)?;
|
|
let source_name = "Renamed parent thread";
|
|
append_thread_name(codex_home.path(), source_thread_id, source_name).await?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
|
|
let ThreadListResponse { data, .. } = list_threads(&mut mcp).await?;
|
|
let listed = data
|
|
.iter()
|
|
.find(|candidate| candidate.id == thread.id)
|
|
.expect("thread/list should include the forked thread");
|
|
assert_eq!(listed.name.as_deref(), Some(source_name));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_can_load_source_by_path() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
let preview = "Saved user message";
|
|
let conversation_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
preview,
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let original_path = codex_home
|
|
.path()
|
|
.join("sessions")
|
|
.join("2025")
|
|
.join("01")
|
|
.join("05")
|
|
.join(format!(
|
|
"rollout-2025-01-05T12-00-00-{conversation_id}.jsonl"
|
|
));
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: "not-a-valid-thread-id".to_string(),
|
|
path: Some(original_path),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
|
|
assert_ne!(thread.id, conversation_id);
|
|
assert_eq!(thread.forked_from_id, Some(conversation_id));
|
|
assert_eq!(thread.preview, preview);
|
|
assert_eq!(thread.model_provider, "mock_provider");
|
|
assert_eq!(thread.turns.len(), 1, "expected copied fork history");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_can_cut_before_unfinished_stored_turn() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
let filename_ts = "2025-01-05T12-00-00";
|
|
let conversation_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
filename_ts,
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let source_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
|
|
let unfinished_turn_id = "unfinished-turn";
|
|
append_rollout_item_to_path(
|
|
&source_path,
|
|
&RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: unfinished_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(
|
|
&source_path,
|
|
&RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
message: "Unfinished user message".to_string(),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let read_id = mcp
|
|
.send_thread_read_request(ThreadReadParams {
|
|
thread_id: conversation_id.clone(),
|
|
include_turns: true,
|
|
})
|
|
.await?;
|
|
let ThreadReadResponse {
|
|
thread: source_thread,
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
|
|
assert_eq!(source_thread.turns.len(), 2);
|
|
assert_eq!(source_thread.turns[1].id, unfinished_turn_id);
|
|
assert_eq!(source_thread.turns[1].status, TurnStatus::Interrupted);
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id,
|
|
before_turn_id: Some(unfinished_turn_id.to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
thread: forked_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
assert_eq!(forked_thread.turns.len(), 1);
|
|
assert_eq!(forked_thread.preview, "Saved user message");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&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())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id,
|
|
thread_source: Some(ThreadSource::User),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_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 thread_fork_can_exclude_turns_and_skip_restored_token_usage() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&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())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id.clone(),
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
|
|
assert_eq!(thread.forked_from_id, Some(conversation_id));
|
|
assert_eq!(thread.preview, "Saved user message");
|
|
assert!(thread.turns.is_empty());
|
|
|
|
let note = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/tokenUsage/updated"),
|
|
)
|
|
.await;
|
|
assert!(
|
|
note.is_err(),
|
|
"excludeTurns=true should not replay token usage"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&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,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.without_managed_config()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id,
|
|
thread_source: Some(ThreadSource::User),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
|
|
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",
|
|
"mock-model",
|
|
"forked",
|
|
"user",
|
|
);
|
|
assert_eq!(
|
|
event["event_params"]["forked_from_thread_id"],
|
|
thread
|
|
.forked_from_id
|
|
.as_deref()
|
|
.expect("forked thread has a source thread")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&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("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: thread.id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let fork_err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(fork_id)),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
fork_err
|
|
.error
|
|
.message
|
|
.contains("no rollout found for thread id"),
|
|
"unexpected fork error: {}",
|
|
fork_err.error.message
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_creates_reference_backed_paginated_thread() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&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 source_path = rollout_path(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
conversation_id.as_str(),
|
|
);
|
|
for item in [
|
|
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
trace_id: None,
|
|
started_at: Some(10),
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
})),
|
|
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
last_agent_message: None,
|
|
error: None,
|
|
started_at: Some(10),
|
|
completed_at: Some(20),
|
|
duration_ms: Some(10_000),
|
|
time_to_first_token_ms: None,
|
|
})),
|
|
] {
|
|
append_rollout_item_to_path(source_path.as_path(), &item).await?;
|
|
}
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
thread: forked_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
assert_eq!(forked_thread.forked_from_id, Some(conversation_id.clone()));
|
|
assert_eq!(forked_thread.turns.len(), 1);
|
|
let forked_thread_id = forked_thread.id.clone();
|
|
let forked_path = forked_thread.path.expect("forked rollout path");
|
|
assert!(!std::fs::read_to_string(forked_path.as_path())?.contains("Saved user message"));
|
|
let meta = read_session_meta_line(forked_path.as_path()).await?;
|
|
let history_base = meta.meta.history_base.expect("history base");
|
|
assert_eq!(
|
|
history_base.thread_id,
|
|
ThreadId::from_string(conversation_id.as_str())?
|
|
);
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: forked_thread_id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "Continue from the fork".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _: TurnStartResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
let requests = server.received_requests().await.expect("wiremock requests");
|
|
let response_request = requests
|
|
.iter()
|
|
.find(|request| request.url.path().ends_with("/responses"))
|
|
.expect("forked turn response request");
|
|
let request_body = response_request.body_json::<Value>()?;
|
|
let model_input = request_body["input"]
|
|
.as_array()
|
|
.expect("response input array");
|
|
let model_input = serde_json::to_string(model_input)?;
|
|
assert!(model_input.contains("Saved user message"));
|
|
assert!(model_input.contains("Continue from the fork"));
|
|
|
|
// excludeTurns only controls response hydration; it must not change the inherited prefix.
|
|
let exclude_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id,
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse {
|
|
thread: excluded_turns_thread,
|
|
..
|
|
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(exclude_id)).await??;
|
|
assert!(excluded_turns_thread.turns.is_empty());
|
|
let excluded_turns_path = excluded_turns_thread.path.expect("forked rollout path");
|
|
let excluded_turns_meta = read_session_meta_line(excluded_turns_path.as_path()).await?;
|
|
assert_eq!(excluded_turns_meta.meta.history_base, Some(history_base));
|
|
|
|
let ThreadForkResponse {
|
|
thread: nested_thread,
|
|
..
|
|
} = mcp
|
|
.request(|request_id| ClientRequest::ThreadFork {
|
|
request_id,
|
|
params: ThreadForkParams {
|
|
thread_id: forked_thread_id.clone(),
|
|
exclude_turns: true,
|
|
..ThreadForkParams::default()
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(nested_thread.forked_from_id, Some(forked_thread_id.clone()));
|
|
assert_eq!(nested_thread.history_mode, ThreadHistoryMode::Paginated);
|
|
assert!(nested_thread.turns.is_empty());
|
|
let nested_path = nested_thread.path.expect("nested fork rollout path");
|
|
let nested_meta = read_session_meta_line(nested_path.as_path()).await?;
|
|
assert_eq!(
|
|
nested_meta
|
|
.meta
|
|
.history_base
|
|
.expect("nested fork history base")
|
|
.thread_id,
|
|
ThreadId::from_string(forked_thread_id.as_str())?
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_freezes_active_paginated_turn_as_interrupted() -> Result<()> {
|
|
assert_thread_fork_freezes_active_paginated_turn_as_interrupted(MultiAgentVersion::V1).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_persists_developer_interruption_marker_for_multi_agent_v2() -> Result<()> {
|
|
assert_thread_fork_freezes_active_paginated_turn_as_interrupted(MultiAgentVersion::V2).await
|
|
}
|
|
|
|
async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted(
|
|
multi_agent_version: MultiAgentVersion,
|
|
) -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let config = MockResponsesConfig::new(&server.uri());
|
|
let (config, expected_marker_role, thread_source) = match multi_agent_version {
|
|
MultiAgentVersion::V2 => (
|
|
config.enable_feature(Feature::MultiAgentV2),
|
|
"developer",
|
|
Some(ThreadSource::Subagent),
|
|
),
|
|
MultiAgentVersion::V1 => (config, "user", None),
|
|
MultiAgentVersion::Disabled => unreachable!("interruption markers require agent support"),
|
|
};
|
|
config.write(codex_home.path())?;
|
|
let source_thread_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 source_path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &source_thread_id);
|
|
let source_id = ThreadId::from_string(source_thread_id.as_str())?;
|
|
let user_response_item = |id: &str| {
|
|
RolloutItem::ResponseItem(
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: format!("{id} model input"),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}
|
|
.into(),
|
|
)
|
|
};
|
|
let completed_user_item = |id: &str, completed_at_ms| {
|
|
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
thread_id: source_id,
|
|
turn_id: "active-turn".to_string(),
|
|
item: CoreTurnItem::UserMessage(UserMessageItem {
|
|
id: id.to_string(),
|
|
client_id: None,
|
|
content: vec![codex_protocol::user_input::UserInput::Text {
|
|
text: format!("{id} needle"),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
}),
|
|
started_at_ms: Some(0),
|
|
completed_at_ms,
|
|
}))
|
|
};
|
|
append_rollout_item_to_path(
|
|
source_path.as_path(),
|
|
&RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "active-turn".to_string(),
|
|
trace_id: None,
|
|
started_at: Some(10),
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
})),
|
|
)
|
|
.await?;
|
|
append_rollout_item_to_path(source_path.as_path(), &user_response_item("before-fork")).await?;
|
|
append_rollout_item_to_path(
|
|
source_path.as_path(),
|
|
&completed_user_item("before-fork", /*completed_at_ms*/ 1),
|
|
)
|
|
.await?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let ThreadForkResponse {
|
|
thread: ephemeral_fork,
|
|
..
|
|
} = mcp
|
|
.request(|request_id| ClientRequest::ThreadFork {
|
|
request_id,
|
|
params: ThreadForkParams {
|
|
thread_id: source_thread_id.clone(),
|
|
thread_source: thread_source.clone(),
|
|
ephemeral: true,
|
|
exclude_turns: true,
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
let invalid_fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: source_thread_id.clone(),
|
|
last_turn_id: Some("active-turn".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let invalid_fork = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(invalid_fork_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(
|
|
invalid_fork.error.message,
|
|
"lastTurnId 'active-turn' identifies an in-progress turn"
|
|
);
|
|
|
|
let ThreadForkResponse {
|
|
thread: forked_thread,
|
|
..
|
|
} = mcp
|
|
.request(|request_id| ClientRequest::ThreadFork {
|
|
request_id,
|
|
params: ThreadForkParams {
|
|
thread_id: source_thread_id.clone(),
|
|
thread_source,
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
let forked_thread_id = forked_thread.id.clone();
|
|
let forked_path = forked_thread.path.expect("forked rollout path");
|
|
let child_rollout = std::fs::read_to_string(forked_path.as_path())?
|
|
.lines()
|
|
.map(serde_json::from_str::<RolloutLine>)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
assert!(matches!(
|
|
child_rollout.as_slice(),
|
|
[
|
|
RolloutLine { item: RolloutItem::SessionMeta(_), .. },
|
|
RolloutLine {
|
|
item: RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(_)),
|
|
..
|
|
},
|
|
RolloutLine {
|
|
item: RolloutItem::ResponseItem(response_item),
|
|
..
|
|
},
|
|
RolloutLine {
|
|
item: RolloutItem::EventMsg(EventMsg::TurnAborted(aborted)),
|
|
..
|
|
},
|
|
] if matches!(
|
|
&response_item.item,
|
|
codex_protocol::models::ResponseItem::Message { role, .. }
|
|
if role == expected_marker_role
|
|
) && aborted.turn_id.as_deref() == Some("active-turn")
|
|
));
|
|
|
|
append_rollout_item_to_path(source_path.as_path(), &user_response_item("after-fork")).await?;
|
|
append_rollout_item_to_path(
|
|
source_path.as_path(),
|
|
&completed_user_item("after-fork", /*completed_at_ms*/ 2),
|
|
)
|
|
.await?;
|
|
append_rollout_item_to_path(
|
|
source_path.as_path(),
|
|
&RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
|
turn_id: "active-turn".to_string(),
|
|
last_agent_message: None,
|
|
error: None,
|
|
started_at: Some(10),
|
|
completed_at: Some(20),
|
|
duration_ms: Some(10_000),
|
|
time_to_first_token_ms: None,
|
|
})),
|
|
)
|
|
.await?;
|
|
|
|
let _: TurnStartResponse = mcp
|
|
.request(|request_id| ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: ephemeral_fork.id,
|
|
input: vec![UserInput::Text {
|
|
text: "Continue in an ephemeral fork".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
let requests = server.received_requests().await.expect("response requests");
|
|
let input = requests
|
|
.iter()
|
|
.rev()
|
|
.find(|request| request.url.path().ends_with("/responses"))
|
|
.expect("ephemeral fork model request")
|
|
.body_json::<Value>()?["input"]
|
|
.clone();
|
|
let serialized_input = serde_json::to_string(&input)?;
|
|
assert!(serialized_input.contains("before-fork model input"));
|
|
assert!(!serialized_input.contains("after-fork model input"));
|
|
assert!(input.as_array().is_some_and(|items| {
|
|
items.iter().any(|item| {
|
|
item["role"] == expected_marker_role
|
|
&& item["content"].as_array().is_some_and(|content| {
|
|
content.iter().any(|fragment| {
|
|
fragment["text"]
|
|
.as_str()
|
|
.is_some_and(|text| text.contains("<turn_aborted>"))
|
|
})
|
|
})
|
|
})
|
|
}));
|
|
|
|
let ThreadTurnsListResponse { data: turns, .. } = mcp
|
|
.request(|request_id| ClientRequest::ThreadTurnsList {
|
|
request_id,
|
|
params: ThreadTurnsListParams {
|
|
thread_id: forked_thread_id.clone(),
|
|
cursor: None,
|
|
limit: None,
|
|
sort_direction: None,
|
|
items_view: None,
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(turns.len(), 1);
|
|
assert_eq!(turns[0].id, "active-turn");
|
|
assert_eq!(turns[0].status, TurnStatus::Interrupted);
|
|
assert_eq!(turns[0].items.len(), 1);
|
|
assert!(matches!(
|
|
&turns[0].items[0],
|
|
ThreadItem::UserMessage { id, .. } if id == "before-fork"
|
|
));
|
|
|
|
let search: ThreadSearchOccurrencesResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadSearchOccurrences {
|
|
request_id,
|
|
params: ThreadSearchOccurrencesParams {
|
|
thread_id: forked_thread_id.clone(),
|
|
search_term: "needle".to_string(),
|
|
cursor: None,
|
|
limit: Some(1),
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(search.data.len(), 1);
|
|
assert_eq!(search.data[0].item_id, "before-fork");
|
|
assert!(search.next_cursor.is_none());
|
|
let searched_turns: ThreadTurnsListResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadTurnsList {
|
|
request_id,
|
|
params: ThreadTurnsListParams {
|
|
thread_id: forked_thread_id.clone(),
|
|
cursor: Some(search.data[0].turn_cursor.clone()),
|
|
limit: Some(1),
|
|
sort_direction: None,
|
|
items_view: None,
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(searched_turns.data, turns);
|
|
|
|
let ThreadForkResponse {
|
|
thread: nested_fork,
|
|
..
|
|
} = mcp
|
|
.request(|request_id| ClientRequest::ThreadFork {
|
|
request_id,
|
|
params: ThreadForkParams {
|
|
thread_id: forked_thread_id.clone(),
|
|
last_turn_id: Some("active-turn".to_string()),
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
let ThreadTurnsListResponse {
|
|
data: nested_turns, ..
|
|
} = mcp
|
|
.request(|request_id| ClientRequest::ThreadTurnsList {
|
|
request_id,
|
|
params: ThreadTurnsListParams {
|
|
thread_id: nested_fork.id,
|
|
cursor: None,
|
|
limit: None,
|
|
sort_direction: None,
|
|
items_view: None,
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(nested_turns, turns);
|
|
|
|
let ThreadForkResponse {
|
|
thread: nested_before,
|
|
..
|
|
} = mcp
|
|
.request(|request_id| ClientRequest::ThreadFork {
|
|
request_id,
|
|
params: ThreadForkParams {
|
|
thread_id: forked_thread_id.clone(),
|
|
before_turn_id: Some("active-turn".to_string()),
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
assert!(nested_before.turns.is_empty());
|
|
|
|
drop(mcp);
|
|
let mut resumed_app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
let ThreadResumeResponse {
|
|
thread: resumed_thread,
|
|
..
|
|
} = resumed_app_server
|
|
.request(|request_id| ClientRequest::ThreadResume {
|
|
request_id,
|
|
params: ThreadResumeParams {
|
|
thread_id: forked_thread_id,
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
let mut expected_resumed_turns = turns;
|
|
for turn in &mut expected_resumed_turns {
|
|
turn.items_view = TurnItemsView::Full;
|
|
}
|
|
assert_eq!(resumed_thread.turns, expected_resumed_turns);
|
|
|
|
let _: TurnStartResponse = resumed_app_server
|
|
.request(|request_id| ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: resumed_thread.id,
|
|
input: vec![UserInput::Text {
|
|
text: "Continue after cold resume".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
resumed_app_server.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
let requests = server.received_requests().await.expect("response requests");
|
|
let request_body = requests
|
|
.iter()
|
|
.rev()
|
|
.find(|request| request.url.path().ends_with("/responses"))
|
|
.expect("cold-resumed model request")
|
|
.body_json::<Value>()?;
|
|
let model_input = request_body["input"].as_array().expect("model input");
|
|
assert!(model_input.iter().any(|item| {
|
|
item["role"] == expected_marker_role
|
|
&& item["content"].as_array().is_some_and(|content| {
|
|
content.iter().any(|fragment| {
|
|
fragment["text"]
|
|
.as_str()
|
|
.is_some_and(|text| text.to_ascii_lowercase().contains("interrupt"))
|
|
})
|
|
})
|
|
}));
|
|
let serialized_input = serde_json::to_string(model_input)?;
|
|
assert!(serialized_input.contains("Saved user message"));
|
|
assert!(serialized_input.contains("before-fork model input"));
|
|
assert!(!serialized_input.contains("after-fork model input"));
|
|
assert!(serialized_input.contains("Continue after cold resume"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
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,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id.clone(),
|
|
path: Some(std::path::PathBuf::new()),
|
|
thread_source: Some(ThreadSource::User),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
|
|
|
|
assert_eq!(
|
|
thread.forked_from_id.as_deref(),
|
|
Some(conversation_id.as_str())
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_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());
|
|
MockResponsesConfig::new(&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(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
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())
|
|
.without_auto_env()
|
|
.with_env_overrides(&[
|
|
("OPENAI_API_KEY", None),
|
|
(
|
|
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
|
|
Some(refresh_token_url.as_str()),
|
|
),
|
|
])
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let fork_err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(fork_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert!(
|
|
fork_err
|
|
.error
|
|
.message
|
|
.contains("failed to load configuration"),
|
|
"unexpected fork error: {}",
|
|
fork_err.error.message
|
|
);
|
|
assert_eq!(
|
|
fork_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_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()> {
|
|
assert_thread_fork_ephemeral_remains_pathless_and_omits_listing(ThreadHistoryMode::Legacy).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn paginated_thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()> {
|
|
assert_thread_fork_ephemeral_remains_pathless_and_omits_listing(ThreadHistoryMode::Paginated)
|
|
.await
|
|
}
|
|
|
|
async fn assert_thread_fork_ephemeral_remains_pathless_and_omits_listing(
|
|
history_mode: ThreadHistoryMode,
|
|
) -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
let preview = "Saved user message";
|
|
let create_rollout = match history_mode {
|
|
ThreadHistoryMode::Legacy => create_fake_rollout,
|
|
ThreadHistoryMode::Paginated => create_fake_paginated_rollout,
|
|
};
|
|
let conversation_id = create_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
preview,
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
if history_mode == ThreadHistoryMode::Paginated {
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id.clone(),
|
|
ephemeral: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let error = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(fork_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(
|
|
error.error.message,
|
|
"ephemeral paginated thread/fork requires `excludeTurns: true`"
|
|
);
|
|
}
|
|
|
|
let fork_id = mcp
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: conversation_id.clone(),
|
|
ephemeral: true,
|
|
exclude_turns: history_mode == ThreadHistoryMode::Paginated,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let fork_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(fork_id)),
|
|
)
|
|
.await??;
|
|
let fork_result = fork_resp.result.clone();
|
|
let ThreadForkResponse { thread, .. } = to_response::<ThreadForkResponse>(fork_resp)?;
|
|
let fork_thread_id = thread.id.clone();
|
|
|
|
assert!(
|
|
thread.ephemeral,
|
|
"ephemeral forks should be marked explicitly"
|
|
);
|
|
assert_eq!(
|
|
thread.path, None,
|
|
"ephemeral forks should not expose a path"
|
|
);
|
|
assert_eq!(thread.preview, preview);
|
|
assert_eq!(thread.status, ThreadStatus::Idle);
|
|
assert_eq!(thread.name, None);
|
|
if history_mode == ThreadHistoryMode::Paginated {
|
|
assert!(thread.turns.is_empty());
|
|
} else {
|
|
assert_eq!(thread.turns.len(), 1, "expected copied fork history");
|
|
|
|
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: Vec::new(),
|
|
}]
|
|
);
|
|
}
|
|
other => panic!("expected user message item, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
let thread_json = fork_result
|
|
.get("thread")
|
|
.and_then(Value::as_object)
|
|
.expect("thread/fork result.thread must be an object");
|
|
assert_eq!(
|
|
thread_json.get("ephemeral").and_then(Value::as_bool),
|
|
Some(true),
|
|
"ephemeral forks should serialize `ephemeral: true`"
|
|
);
|
|
|
|
let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT;
|
|
let notif = loop {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
let message = timeout(remaining, mcp.read_next_message()).await??;
|
|
let JSONRPCMessage::Notification(notif) = message else {
|
|
continue;
|
|
};
|
|
if notif.method == "thread/status/changed" {
|
|
let status_changed: ThreadStatusChangedNotification =
|
|
serde_json::from_value(notif.params.expect("params must be present"))?;
|
|
if status_changed.thread_id == fork_thread_id {
|
|
anyhow::bail!(
|
|
"thread/fork should introduce the thread without a preceding thread/status/changed"
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if notif.method == "thread/started" {
|
|
break notif;
|
|
}
|
|
};
|
|
let started_params = notif.params.clone().expect("params must be present");
|
|
let started_thread_json = started_params
|
|
.get("thread")
|
|
.and_then(Value::as_object)
|
|
.expect("thread/started params.thread must be an object");
|
|
assert_eq!(
|
|
started_thread_json
|
|
.get("ephemeral")
|
|
.and_then(Value::as_bool),
|
|
Some(true),
|
|
"thread/started should serialize `ephemeral: true` for ephemeral forks"
|
|
);
|
|
assert_eq!(
|
|
started_thread_json.get("turns"),
|
|
Some(&json!([])),
|
|
"thread/started must not emit copied ephemeral fork turns"
|
|
);
|
|
let started: ThreadStartedNotification =
|
|
serde_json::from_value(notif.params.expect("params must be present"))?;
|
|
let mut expected_started_thread = thread;
|
|
expected_started_thread.turns.clear();
|
|
assert_eq!(started.thread, expected_started_thread);
|
|
|
|
let ThreadListResponse { data, .. } = list_threads(&mut mcp).await?;
|
|
assert!(
|
|
data.iter().all(|candidate| candidate.id != fork_thread_id),
|
|
"ephemeral forks should not appear in thread/list"
|
|
);
|
|
assert!(
|
|
data.iter().any(|candidate| candidate.id == conversation_id),
|
|
"persistent source thread should remain listed"
|
|
);
|
|
|
|
let turn_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: fork_thread_id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "continue".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _: TurnStartResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let requests = server.received_requests().await.expect("response requests");
|
|
let model_input = requests
|
|
.iter()
|
|
.find(|request| request.url.path().ends_with("/responses"))
|
|
.expect("ephemeral fork model request")
|
|
.body_json::<Value>()?["input"]
|
|
.to_string();
|
|
assert!(model_input.contains(preview));
|
|
assert!(model_input.contains("continue"));
|
|
|
|
let ThreadListResponse { data, .. } = list_threads(&mut mcp).await?;
|
|
assert!(data.iter().all(|thread| thread.id != fork_thread_id));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_rejects_incompatible_boundaries_and_ephemeral_goal_deferral() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
let thread_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
for (params, expected_message) in [
|
|
(
|
|
ThreadForkParams {
|
|
thread_id: thread_id.clone(),
|
|
last_turn_id: Some("turn-1".to_string()),
|
|
before_turn_id: Some("turn-2".to_string()),
|
|
..Default::default()
|
|
},
|
|
"`beforeTurnId` cannot be combined with `lastTurnId`",
|
|
),
|
|
(
|
|
ThreadForkParams {
|
|
thread_id: thread_id.clone(),
|
|
ephemeral: true,
|
|
defer_goal_continuation: true,
|
|
..Default::default()
|
|
},
|
|
"`deferGoalContinuation` cannot be combined with `ephemeral`",
|
|
),
|
|
] {
|
|
let fork_id = mcp.send_thread_fork_request(params).await?;
|
|
let error = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(fork_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(error.error.message, expected_message);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pathless_ephemeral_thread_rejects_codex_home_path_after_reload() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
|
|
|
let parent_thread_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Parent message",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
|
|
let side_thread_id = {
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let fork_id = app_server
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: parent_thread_id,
|
|
ephemeral: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let ThreadForkResponse { thread, .. } =
|
|
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(fork_id)).await??;
|
|
assert!(thread.ephemeral);
|
|
assert_eq!(thread.path, None);
|
|
|
|
let turn_id = app_server
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "continue".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let _: TurnStartResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(turn_id)).await??;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
app_server.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
thread.id
|
|
};
|
|
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
let codex_home_path = codex_home.path().to_path_buf();
|
|
|
|
let resume_id = app_server
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: side_thread_id.clone(),
|
|
path: Some(codex_home_path.clone()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let resume_err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
app_server.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
resume_err.error.message.contains("path is a directory"),
|
|
"unexpected resume error: {}",
|
|
resume_err.error.message
|
|
);
|
|
assert!(
|
|
!resume_err.error.message.contains("Is a directory"),
|
|
"resume should reject the directory before rollout reading: {}",
|
|
resume_err.error.message
|
|
);
|
|
|
|
let fork_id = app_server
|
|
.send_thread_fork_request(ThreadForkParams {
|
|
thread_id: side_thread_id,
|
|
path: Some(codex_home_path),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let fork_err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
app_server.read_stream_until_error_message(RequestId::Integer(fork_id)),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
fork_err.error.message.contains("path is a directory"),
|
|
"unexpected fork error: {}",
|
|
fork_err.error.message
|
|
);
|
|
assert!(
|
|
!fork_err.error.message.contains("Is a directory"),
|
|
"fork should reject the directory before rollout reading: {}",
|
|
fork_err.error.message
|
|
);
|
|
|
|
Ok(())
|
|
}
|