diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 79241e9314..3d990a9b7f 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server/src/external_agent_migration/session_importer.rs b/codex-rs/app-server/src/external_agent_migration/session_importer.rs index 956271528e..85592dc7fb 100644 --- a/codex-rs/app-server/src/external_agent_migration/session_importer.rs +++ b/codex-rs/app-server/src/external_agent_migration/session_importer.rs @@ -456,6 +456,8 @@ impl ExternalAgentSessionImporter { }; let now = Utc::now(); let create_params = CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index d55fd86c13..75a48b5987 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -253,6 +253,8 @@ fn create_fake_rollout_with_source_and_parent_thread_id( // Build JSONL lines let meta = SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id, id: conversation_id, forked_from_id: None, @@ -347,6 +349,8 @@ pub fn create_fake_rollout_with_text_elements( // Build JSONL lines let meta = SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 02e65ab155..fbbd40651a 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -122,6 +122,8 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> let thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000125")?; store .create_thread(CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index abf69a853a..523857eed4 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -374,6 +374,8 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist let unloaded_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; thread_store .create_thread(StoreCreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: unloaded_thread_id.into(), thread_id: unloaded_thread_id, extra_config: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 860f150869..6897588e1c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -588,6 +588,8 @@ async fn thread_search_occurrences_reads_paginated_projection() -> Result<()> { ); store .create_thread(CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, @@ -1648,6 +1650,8 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( ); store .create_thread(CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, @@ -2376,6 +2380,8 @@ async fn seed_pathless_store_thread( ) -> Result<()> { store .create_thread(CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 1904431cc7..b54db35dbc 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -4433,6 +4433,8 @@ async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Res let rollout_dir = rollout_path.parent().expect("rollout parent directory"); std::fs::create_dir_all(rollout_dir)?; let session_meta = SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_timeline.rs b/codex-rs/app-server/tests/suite/v2/thread_timeline.rs index 43aea1dd62..c0babef2bc 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_timeline.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_timeline.rs @@ -59,6 +59,8 @@ async fn timeline_pages_mix_items_and_resolve_the_opening_realtime_session() -> ); store .create_thread(CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 3af590c312..3ca6046351 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -246,6 +246,8 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { let parent_thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000127")?; store .create_thread(CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 0f729c2e21..4be0cb1218 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -983,6 +983,8 @@ impl Session { // - initialize thread persistence with new or resumed session info // - perform default shell discovery // - load history metadata (skipped for subagents) + let persistence_auth = futures::FutureExt::shared(auth_manager.auth()); + let mcp_auth = persistence_auth.clone(); let thread_persistence_fut = async { if config.ephemeral { Ok::<_, anyhow::Error>((None, LiveThreadInitGuard::new(/*live_thread*/ None))) @@ -995,7 +997,10 @@ impl Session { let guard = managed_guard.as_deref_mut().unwrap_or(&mut local_guard); let live_thread = match &initial_history { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { + let auth = persistence_auth.await; let params = CreateThreadParams { + creator_user_id: auth.as_ref().and_then(CodexAuth::get_chatgpt_user_id), + creator_account_id: auth.as_ref().and_then(CodexAuth::get_account_id), session_id, thread_id, extra_config: config.extra_config.clone(), @@ -1099,7 +1104,6 @@ impl Session { )); let mut mcp_auth_changes = auth_manager.auth_change_receiver(); - let auth_manager_clone = Arc::clone(&auth_manager); let plugins_manager_for_prewarm = Arc::clone(&plugins_manager); let config_for_mcp = Arc::clone(&config); let mcp_manager_for_mcp = Arc::clone(&mcp_manager); @@ -1114,7 +1118,7 @@ impl Session { .map(|cwd| cwd.to_path_buf()) .unwrap_or_else(|| session_configuration.cwd().to_path_buf()); let auth_and_mcp_fut = async move { - let auth = auth_manager_clone.auth().await; + let auth = mcp_auth.await; if config_for_mcp.features.plugin_recommendations_enabled() { let plugins_config = config_for_mcp.plugins_config_input(); let auth_for_prewarm = auth.clone(); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a4b3219867..bfa037d6d8 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4518,6 +4518,8 @@ async fn open_thread_persistence(session: &mut Session) -> PathBuf { let live_thread = LiveThread::create( Arc::clone(&session.services.thread_store), CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: session.session_id(), thread_id: session.thread_id, extra_config: None, @@ -8085,6 +8087,8 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { let live_thread = LiveThread::create( Arc::clone(&thread_store), CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: session.session_id(), thread_id: session.thread_id, extra_config: None, @@ -8197,6 +8201,8 @@ async fn submission_loop_channel_close_runs_full_thread_teardown() { let live_thread = LiveThread::create( Arc::clone(&thread_store), CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: session.session_id(), thread_id: session.thread_id, extra_config: None, @@ -10859,6 +10865,8 @@ async fn attach_in_memory_thread_store( let live_thread = LiveThread::create( Arc::clone(&thread_store), CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: session.session_id(), thread_id: session.thread_id, extra_config: None, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index e86c6b73dc..54432b9ca3 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -59,6 +59,145 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn thread_creator_survives_resume_and_forks_use_current_auth() -> Result<()> { + use base64::Engine; + + let server = start_mock_server().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw( + responses::sse(vec![ev_response_created("resp"), ev_completed("resp")]), + "text/event-stream", + ), + ) + .mount(&server) + .await; + let chatgpt_auth = |user_id: &str, account_id: &str| { + let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + json!({"https://api.openai.com/auth": {"chatgpt_user_id": user_id}}).to_string(), + ); + CodexAuth::from_external_chatgpt_tokens( + &format!("e30.{claims}.sig"), + account_id, + /*chatgpt_plan_type*/ None, + ) + }; + for auth in [ + chatgpt_auth("creator-user", "creator-account")?, + CodexAuth::from_api_key("test"), + ] { + let expected_creator = (auth.get_chatgpt_user_id(), auth.get_account_id()); + let mut builder = test_codex().with_auth(auth).with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("enable SQLite"); + }); + let test = builder.build_with_auto_env(&server).await?; + test.submit_turn("persist creator").await?; + let thread_id = test.session_configured.thread_id; + let rollout_path = test.codex.rollout_path().expect("rollout path"); + test.codex.shutdown_and_wait().await?; + let meta = codex_rollout::read_session_meta_line(&rollout_path) + .await? + .meta; + let stored = test + .codex + .state_db() + .expect("SQLite") + .get_thread(thread_id) + .await? + .expect("thread"); + assert_eq!( + (meta.creator_user_id, meta.creator_account_id), + expected_creator + ); + assert_eq!( + (stored.creator_user_id, stored.creator_account_id), + expected_creator + ); + + let mut resume_builder = test_codex() + .with_auth(chatgpt_auth("resuming-user", "resuming-account")?) + .with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("enable SQLite"); + }); + let resumed = resume_builder + .resume(&server, test.home.clone(), rollout_path.clone()) + .await?; + resumed.submit_turn("resume under another account").await?; + resumed.codex.shutdown_and_wait().await?; + let meta = codex_rollout::read_session_meta_line(&rollout_path) + .await? + .meta; + let stored = resumed + .codex + .state_db() + .expect("SQLite") + .get_thread(thread_id) + .await? + .expect("thread"); + assert_eq!( + (meta.creator_user_id, meta.creator_account_id), + expected_creator + ); + assert_eq!( + (stored.creator_user_id, stored.creator_account_id), + expected_creator + ); + + let fork = resumed + .thread_manager + .fork_thread( + codex_core::ForkSnapshot::Interrupted, + StartThreadOptions::new(resumed.config.clone()), + rollout_path, + ) + .await?; + fork.thread + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "persist fork".to_string(), + text_elements: Vec::new(), + }])) + .await?; + wait_for_event(&fork.thread, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let fork_path = fork.thread.rollout_path().expect("fork rollout"); + fork.thread.shutdown_and_wait().await?; + let meta = codex_rollout::read_session_meta_line(&fork_path) + .await? + .meta; + let stored = fork + .thread + .state_db() + .expect("SQLite") + .get_thread(fork.thread_id) + .await? + .expect("fork"); + let expected_fork = ( + Some("resuming-user".to_string()), + Some("resuming-account".to_string()), + ); + assert_eq!( + (meta.creator_user_id, meta.creator_account_id), + expected_fork + ); + assert_eq!( + (stored.creator_user_id, stored.creator_account_id), + expected_fork + ); + } + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn new_thread_is_recorded_in_state_db() -> Result<()> { let server = start_mock_server().await; @@ -414,6 +553,8 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { fs::create_dir_all(parent).expect("should create rollout directory"); let session_meta_line = SessionMetaLine { meta: SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), id: thread_id, forked_from_id: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 787d3e48a8..ad793df081 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3115,6 +3115,12 @@ pub struct HistoryPosition { /// and should be used when there is no config override. #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { + /// ChatGPT user that created this thread; absent when unavailable or for older threads. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creator_user_id: Option, + /// ChatGPT account selected when this thread was created. Never updated on resume. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creator_account_id: Option, /// session_id is equal to the root thread's ID. pub session_id: SessionId, pub id: ThreadId, @@ -3189,6 +3195,8 @@ impl Default for SessionMeta { fn default() -> Self { let id = ThreadId::default(); SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: id.into(), id, forked_from_id: None, diff --git a/codex-rs/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index e77dbee344..71843ce373 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -675,6 +675,8 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) -> fs::create_dir_all(parent)?; let session_meta_line = SessionMetaLine { meta: SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), id: thread_id, forked_from_id: None, diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 33cf377b8a..c2469d0d70 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -47,6 +47,8 @@ pub(crate) fn builder_from_session_meta( created_at, session_meta.meta.source.clone(), ); + builder.creator_user_id = session_meta.meta.creator_user_id.clone(); + builder.creator_account_id = session_meta.meta.creator_account_id.clone(); builder.history_mode = session_meta.meta.history_mode; builder.originator = (!session_meta.meta.originator.is_empty()).then(|| session_meta.meta.originator.clone()); diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 3e0ec6d494..8654dd26ef 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -117,6 +117,8 @@ async fn extract_metadata_from_rollout_uses_session_meta() { .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); let session_meta = SessionMeta { + creator_user_id: Some("creator-user".to_string()), + creator_account_id: Some("creator-account".to_string()), session_id: id.into(), id, forked_from_id: None, @@ -155,6 +157,21 @@ async fn extract_metadata_from_rollout_uses_session_meta() { let json = serde_json::to_string(&rollout_line).expect("rollout json"); let mut file = File::create(&path).expect("create rollout"); writeln!(file, "{json}").expect("write rollout"); + let mut parent = session_meta_line.clone(); + parent.meta.id = ThreadId::new(); + parent.meta.creator_user_id = Some("parent-user".to_string()); + parent.meta.creator_account_id = Some("parent-account".to_string()); + let parent_line = RolloutLine { + timestamp: rollout_line.timestamp.clone(), + ordinal: Some(1), + item: RolloutItem::SessionMeta(parent), + }; + writeln!( + file, + "{}", + serde_json::to_string(&parent_line).expect("parent json") + ) + .expect("write inherited metadata"); let outcome = extract_metadata_from_rollout(&path, "openai") .await @@ -167,6 +184,13 @@ async fn extract_metadata_from_rollout_uses_session_meta() { expected.recency_at = expected.updated_at; assert_eq!(outcome.metadata, expected); + assert_eq!( + ( + outcome.metadata.creator_user_id.as_deref(), + outcome.metadata.creator_account_id.as_deref() + ), + (Some("creator-user"), Some("creator-account")), + ); assert_eq!(outcome.memory_mode, None); assert_eq!(outcome.parse_errors, 0); } @@ -217,6 +241,8 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); let session_meta = SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: id.into(), id, forked_from_id: None, @@ -563,6 +589,8 @@ fn write_rollout_in_sessions_with_cwd( std::fs::create_dir_all(sessions_dir.as_path()).expect("create sessions dir"); let path = sessions_dir.join(format!("rollout-{filename_ts}-{thread_uuid}.jsonl")); let session_meta = SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: id.into(), id, forked_from_id: None, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 3da60cbd4d..cd05f6149b 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -108,6 +108,8 @@ pub enum RolloutRecorderParams { source: Box, thread_source: Option, originator: String, + creator_user_id: Option, + creator_account_id: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, selected_capability_roots: Vec, @@ -212,6 +214,8 @@ impl RolloutRecorderParams { source: Box::new(source), thread_source, originator, + creator_user_id: None, + creator_account_id: None, base_instructions, dynamic_tools, selected_capability_roots: Vec::new(), @@ -224,6 +228,20 @@ impl RolloutRecorderParams { } } + /// Record the authenticated identity at thread creation, or preserve it on revert. + pub fn with_creator(mut self, user_id: Option, account_id: Option) -> Self { + if let Self::Create { + creator_user_id, + creator_account_id, + .. + } = &mut self + { + *creator_user_id = user_id; + *creator_account_id = account_id; + } + self + } + pub fn with_session_id(mut self, session_id: SessionId) -> Self { if let Self::Create { session_id: id, .. } = &mut self { *id = session_id; @@ -889,6 +907,8 @@ impl RolloutRecorder { source, thread_source, originator, + creator_user_id, + creator_account_id, base_instructions, dynamic_tools, selected_capability_roots, @@ -923,6 +943,8 @@ impl RolloutRecorder { cwd: cwd.clone(), runtime_workspace_roots, originator, + creator_user_id, + creator_account_id, cli_version: env!("CARGO_PKG_VERSION").to_string(), agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index d8d4c897c5..c36321a988 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -189,6 +189,8 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { let session_meta_line = SessionMetaLine { meta: SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), id: thread_id, forked_from_id: None, diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 5280d7a013..4d5d2fd7ed 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -35,6 +35,8 @@ fn write_rollout_with_source_and_provider( ordinal: None, item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), id: thread_id, forked_from_id: None, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index 41e1c48a44..22de3ccdb4 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -325,6 +325,8 @@ fn write_rollout_with_user_message( ordinal: None, item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), id: thread_id, forked_from_id: None, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index b955a9668e..dc25b4e856 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1522,6 +1522,8 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { ordinal: None, item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, diff --git a/codex-rs/state/migrations/0056_threads_creator_identity.sql b/codex-rs/state/migrations/0056_threads_creator_identity.sql new file mode 100644 index 0000000000..2cda174974 --- /dev/null +++ b/codex-rs/state/migrations/0056_threads_creator_identity.sql @@ -0,0 +1,2 @@ +ALTER TABLE threads ADD COLUMN creator_user_id TEXT; +ALTER TABLE threads ADD COLUMN creator_account_id TEXT; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 308e892789..2bf5470d7f 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -69,6 +69,14 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi // e.g., forked rollouts that embed the source session metadata. return; } + metadata.creator_user_id = metadata + .creator_user_id + .take() + .or_else(|| meta_line.meta.creator_user_id.clone()); + metadata.creator_account_id = metadata + .creator_account_id + .take() + .or_else(|| meta_line.meta.creator_account_id.clone()); metadata.id = meta_line.meta.id; metadata.source = enum_to_string(&meta_line.meta.source); if metadata.originator.is_none() && !meta_line.meta.originator.is_empty() { @@ -395,6 +403,8 @@ mod tests { &mut metadata, &RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), id: thread_id, forked_from_id: Some( @@ -667,6 +677,8 @@ mod tests { &mut metadata, &RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), id: thread_id, forked_from_id: None, @@ -707,6 +719,8 @@ mod tests { let id = ThreadId::from_string(&Uuid::from_u128(42).to_string()).expect("thread id"); let created_at = DateTime::::from_timestamp(1_735_689_600, 0).expect("timestamp"); ThreadMetadata { + creator_user_id: None, + creator_account_id: None, originator: None, id, rollout_path: PathBuf::from("/tmp/a.jsonl"), diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index 418edb74a8..2242c32625 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -125,6 +125,10 @@ pub struct ExtractionOutcome { pub struct ThreadMetadata { /// Originator recorded at creation, if available. pub originator: Option, + /// ChatGPT user that created the thread, if known. + pub creator_user_id: Option, + /// ChatGPT account at creation, if known. + pub creator_account_id: Option, /// The thread identifier. pub id: ThreadId, /// The absolute rollout path on disk. @@ -196,6 +200,10 @@ pub struct ThreadMetadata { pub struct ThreadMetadataBuilder { /// Originator recorded at creation, if available. pub originator: Option, + /// ChatGPT user that created the thread, if known. + pub creator_user_id: Option, + /// ChatGPT account at creation, if known. + pub creator_account_id: Option, /// The thread identifier. pub id: ThreadId, /// The absolute rollout path on disk. @@ -253,6 +261,8 @@ impl ThreadMetadataBuilder { updated_at: None, recency_at: None, originator: None, + creator_user_id: None, + creator_account_id: None, source, history_mode: ThreadHistoryMode::Legacy, thread_source: None, @@ -287,6 +297,8 @@ impl ThreadMetadataBuilder { .unwrap_or(updated_at); ThreadMetadata { originator: self.originator.clone(), + creator_user_id: self.creator_user_id.clone(), + creator_account_id: self.creator_account_id.clone(), id: self.id, rollout_path: self.rollout_path.clone(), created_at, @@ -388,6 +400,12 @@ impl ThreadMetadata { if self.source != other.source { diffs.push("source"); } + if self.creator_user_id != other.creator_user_id { + diffs.push("creator_user_id"); + } + if self.creator_account_id != other.creator_account_id { + diffs.push("creator_account_id"); + } if self.originator != other.originator { diffs.push("originator"); } @@ -474,6 +492,8 @@ fn canonicalize_datetime(dt: DateTime) -> DateTime { #[derive(Debug)] pub(crate) struct ThreadRow { originator: Option, + creator_user_id: Option, + creator_account_id: Option, id: String, rollout_path: String, created_at: i64, @@ -514,6 +534,8 @@ impl ThreadRow { pub(crate) fn try_from_row(row: &SqliteRow) -> Result { Ok(Self { originator: row.try_get("originator")?, + creator_user_id: row.try_get("creator_user_id")?, + creator_account_id: row.try_get("creator_account_id")?, id: row.try_get("id")?, rollout_path: row.try_get("rollout_path")?, created_at: row.try_get("created_at")?, @@ -558,6 +580,8 @@ impl TryFrom for ThreadMetadata { fn try_from(row: ThreadRow) -> std::result::Result { let ThreadRow { originator, + creator_user_id, + creator_account_id, id, rollout_path, created_at, @@ -617,6 +641,8 @@ impl TryFrom for ThreadMetadata { Ok(Self { id: ThreadId::try_from(id)?, originator, + creator_user_id, + creator_account_id, rollout_path: PathBuf::from(rollout_path), created_at: epoch_millis_to_datetime(created_at)?, updated_at: epoch_millis_to_datetime(updated_at)?, @@ -728,6 +754,8 @@ mod tests { fn thread_row(reasoning_effort: Option<&str>) -> ThreadRow { ThreadRow { originator: None, + creator_user_id: None, + creator_account_id: None, id: "00000000-0000-0000-0000-000000000123".to_string(), rollout_path: "/tmp/rollout-123.jsonl".to_string(), created_at: 1_700_000_000, @@ -768,6 +796,8 @@ mod tests { fn expected_thread_metadata(reasoning_effort: Option) -> ThreadMetadata { ThreadMetadata { originator: None, + creator_user_id: None, + creator_account_id: None, id: ThreadId::from_string("00000000-0000-0000-0000-000000000123") .expect("valid thread id"), rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"), diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 6eb810bc46..2cb66d1f8c 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -181,6 +181,8 @@ SELECT threads.recency_at_ms AS recency_at, threads.source, threads.originator, + threads.creator_user_id, + threads.creator_account_id, threads.history_mode, threads.thread_source, threads.agent_path, @@ -573,6 +575,8 @@ SELECT threads.recency_at_ms AS recency_at, threads.source, threads.originator, + threads.creator_user_id, + threads.creator_account_id, threads.history_mode, threads.thread_source, threads.agent_nickname, diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index b8c7679294..a259dd93b8 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -45,6 +45,8 @@ pub(super) fn test_thread_metadata( ) -> ThreadMetadata { let now = DateTime::::from_timestamp(1_700_000_000, 0).expect("timestamp"); ThreadMetadata { + creator_user_id: None, + creator_account_id: None, originator: None, id: thread_id, rollout_path: codex_home.join(format!("rollout-{thread_id}.jsonl")), diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index aacc960b04..2917fc2499 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -17,6 +17,8 @@ SELECT threads.recency_at_ms AS recency_at, threads.source, threads.originator, + threads.creator_user_id, + threads.creator_account_id, threads.history_mode, threads.thread_source, threads.agent_nickname, @@ -621,6 +623,8 @@ INSERT INTO threads ( recency_at_ms, source, originator, + creator_user_id, + creator_account_id, history_mode, thread_source, agent_nickname, @@ -649,7 +653,7 @@ INSERT INTO threads ( memory_mode, project_id, daybreak_enabled -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -663,6 +667,8 @@ ON CONFLICT(id) DO NOTHING .bind(datetime_to_epoch_millis(recency_at)) .bind(metadata.source.as_str()) .bind(metadata.originator.as_deref()) + .bind(metadata.creator_user_id.as_deref()) + .bind(metadata.creator_account_id.as_deref()) .bind(metadata.history_mode.as_str()) .bind( metadata @@ -917,6 +923,8 @@ INSERT INTO threads ( recency_at_ms, source, originator, + creator_user_id, + creator_account_id, history_mode, thread_source, agent_nickname, @@ -945,7 +953,7 @@ INSERT INTO threads ( memory_mode, project_id, daybreak_enabled -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, @@ -956,6 +964,8 @@ ON CONFLICT(id) DO UPDATE SET recency_at_ms = threads.recency_at_ms, source = excluded.source, originator = COALESCE(threads.originator, excluded.originator), + creator_user_id = COALESCE(threads.creator_user_id, excluded.creator_user_id), + creator_account_id = COALESCE(threads.creator_account_id, excluded.creator_account_id), -- Paginated history is a one-way promotion; stale legacy metadata must not downgrade it. history_mode = CASE WHEN threads.history_mode = 'paginated' THEN threads.history_mode @@ -993,6 +1003,8 @@ ON CONFLICT(id) DO UPDATE SET .bind(datetime_to_epoch_millis(insert_recency_at)) .bind(metadata.source.as_str()) .bind(metadata.originator.as_deref()) + .bind(metadata.creator_user_id.as_deref()) + .bind(metadata.creator_account_id.as_deref()) .bind(metadata.history_mode.as_str()) .bind( metadata @@ -1300,6 +1312,8 @@ SELECT threads.recency_at_ms AS recency_at, threads.source, threads.originator, + threads.creator_user_id, + threads.creator_account_id, threads.history_mode, threads.thread_source, threads.agent_nickname, @@ -2595,6 +2609,8 @@ mod tests { ); let items = vec![RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), id: thread_id, forked_from_id: None, @@ -2667,6 +2683,8 @@ mod tests { ); let items = vec![RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), id: thread_id, forked_from_id: None, @@ -2750,6 +2768,8 @@ mod tests { let mut rollout_metadata = metadata.clone(); rollout_metadata.originator = Some("recorded_client".to_string()); + rollout_metadata.creator_user_id = Some("creator-user".to_string()); + rollout_metadata.creator_account_id = Some("creator-account".to_string()); rollout_metadata.git_sha = Some("rollout-sha".to_string()); rollout_metadata.git_branch = Some("rollout-branch".to_string()); rollout_metadata.git_origin_url = Some( @@ -2777,6 +2797,8 @@ mod tests { for incoming_originator in [None, Some("resume_client")] { rollout_metadata.originator = incoming_originator.map(str::to_owned); + rollout_metadata.creator_user_id = incoming_originator.map(str::to_owned); + rollout_metadata.creator_account_id = incoming_originator.map(str::to_owned); runtime .upsert_thread(&rollout_metadata) .await @@ -2787,6 +2809,13 @@ mod tests { .expect("thread should load") .expect("thread should exist"); assert_eq!(persisted.originator.as_deref(), Some("recorded_client")); + assert_eq!( + ( + persisted.creator_user_id.as_deref(), + persisted.creator_account_id.as_deref() + ), + (Some("creator-user"), Some("creator-account")), + ); } } diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 955f874156..37c3bc6bca 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -165,6 +165,8 @@ mod tests { ] { store .create_thread(CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, @@ -459,6 +461,8 @@ mod tests { history_mode: ThreadHistoryMode, ) -> CreateThreadParams { CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, @@ -606,6 +610,8 @@ impl InMemoryThreadStore { agent_role: params.source.get_agent_role(), agent_path: params.source.get_agent_path().map(Into::into), originator: params.originator.clone(), + creator_user_id: params.creator_user_id.clone(), + creator_account_id: params.creator_account_id.clone(), source: params.source.clone(), thread_source: params.thread_source.clone(), model_provider: Some(params.metadata.model_provider.clone()), diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index 70b27e7efa..e3b7fc8a89 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -39,6 +39,7 @@ pub(super) async fn create_thread( params.base_instructions, params.dynamic_tools, ) + .with_creator(params.creator_user_id, params.creator_account_id) .with_session_id(params.session_id) .with_selected_capability_roots(params.selected_capability_roots) .with_runtime_workspace_roots(params.runtime_workspace_roots.map(|roots| { diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 3bc268268a..2261ec8c00 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -2063,6 +2063,8 @@ mod tests { fn create_thread_params(thread_id: ThreadId) -> CreateThreadParams { CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, diff --git a/codex-rs/thread-store/src/local/pending_thread_metadata_tests.rs b/codex-rs/thread-store/src/local/pending_thread_metadata_tests.rs index 3e31298016..1f7c272faf 100644 --- a/codex-rs/thread-store/src/local/pending_thread_metadata_tests.rs +++ b/codex-rs/thread-store/src/local/pending_thread_metadata_tests.rs @@ -261,6 +261,8 @@ fn staged_model_patch() -> ThreadMetadataPatch { fn create_thread_params(thread_id: ThreadId) -> CreateThreadParams { CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, diff --git a/codex-rs/thread-store/src/local/revert_thread.rs b/codex-rs/thread-store/src/local/revert_thread.rs index 9f6e8f139d..5aeebce31e 100644 --- a/codex-rs/thread-store/src/local/revert_thread.rs +++ b/codex-rs/thread-store/src/local/revert_thread.rs @@ -37,14 +37,14 @@ pub(super) async fn revert( let writer_lock = store.acquire_writer_lock(thread_id)?; // Resolution may return a compressed sibling. Keep SQLite's exact stored path for the CAS. - let expected_sqlite_path = state_db + let stored_metadata = state_db .get_thread(thread_id) .await .map_err(|err| ThreadStoreError::Internal { message: format!("failed to read thread metadata for {thread_id}: {err}"), })? - .ok_or(ThreadStoreError::ThreadNotFound { thread_id })? - .rollout_path; + .ok_or(ThreadStoreError::ThreadNotFound { thread_id })?; + let expected_sqlite_path = stored_metadata.rollout_path; let current_rollout = thread_rollout_resolver::resolve_current(store, thread_id) .await? .ok_or(ThreadStoreError::ThreadNotFound { thread_id })?; @@ -69,6 +69,14 @@ pub(super) async fn revert( }); } + // Older binaries can omit creator fields when replacing a rollout during revert. + source_meta.creator_user_id = source_meta + .creator_user_id + .or(stored_metadata.creator_user_id); + source_meta.creator_account_id = source_meta + .creator_account_id + .or(stored_metadata.creator_account_id); + // Preserve old-reader compatibility when introducing the first reference to a standalone // source. Already-shared ancestors stay read-only; their offsets address decoded JSONL bytes. let mut lineage = store.resolve_rollout_lineage(thread_id).await?; @@ -169,6 +177,7 @@ async fn create_replacement_recorder( source_meta.base_instructions.unwrap_or_default(), source_meta.dynamic_tools.unwrap_or_default(), ) + .with_creator(source_meta.creator_user_id, source_meta.creator_account_id) .with_session_id(source_meta.session_id) .with_rollout_id(rollout_id) .with_selected_capability_roots(source_meta.selected_capability_roots) diff --git a/codex-rs/thread-store/src/local/revert_thread_tests.rs b/codex-rs/thread-store/src/local/revert_thread_tests.rs index 0b27ac1b82..fd9ca13c0c 100644 --- a/codex-rs/thread-store/src/local/revert_thread_tests.rs +++ b/codex-rs/thread-store/src/local/revert_thread_tests.rs @@ -94,6 +94,13 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() { .expect("read replacement metadata") .meta; assert_eq!(replacement_meta.id, thread_id); + assert_eq!( + ( + replacement_meta.creator_user_id.as_deref(), + replacement_meta.creator_account_id.as_deref() + ), + (Some("creator-user"), Some("creator-account")), + ); assert_eq!(replacement_meta.memory_mode, None); assert_eq!( replacement_meta.runtime_workspace_roots, @@ -101,6 +108,24 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() { ); assert_eq!(turn_ids(&store, thread_id).await, vec!["turn-1"]); + // Simulate an older binary replacing the rollout without the creator fields it does not know. + let rollout = tokio::fs::read_to_string(&first_replacement_path) + .await + .expect("read replacement rollout"); + let (meta_line, remaining_lines) = rollout.split_once('\n').expect("session metadata line"); + let mut meta_line: serde_json::Value = serde_json::from_str(meta_line).expect("parse metadata"); + let payload = meta_line["payload"] + .as_object_mut() + .expect("metadata payload"); + payload.remove("creator_user_id"); + payload.remove("creator_account_id"); + tokio::fs::write( + &first_replacement_path, + format!("{meta_line}\n{remaining_lines}"), + ) + .await + .expect("write legacy replacement rollout"); + store .revert_thread(RevertThreadParams { thread_id, @@ -110,6 +135,22 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() { .await .expect("revert before first turn"); assert_eq!(turn_ids(&store, thread_id).await, Vec::::new()); + let stored = state_db + .get_thread(thread_id) + .await + .expect("read metadata") + .expect("thread metadata"); + let recovered = codex_rollout::read_session_meta_line(&stored.rollout_path) + .await + .expect("read recovered creator metadata") + .meta; + assert_eq!( + (recovered.creator_user_id, recovered.creator_account_id), + ( + Some("creator-user".to_string()), + Some("creator-account".to_string()) + ), + ); store .archive_thread(ArchiveThreadParams { thread_id }) @@ -231,6 +272,8 @@ async fn rollout_paths_for_thread( async fn create_paginated_thread(store: &LocalThreadStore, thread_id: ThreadId) { store .create_thread(CreateThreadParams { + creator_user_id: Some("creator-user".to_string()), + creator_account_id: Some("creator-account".to_string()), session_id: thread_id.into(), thread_id, extra_config: None, diff --git a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs index 3f6fcbf615..55964eff96 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs @@ -583,6 +583,8 @@ async fn paginated_realtime_items_materialize_separately_in_rollout_order() { let legacy_thread_id = ThreadId::new(); store .create_thread(CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: legacy_thread_id.into(), thread_id: legacy_thread_id, extra_config: None, @@ -2571,6 +2573,8 @@ async fn create_paginated_subagent_thread( ) { store .create_thread(CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 6caf5b294d..8cfb38bf2f 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -449,6 +449,8 @@ async fn apply_metadata_update( metadata.source = enum_to_string(&source); } metadata.originator = metadata.originator.or(patch.originator); + metadata.creator_user_id = metadata.creator_user_id.or(patch.creator_user_id); + metadata.creator_account_id = metadata.creator_account_id.or(patch.creator_account_id); if let Some(thread_source) = patch.thread_source { metadata.thread_source = thread_source; } @@ -737,6 +739,8 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { || patch.created_at.is_some() || patch.source.is_some() || patch.originator.is_some() + || patch.creator_user_id.is_some() + || patch.creator_account_id.is_some() || patch.thread_source.is_some() || patch.agent_nickname.is_some() || patch.agent_role.is_some() diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index fb7d4d8c78..54350fbc68 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -68,6 +68,8 @@ impl ThreadMetadataSync { updated_at: Some(created_at), source: Some(params.source.clone()), originator: (!params.originator.is_empty()).then(|| params.originator.clone()), + creator_user_id: params.creator_user_id.clone(), + creator_account_id: params.creator_account_id.clone(), thread_source: Some(params.thread_source.clone()), agent_nickname: Some(params.source.get_nickname()), agent_role: Some(params.source.get_agent_role()), @@ -228,6 +230,8 @@ impl ThreadMetadataSync { match item { RolloutItem::SessionMeta(meta_line) if meta_line.meta.id == self.thread_id => { update.created_at = parse_session_timestamp(meta_line.meta.timestamp.as_str()); + update.creator_user_id = meta_line.meta.creator_user_id.clone(); + update.creator_account_id = meta_line.meta.creator_account_id.clone(); update.source = Some(meta_line.meta.source.clone()); if !meta_line.meta.originator.is_empty() { update.originator = Some(meta_line.meta.originator.clone()); @@ -386,6 +390,8 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.advance_recency_at.is_some() || update.source.is_some() || update.originator.is_some() + || update.creator_user_id.is_some() + || update.creator_account_id.is_some() || update.thread_source.is_some() || update.agent_nickname.is_some() || update.agent_role.is_some() @@ -444,6 +450,8 @@ mod tests { async fn create_metadata_records_originator_without_project() { let thread_id = ThreadId::new(); let sync = ThreadMetadataSync::for_create(&CreateThreadParams { + creator_user_id: None, + creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 28568b3e2b..609e35f8d2 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -72,6 +72,12 @@ pub struct ExtraConfig {} /// Parameters required to create a persisted thread. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CreateThreadParams { + /// ChatGPT user that created this thread; absent when unavailable or for older threads. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creator_user_id: Option, + /// ChatGPT account selected when this thread was created. Never updated on resume. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creator_account_id: Option, /// Session id shared by the root thread and all of its subagents. pub session_id: SessionId, /// Thread id generated by Codex before opening persistence. @@ -737,6 +743,10 @@ pub struct ThreadMetadataPatch { pub advance_recency_at: Option>, /// Session source. pub source: Option, + /// Recorded creator user. Only fills a missing stored value. + pub creator_user_id: Option, + /// Recorded creator account. Only fills a missing stored value. + pub creator_account_id: Option, /// Recorded creation-time originator. Only fills a missing stored value. pub originator: Option, /// Optional analytics source classification. @@ -834,6 +844,12 @@ impl ThreadMetadataPatch { if next.source.is_some() { self.source = next.source; } + if next.creator_user_id.is_some() { + self.creator_user_id = next.creator_user_id; + } + if next.creator_account_id.is_some() { + self.creator_account_id = next.creator_account_id; + } if next.originator.is_some() { self.originator = next.originator; } @@ -896,6 +912,8 @@ impl ThreadMetadataPatch { && self.advance_recency_at.is_none() && self.source.is_none() && self.originator.is_none() + && self.creator_user_id.is_none() + && self.creator_account_id.is_none() && self.thread_source.is_none() && self.agent_nickname.is_none() && self.agent_role.is_none()