mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## Why Collaboration can resume an archived descendant without unarchiving its rollout. Archiving the parent again previously skipped shutdown for that descendant because only rollouts newly marked as archived were prepared. ## What changed Prepare every loaded thread in the spawn subtree for archival, including descendants whose rollouts are already archived, before updating archive state. ## Testing Add an app-server integration test that resumes an archived child through its parent and verifies that archiving the parent unloads the entire subtree. GitOrigin-RevId: aca8c0d6ddb8900aad16becbeb594426cea1e3a6
986 lines
31 KiB
Rust
986 lines
31 KiB
Rust
use anyhow::Result;
|
|
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_mock_responses_server_repeating_assistant;
|
|
use codex_app_server_protocol::ClientInfo;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::JSONRPCError;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ThreadArchiveParams;
|
|
use codex_app_server_protocol::ThreadArchiveResponse;
|
|
use codex_app_server_protocol::ThreadArchivedNotification;
|
|
use codex_app_server_protocol::ThreadHistoryMode;
|
|
use codex_app_server_protocol::ThreadLoadedListParams;
|
|
use codex_app_server_protocol::ThreadLoadedListResponse;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
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::ThreadUnarchiveParams;
|
|
use codex_app_server_protocol::ThreadUnarchiveResponse;
|
|
use codex_app_server_protocol::TurnCompletedNotification;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStartResponse;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
|
|
use codex_core::find_archived_thread_path_by_id_str;
|
|
use codex_core::find_thread_path_by_id_str;
|
|
use codex_features::Feature;
|
|
use codex_protocol::ThreadId;
|
|
use codex_state::DirectionalThreadSpawnEdgeStatus;
|
|
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 std::path::Path;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
|
|
use super::analytics::mount_analytics_capture;
|
|
use super::analytics::wait_for_matching_analytics_event;
|
|
|
|
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
|
|
fn body_contains(request: &wiremock::Request, text: &str) -> bool {
|
|
String::from_utf8(request.body.clone())
|
|
.ok()
|
|
.is_some_and(|body| body.contains(text))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_archive_rejects_owned_unmaterialized_paginated_descendant() -> 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_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-01T00-00-00",
|
|
"2025-01-01T00:00:00Z",
|
|
"parent",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let parent_thread_id = ThreadId::from_string(&parent_id)?;
|
|
let mut owner = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let ThreadStartResponse { thread: child, .. } = owner
|
|
.start_thread(ThreadStartParams {
|
|
history_mode: Some(ThreadHistoryMode::Paginated),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let child_thread_id = ThreadId::from_string(&child.id)?;
|
|
let state_db = StateRuntime::init(
|
|
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
|
|
"mock_provider".into(),
|
|
)
|
|
.await?;
|
|
state_db
|
|
.upsert_thread_spawn_edge(
|
|
parent_thread_id,
|
|
child_thread_id,
|
|
DirectionalThreadSpawnEdgeStatus::Open,
|
|
)
|
|
.await?;
|
|
|
|
let mut other = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let request_id = other
|
|
.send_thread_archive_request(ThreadArchiveParams {
|
|
thread_id: parent_id.clone(),
|
|
})
|
|
.await?;
|
|
let error: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
other.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert_eq!(error.error.code, -32600);
|
|
assert_eq!(
|
|
error.error.message,
|
|
format!("thread {} already has an active writer", child.id)
|
|
);
|
|
timeout(DEFAULT_READ_TIMEOUT, owner.shutdown_gracefully()).await??;
|
|
let _: ThreadArchiveResponse = other
|
|
.request(|request_id| ClientRequest::ThreadArchive {
|
|
request_id,
|
|
params: ThreadArchiveParams {
|
|
thread_id: parent_id,
|
|
},
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_archive_shuts_down_resumed_archived_descendant() -> Result<()> {
|
|
const RESUME_PROMPT: &str = "resume the child";
|
|
const RESUME_CALL_ID: &str = "resume-call-1";
|
|
|
|
let server = responses::start_mock_server().await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri())
|
|
.enable_feature(Feature::Collab)
|
|
.write(codex_home.path())?;
|
|
let parent_id = create_fake_paginated_rollout(
|
|
codex_home.path(),
|
|
"2025-01-01T00-00-00",
|
|
"2025-01-01T00:00:00Z",
|
|
"parent",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let child_id = create_fake_paginated_rollout(
|
|
codex_home.path(),
|
|
"2025-01-01T00-01-00",
|
|
"2025-01-01T00:01:00Z",
|
|
"child",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let parent_thread_id = ThreadId::from_string(&parent_id)?;
|
|
let child_thread_id = ThreadId::from_string(&child_id)?;
|
|
let state_db = StateRuntime::init(
|
|
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
|
|
"mock_provider".into(),
|
|
)
|
|
.await?;
|
|
state_db
|
|
.upsert_thread_spawn_edge(
|
|
parent_thread_id,
|
|
child_thread_id,
|
|
DirectionalThreadSpawnEdgeStatus::Closed,
|
|
)
|
|
.await?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let _: ThreadArchiveResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadArchive {
|
|
request_id,
|
|
params: ThreadArchiveParams {
|
|
thread_id: parent_id.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
for _ in 0..2 {
|
|
let _: ThreadArchivedNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_notification("thread/archived"),
|
|
)
|
|
.await??;
|
|
}
|
|
|
|
let _: ThreadUnarchiveResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadUnarchive {
|
|
request_id,
|
|
params: ThreadUnarchiveParams {
|
|
thread_id: parent_id.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("thread/unarchived"),
|
|
)
|
|
.await??;
|
|
let _: ThreadResumeResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadResume {
|
|
request_id,
|
|
params: ThreadResumeParams {
|
|
thread_id: parent_id.clone(),
|
|
model: Some("gpt-5.4".to_string()),
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
let resume_args = serde_json::to_string(&json!({ "id": child_thread_id }))?;
|
|
let _parent_resume = responses::mount_sse_once_match(
|
|
&server,
|
|
|request: &wiremock::Request| {
|
|
body_contains(request, RESUME_PROMPT) && !body_contains(request, RESUME_CALL_ID)
|
|
},
|
|
responses::sse(vec![
|
|
responses::ev_response_created("resp-parent-resume"),
|
|
responses::ev_function_call_with_namespace(
|
|
RESUME_CALL_ID,
|
|
"multi_agent_v1",
|
|
"resume_agent",
|
|
&resume_args,
|
|
),
|
|
responses::ev_completed("resp-parent-resume"),
|
|
]),
|
|
)
|
|
.await;
|
|
let _parent_resume_follow_up = responses::mount_sse_once_match(
|
|
&server,
|
|
|request: &wiremock::Request| body_contains(request, RESUME_CALL_ID),
|
|
responses::sse(vec![
|
|
responses::ev_response_created("resp-parent-resume-follow-up"),
|
|
responses::ev_assistant_message("msg-parent-resume", "parent done"),
|
|
responses::ev_completed("resp-parent-resume-follow-up"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let _: TurnStartResponse = mcp
|
|
.request(|request_id| ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: parent_id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: RESUME_PROMPT.to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, async {
|
|
loop {
|
|
let completed: TurnCompletedNotification =
|
|
mcp.read_notification("turn/completed").await?;
|
|
if completed.thread_id == parent_id {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
}
|
|
})
|
|
.await??;
|
|
|
|
let ThreadLoadedListResponse { data, .. } = mcp
|
|
.request(|request_id| ClientRequest::ThreadLoadedList {
|
|
request_id,
|
|
params: ThreadLoadedListParams::default(),
|
|
})
|
|
.await?;
|
|
assert!(data.contains(&child_id));
|
|
|
|
let _: ThreadArchiveResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadArchive {
|
|
request_id,
|
|
params: ThreadArchiveParams {
|
|
thread_id: parent_id,
|
|
},
|
|
})
|
|
.await?;
|
|
let ThreadLoadedListResponse { data, .. } = mcp
|
|
.request(|request_id| ClientRequest::ThreadLoadedList {
|
|
request_id,
|
|
params: ThreadLoadedListParams::default(),
|
|
})
|
|
.await?;
|
|
assert_eq!(data, Vec::<String>::new());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_archive_requires_materialized_rollout() -> 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 mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build()
|
|
.await?;
|
|
mcp.initialize_with_client_info(ClientInfo {
|
|
name: "codex_work_desktop".to_string(),
|
|
title: None,
|
|
version: "0.1.0".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
// Start a thread.
|
|
let ThreadStartResponse { thread, .. } = mcp
|
|
.start_thread(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
thread_source: Some(ThreadSource::User),
|
|
service_name: Some("codex_work_desktop".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
assert!(!thread.id.is_empty());
|
|
|
|
let rollout_path = thread.path.clone().expect("thread path");
|
|
assert!(
|
|
!rollout_path.exists(),
|
|
"fresh thread rollout should not exist yet at {}",
|
|
rollout_path.display()
|
|
);
|
|
assert!(
|
|
find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None)
|
|
.await?
|
|
.is_none(),
|
|
"thread id should not be discoverable before rollout materialization"
|
|
);
|
|
|
|
// Archive should fail before the rollout is materialized.
|
|
let archive_id = mcp
|
|
.send_thread_archive_request(ThreadArchiveParams {
|
|
thread_id: thread.id.clone(),
|
|
})
|
|
.await?;
|
|
let archive_err: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(archive_id)),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
archive_err
|
|
.error
|
|
.message
|
|
.contains("no rollout found for thread id"),
|
|
"unexpected archive error: {}",
|
|
archive_err.error.message
|
|
);
|
|
|
|
// Materialize rollout via a real user turn and confirm archive succeeds.
|
|
let _: TurnStartResponse = mcp
|
|
.request(|request_id| ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
assert!(
|
|
rollout_path.exists(),
|
|
"expected rollout path {} to exist after first user message",
|
|
rollout_path.display()
|
|
);
|
|
|
|
let discovered_path =
|
|
find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None)
|
|
.await?
|
|
.expect("expected rollout path for thread id to exist after materialization");
|
|
assert_paths_match_on_disk(&discovered_path, &rollout_path)?;
|
|
|
|
let _: ThreadArchiveResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadArchive {
|
|
request_id,
|
|
params: ThreadArchiveParams {
|
|
thread_id: thread.id.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
let archived_notification: ThreadArchivedNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_notification("thread/archived"),
|
|
)
|
|
.await??;
|
|
assert_eq!(archived_notification.thread_id, thread.id);
|
|
|
|
let event = wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| {
|
|
event["event_type"] == "codex_thread_archive_event"
|
|
&& event["event_params"]["thread_id"] == thread.id
|
|
})
|
|
.await?;
|
|
let occurred_at_ms = event["event_params"]["occurred_at_ms"]
|
|
.as_u64()
|
|
.expect("thread archive analytics must include its producer timestamp");
|
|
assert_eq!(
|
|
event,
|
|
json!({
|
|
"event_type": "codex_thread_archive_event",
|
|
"event_params": {
|
|
"thread_id": thread.id,
|
|
"action": "archived",
|
|
"occurred_at_ms": occurred_at_ms,
|
|
"app_server_client": {
|
|
"product_client_id": "codex_work_desktop",
|
|
"client_name": "codex_work_desktop",
|
|
"client_version": "0.1.0",
|
|
"rpc_transport": "stdio",
|
|
"experimental_api_enabled": true,
|
|
},
|
|
"runtime": {
|
|
"codex_rs_version": env!("CARGO_PKG_VERSION"),
|
|
"runtime_os": std::env::consts::OS,
|
|
"runtime_os_version": event["event_params"]["runtime"]["runtime_os_version"],
|
|
"runtime_arch": std::env::consts::ARCH,
|
|
},
|
|
"thread_source": "user",
|
|
},
|
|
})
|
|
);
|
|
|
|
// Verify file moved.
|
|
let archived_directory = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR);
|
|
// The archived file keeps the original filename (rollout-...-<id>.jsonl).
|
|
let archived_rollout_path =
|
|
archived_directory.join(rollout_path.file_name().expect("rollout file name"));
|
|
assert!(
|
|
!rollout_path.exists(),
|
|
"expected rollout path {} to be moved",
|
|
rollout_path.display()
|
|
);
|
|
assert!(
|
|
archived_rollout_path.exists(),
|
|
"expected archived rollout path {} to exist",
|
|
archived_rollout_path.display()
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_archive_archives_spawned_descendants() -> 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_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-01T00-00-00",
|
|
"2025-01-01T00:00:00Z",
|
|
"parent",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let child_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-01T00-01-00",
|
|
"2025-01-01T00:01:00Z",
|
|
"child",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let grandchild_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-01T00-02-00",
|
|
"2025-01-01T00:02:00Z",
|
|
"grandchild",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
|
|
let parent_thread_id = ThreadId::from_string(&parent_id)?;
|
|
let child_thread_id = ThreadId::from_string(&child_id)?;
|
|
let grandchild_thread_id = ThreadId::from_string(&grandchild_id)?;
|
|
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?;
|
|
state_db
|
|
.upsert_thread_spawn_edge(
|
|
parent_thread_id,
|
|
child_thread_id,
|
|
DirectionalThreadSpawnEdgeStatus::Closed,
|
|
)
|
|
.await?;
|
|
state_db
|
|
.upsert_thread_spawn_edge(
|
|
child_thread_id,
|
|
grandchild_thread_id,
|
|
DirectionalThreadSpawnEdgeStatus::Open,
|
|
)
|
|
.await?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let _: ThreadArchiveResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadArchive {
|
|
request_id,
|
|
params: ThreadArchiveParams {
|
|
thread_id: parent_id.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
let mut archived_ids = Vec::new();
|
|
for _ in 0..3 {
|
|
let archived_notification: ThreadArchivedNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_notification("thread/archived"),
|
|
)
|
|
.await??;
|
|
archived_ids.push(archived_notification.thread_id);
|
|
}
|
|
assert_eq!(archived_ids, vec![parent_id, grandchild_id, child_id]);
|
|
|
|
for thread_id in [parent_thread_id, child_thread_id, grandchild_thread_id] {
|
|
assert!(
|
|
find_thread_path_by_id_str(
|
|
codex_home.path(),
|
|
&thread_id.to_string(),
|
|
/*state_db_ctx*/ None,
|
|
)
|
|
.await?
|
|
.is_none(),
|
|
"expected active rollout for {thread_id} to be archived"
|
|
);
|
|
assert!(
|
|
find_archived_thread_path_by_id_str(
|
|
codex_home.path(),
|
|
&thread_id.to_string(),
|
|
/*state_db_ctx*/ None,
|
|
)
|
|
.await?
|
|
.is_some(),
|
|
"expected archived rollout for {thread_id} to exist"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_archive_succeeds_when_descendant_archive_fails() -> 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 parent_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-01T00-00-00",
|
|
"2025-01-01T00:00:00Z",
|
|
"parent",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let child_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-01T00-01-00",
|
|
"2025-01-01T00:01:00Z",
|
|
"child",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let grandchild_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-01T00-02-00",
|
|
"2025-01-01T00:02:00Z",
|
|
"grandchild",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
|
|
let parent_thread_id = ThreadId::from_string(&parent_id)?;
|
|
let child_thread_id = ThreadId::from_string(&child_id)?;
|
|
let grandchild_thread_id = ThreadId::from_string(&grandchild_id)?;
|
|
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?;
|
|
state_db
|
|
.upsert_thread_spawn_edge(
|
|
parent_thread_id,
|
|
child_thread_id,
|
|
DirectionalThreadSpawnEdgeStatus::Closed,
|
|
)
|
|
.await?;
|
|
state_db
|
|
.upsert_thread_spawn_edge(
|
|
child_thread_id,
|
|
grandchild_thread_id,
|
|
DirectionalThreadSpawnEdgeStatus::Open,
|
|
)
|
|
.await?;
|
|
|
|
let child_rollout_path =
|
|
find_thread_path_by_id_str(codex_home.path(), &child_id, /*state_db_ctx*/ None)
|
|
.await?
|
|
.expect("child rollout path");
|
|
let archived_child_path = codex_home
|
|
.path()
|
|
.join(ARCHIVED_SESSIONS_SUBDIR)
|
|
.join(child_rollout_path.file_name().expect("rollout file name"));
|
|
std::fs::create_dir_all(&archived_child_path)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let _: ThreadArchiveResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadArchive {
|
|
request_id,
|
|
params: ThreadArchiveParams {
|
|
thread_id: parent_id.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
let mut archived_ids = Vec::new();
|
|
for _ in 0..2 {
|
|
let archived_notification: ThreadArchivedNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_notification("thread/archived"),
|
|
)
|
|
.await??;
|
|
archived_ids.push(archived_notification.thread_id);
|
|
}
|
|
assert_eq!(archived_ids, vec![parent_id.clone(), grandchild_id.clone()]);
|
|
|
|
assert!(
|
|
timeout(
|
|
std::time::Duration::from_millis(250),
|
|
mcp.read_stream_until_notification_message("thread/archived"),
|
|
)
|
|
.await
|
|
.is_err()
|
|
);
|
|
|
|
assert!(
|
|
child_rollout_path.exists(),
|
|
"child should stay active after descendant archive failure"
|
|
);
|
|
assert!(
|
|
archived_child_path.is_dir(),
|
|
"test conflict should remain in archived sessions"
|
|
);
|
|
for thread_id in [parent_thread_id, grandchild_thread_id] {
|
|
assert!(
|
|
find_thread_path_by_id_str(
|
|
codex_home.path(),
|
|
&thread_id.to_string(),
|
|
/*state_db_ctx*/ None,
|
|
)
|
|
.await?
|
|
.is_none(),
|
|
"expected active rollout for {thread_id} to be archived"
|
|
);
|
|
assert!(
|
|
find_archived_thread_path_by_id_str(
|
|
codex_home.path(),
|
|
&thread_id.to_string(),
|
|
/*state_db_ctx*/ None,
|
|
)
|
|
.await?
|
|
.is_some(),
|
|
"expected archived rollout for {thread_id} to exist"
|
|
);
|
|
}
|
|
|
|
let repeated_archive_id = mcp
|
|
.send_thread_archive_request(ThreadArchiveParams {
|
|
thread_id: parent_id.clone(),
|
|
})
|
|
.await?;
|
|
let _: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(repeated_archive_id)),
|
|
)
|
|
.await??;
|
|
|
|
let _: ThreadUnarchiveResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadUnarchive {
|
|
request_id,
|
|
params: ThreadUnarchiveParams {
|
|
thread_id: parent_id.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| {
|
|
event["event_type"] == "codex_thread_archive_event"
|
|
&& event["event_params"]["thread_id"] == parent_id
|
|
&& event["event_params"]["action"] == "unarchived"
|
|
})
|
|
.await?;
|
|
|
|
let requests = server
|
|
.received_requests()
|
|
.await
|
|
.ok_or_else(|| anyhow::anyhow!("wiremock did not record requests"))?;
|
|
let mut archive_events = Vec::new();
|
|
for request in requests {
|
|
if request.url.path() != "/codex/analytics-events/events" {
|
|
continue;
|
|
}
|
|
let payload: Value = serde_json::from_slice(&request.body)?;
|
|
let events = payload["events"]
|
|
.as_array()
|
|
.ok_or_else(|| anyhow::anyhow!("analytics payload missing events array"))?;
|
|
for event in events
|
|
.iter()
|
|
.filter(|event| event["event_type"] == "codex_thread_archive_event")
|
|
{
|
|
for (header, expected) in [
|
|
("authorization", "Bearer chatgpt-token"),
|
|
("chatgpt-account-id", "account-123"),
|
|
] {
|
|
assert_eq!(
|
|
request
|
|
.headers
|
|
.get(header)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some(expected)
|
|
);
|
|
}
|
|
archive_events.push(event.clone());
|
|
}
|
|
}
|
|
|
|
let expected = [
|
|
(parent_id.as_str(), "archived"),
|
|
(grandchild_id.as_str(), "archived"),
|
|
(parent_id.as_str(), "unarchived"),
|
|
];
|
|
assert_eq!(archive_events.len(), expected.len());
|
|
for (event, (thread_id, action)) in archive_events.iter().zip(expected) {
|
|
let occurred_at_ms = event["event_params"]["occurred_at_ms"]
|
|
.as_u64()
|
|
.expect("thread archive analytics must include its producer timestamp");
|
|
assert_eq!(
|
|
event,
|
|
&json!({
|
|
"event_type": "codex_thread_archive_event",
|
|
"event_params": {
|
|
"thread_id": thread_id,
|
|
"action": action,
|
|
"occurred_at_ms": occurred_at_ms,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_archive_succeeds_when_spawned_descendant_is_missing() -> 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_id = create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-01T00-00-00",
|
|
"2025-01-01T00:00:00Z",
|
|
"parent",
|
|
Some("mock_provider"),
|
|
/*git_info*/ None,
|
|
)?;
|
|
let parent_thread_id = ThreadId::from_string(&parent_id)?;
|
|
let missing_child_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000901")?;
|
|
|
|
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?;
|
|
state_db
|
|
.upsert_thread_spawn_edge(
|
|
parent_thread_id,
|
|
missing_child_thread_id,
|
|
DirectionalThreadSpawnEdgeStatus::Closed,
|
|
)
|
|
.await?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let _: ThreadArchiveResponse = mcp
|
|
.request(|request_id| ClientRequest::ThreadArchive {
|
|
request_id,
|
|
params: ThreadArchiveParams {
|
|
thread_id: parent_id.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
let archived_notification: ThreadArchivedNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_notification("thread/archived"),
|
|
)
|
|
.await??;
|
|
assert_eq!(archived_notification.thread_id, parent_id);
|
|
|
|
assert!(
|
|
find_thread_path_by_id_str(codex_home.path(), &parent_id, /*state_db_ctx*/ None)
|
|
.await?
|
|
.is_none(),
|
|
"parent should be archived even when a descendant is missing"
|
|
);
|
|
assert!(
|
|
find_archived_thread_path_by_id_str(
|
|
codex_home.path(),
|
|
&parent_id,
|
|
/*state_db_ctx*/ None,
|
|
)
|
|
.await?
|
|
.is_some(),
|
|
"parent should be moved into archived sessions"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_archive_clears_stale_subscriptions_before_resume() -> 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 primary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let ThreadStartResponse { thread, .. } = primary
|
|
.start_thread(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
|
|
let _: TurnStartResponse = primary
|
|
.request(|request_id| ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "materialize".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
primary.clear_message_buffer();
|
|
|
|
let mut secondary = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let _: ThreadArchiveResponse = primary
|
|
.request(|request_id| ClientRequest::ThreadArchive {
|
|
request_id,
|
|
params: ThreadArchiveParams {
|
|
thread_id: thread.id.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("thread/archived"),
|
|
)
|
|
.await??;
|
|
|
|
let _: ThreadUnarchiveResponse = primary
|
|
.request(|request_id| ClientRequest::ThreadUnarchive {
|
|
request_id,
|
|
params: ThreadUnarchiveParams {
|
|
thread_id: thread.id.clone(),
|
|
},
|
|
})
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
primary.read_stream_until_notification_message("thread/unarchived"),
|
|
)
|
|
.await??;
|
|
primary.clear_message_buffer();
|
|
|
|
let resume: ThreadResumeResponse = secondary
|
|
.request(|request_id| ClientRequest::ThreadResume {
|
|
request_id,
|
|
params: ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(resume.thread.status, ThreadStatus::Idle);
|
|
primary.clear_message_buffer();
|
|
secondary.clear_message_buffer();
|
|
|
|
let _: TurnStartResponse = secondary
|
|
.request(|request_id| ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: thread.id,
|
|
client_user_message_id: None,
|
|
input: vec![UserInput::Text {
|
|
text: "secondary turn".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
assert!(
|
|
timeout(
|
|
std::time::Duration::from_millis(250),
|
|
primary.read_stream_until_notification_message("turn/started"),
|
|
)
|
|
.await
|
|
.is_err()
|
|
);
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
secondary.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn assert_paths_match_on_disk(actual: &Path, expected: &Path) -> std::io::Result<()> {
|
|
let actual = actual.canonicalize()?;
|
|
let expected = expected.canonicalize()?;
|
|
assert_eq!(actual, expected);
|
|
Ok(())
|
|
}
|