diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d8c55a3c04..49d3bb9c63 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -242,6 +242,7 @@ fn sample_thread_with_metadata( parent_thread_id: Option, ) -> Thread { Thread { + originator: None, environments: None, id: thread_id.to_string(), extra: None, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 0529e0878d..9ea105a042 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -589,6 +589,7 @@ fn sample_thread_archive_request() -> ClientRequest { fn sample_thread(thread_id: &str) -> Thread { Thread { + originator: None, environments: None, id: thread_id.to_string(), extra: None, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index bd75f7b403..92a2de083d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4448,6 +4448,16 @@ "null" ] }, + "originators": { + "description": "Optional originator allowlist, matching any supplied value exactly. Supported by hosted backends only; the local app-server rejects a nonempty list. Omitted or empty lists leave originators unrestricted.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "searchTerm": { "description": "Optional substring filter for the extracted thread title.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1ad6b768b3..fe293e542d 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -4172,6 +4172,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 9d999767b6..fb4fb4f1a4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -20339,6 +20339,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ @@ -22030,6 +22037,16 @@ "null" ] }, + "originators": { + "description": "Optional originator allowlist, matching any supplied value exactly. Supported by hosted backends only; the local app-server rejects a nonempty list. Omitted or empty lists leave originators unrestricted.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "searchTerm": { "description": "Optional substring filter for the extracted thread title.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 98cc9380dc..c3c4fc797e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -18053,6 +18053,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ @@ -19744,6 +19751,16 @@ "null" ] }, + "originators": { + "description": "Optional originator allowlist, matching any supplied value exactly. Supported by hosted backends only; the local app-server rejects a nonempty list. Omitted or empty lists leave originators unrestricted.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "searchTerm": { "description": "Optional substring filter for the extracted thread title.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 1d46ab0f40..d5b3c56db3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1352,6 +1352,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index a4afb30280..83d8171483 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -91,6 +91,16 @@ "null" ] }, + "originators": { + "description": "Optional originator allowlist, matching any supplied value exactly. Supported by hosted backends only; the local app-server rejects a nonempty list. Omitted or empty lists leave originators unrestricted.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "searchTerm": { "description": "Optional substring filter for the extracted thread title.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index d1f3b13378..58a0e80aae 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1143,6 +1143,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 48fd9923d3..1975d98e26 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1143,6 +1143,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 22936d4d99..b3e3ca9ba3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1143,6 +1143,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 5bab756d40..efdcfc5d9b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1352,6 +1352,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json index 5116e6c1f3..72928d2f2a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json @@ -1143,6 +1143,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 32f37efb5e..33396542a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1143,6 +1143,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index ef86fc274e..0a9cc8b2ab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1352,6 +1352,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index e751825148..bcfd63f923 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1143,6 +1143,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 4f46d1f2ab..2ed8f65ebe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1143,6 +1143,13 @@ "null" ] }, + "originator": { + "description": "Originator recorded when the thread was created, independent of its current client or executor. Null when the recorded originator is unavailable.", + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 4befd79c2b..728f2888d8 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ 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 d7e344bebf..c4ceee2265 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-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 61e91df781..42a836746d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -74,6 +74,10 @@ cwd: AbsolutePathBuf, /** * Version of the CLI that created the thread. */ cliVersion: string, /** + * Originator recorded when the thread was created, independent of its current client or executor. + * Null when the recorded originator is unavailable. + */ +originator: string | null, /** * Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). */ source: SessionSource, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts index 3bff76e2c7..854b6ef92c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -26,6 +26,11 @@ modelProviders?: Array | null, /** * are returned. When omitted or empty, defaults to interactive sources. */ sourceKinds?: Array | null, /** + * Optional originator allowlist, matching any supplied value exactly. + * Supported by hosted backends only; the local app-server rejects a nonempty list. + * Omitted or empty lists leave originators unrestricted. + */ +originators?: Array | null, /** * Optional archived filter; when set to true, only archived threads are returned. * If false or null, only non-archived threads are returned. */ diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index eb4816fe71..c15b6dc71a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3128,6 +3128,7 @@ mod tests { request_id: RequestId::Integer(7), response: v2::ThreadStartResponse { thread: v2::Thread { + originator: None, environments: None, id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), extra: None, @@ -3210,6 +3211,7 @@ mod tests { "path": null, "cwd": absolute_path_string("tmp"), "cliVersion": "0.0.0", + "originator": null, "source": "exec", "canAcceptDirectInput": null, "threadSource": null, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index eed9a7ddc4..024b4e3e83 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -272,6 +272,7 @@ fn thread_resume_params_accept_turns_page_bootstrap() { fn thread_resume_response_round_trips_initial_turns_page() { let response = ThreadResumeResponse { thread: Thread { + originator: Some("future_client".to_string()), environments: Some(vec![ThreadEnvironment { environment_id: "remote".to_string(), cwd: LegacyAppPathString::from_string(r"C:\workspace"), @@ -335,6 +336,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { }; let value = serde_json::to_value(&response).expect("serialize thread resume response"); + assert_eq!(value["thread"]["originator"], json!("future_client")); assert_eq!( value["thread"]["environments"], json!([{ @@ -359,12 +361,14 @@ fn thread_resume_response_round_trips_initial_turns_page() { legacy_thread_fields.remove("sectionEnteredAt"); legacy_thread_fields.remove("projectId"); legacy_thread_fields.remove("environments"); + legacy_thread_fields.remove("originator"); let legacy_thread = serde_json::from_value::(legacy_thread).expect("deserialize legacy thread"); assert_eq!(legacy_thread.section, None); assert_eq!(legacy_thread.section_entered_at, None); assert_eq!(legacy_thread.project_id, None); assert_eq!(legacy_thread.environments, None); + assert_eq!(legacy_thread.originator, None); assert_eq!( value.get("initialTurnsPage"), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 2a08c9fd1d..dd2f73780b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1386,6 +1386,11 @@ pub struct ThreadListParams { /// are returned. When omitted or empty, defaults to interactive sources. #[ts(optional = nullable)] pub source_kinds: Option>, + /// Optional originator allowlist, matching any supplied value exactly. + /// Supported by hosted backends only; the local app-server rejects a nonempty list. + /// Omitted or empty lists leave originators unrestricted. + #[ts(optional = nullable)] + pub originators: Option>, /// Optional archived filter; when set to true, only archived threads are returned. /// If false or null, only non-archived threads are returned. #[ts(optional = nullable)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 1956095031..2bb3396526 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -264,6 +264,9 @@ pub struct Thread { pub cwd: AbsolutePathBuf, /// Version of the CLI that created the thread. pub cli_version: String, + /// Originator recorded when the thread was created, independent of its current client or executor. + /// Null when the recorded originator is unavailable. + pub originator: Option, /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). pub source: SessionSource, /// Whether the app server accepts direct turn input for this loaded thread. @@ -319,6 +322,7 @@ struct ThreadCompatibility { path: Option, cwd: AbsolutePathBuf, cli_version: String, + originator: Option, source: SessionSource, can_accept_direct_input: Option, thread_source: Option, @@ -358,6 +362,7 @@ impl<'de> Deserialize<'de> for Thread { path: thread.path, cwd: thread.cwd, cli_version: thread.cli_version, + originator: thread.originator, source: thread.source, can_accept_direct_input: thread.can_accept_direct_input, thread_source: thread.thread_source, diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index a034cae701..27b8a341fe 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1301,6 +1301,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3 println!("< initialize response: {initialize:?}"); let response = client.thread_list(ThreadListParams { + originators: None, cursor: None, limit: Some(limit), sort_key: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 46f9f73b5b..7cc975f198 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -563,6 +563,7 @@ Pass any combination of: - `sortDirection` — `desc` (default for timestamp sorts) or `asc` (default for `section_position`). - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). +- `originators` — an exact-value allowlist for hosted backends that support originator filtering. The local app-server rejects a nonempty list; omission, `null`, and `[]` leave originators unrestricted. - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). - `sectionId` — provide an ID from `threadSection/list` to return threads from that section; pass `null` to return only threads without a section; or omit it to include threads from every section and threads without a section. - `cwd` — restrict results to threads whose session cwd exactly matches this path, or one of these paths when an array is provided. Relative paths are resolved against the app-server process cwd before matching. @@ -570,6 +571,7 @@ Pass any combination of: - `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive). - Responses include `nextCursor` to continue in the same direction and `backwardsCursor` to pass as `cursor` when reversing `sortDirection`. - Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. +- Full thread responses and `thread/started` include `originator`, the value recorded at creation, or `null` when unavailable. Opening or resuming a thread does not attribute it to the current client. This is separate from the runtime `source` and does not choose an executor. Example: diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 1ef5b0d0f3..3989351255 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2297,6 +2297,7 @@ mod tests { })), ]; let stored_thread = StoredThread { + originator: None, thread_id, extra_config: None, rollout_path: None, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index d209c7055f..2549be0d60 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2513,6 +2513,7 @@ impl ThreadRequestProcessor { sort_direction, model_providers, source_kinds, + originators, archived, section_id, project_id, @@ -2522,6 +2523,14 @@ impl ThreadRequestProcessor { parent_thread_id, ancestor_thread_id, } = params; + if originators + .as_ref() + .is_some_and(|values| !values.is_empty()) + { + return Err(invalid_params( + "originator filtering is not supported by the local app-server", + )); + } if project_id.is_some() && !self.thread_store.supports_projects() { return Err(unsupported_thread_store_operation("projects")); } @@ -5948,6 +5957,7 @@ pub(crate) fn thread_from_stored_thread( path, cwd, cli_version: thread.cli_version, + originator: thread.originator, agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), source: source.into(), @@ -6128,6 +6138,8 @@ fn build_thread_from_snapshot( path, cwd: config_snapshot.cwd().clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), + originator: (!config_snapshot.originator.is_empty()) + .then(|| config_snapshot.originator.clone()), agent_nickname: config_snapshot.session_source.get_nickname(), agent_role: config_snapshot.session_source.get_agent_role(), source: config_snapshot.session_source.clone().into(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 60f36d6365..09b54ebdba 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -459,6 +459,7 @@ mod thread_processor_behavior_tests { let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread"); let stored_thread = StoredThread { + originator: None, thread_id, extra_config: None, rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")), diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 8969e90eac..2fe541da38 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -198,6 +198,7 @@ mod tests { fn test_thread(items: Vec) -> Thread { Thread { + originator: None, environments: None, id: "thread-1".to_string(), extra: None, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index dba7ead185..15bfb09506 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -259,6 +259,7 @@ pub(crate) fn summary_to_thread( let thread_id = conversation_id.to_string(); Thread { + originator: None, environments: None, id: thread_id.clone(), extra: None, diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index b7839519dc..4b1306b34c 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -606,6 +606,7 @@ source = {:?} let request_id = mcp .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: None, sort_key: None, @@ -1970,6 +1971,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let request_id = mcp .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: None, sort_key: None, @@ -2160,6 +2162,7 @@ required = true let request_id = mcp .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: None, sort_key: None, @@ -2245,6 +2248,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res let request_id = mcp .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: None, sort_key: None, @@ -2327,6 +2331,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions() let request_id = mcp .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: None, sort_key: None, @@ -2455,6 +2460,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f let request_id = mcp .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: None, sort_key: None, @@ -2571,6 +2577,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ let request_id = mcp .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: None, sort_key: None, diff --git a/codex-rs/app-server/tests/suite/v2/projects.rs b/codex-rs/app-server/tests/suite/v2/projects.rs index 8c970fc698..78e66e4e6a 100644 --- a/codex-rs/app-server/tests/suite/v2/projects.rs +++ b/codex-rs/app-server/tests/suite/v2/projects.rs @@ -100,6 +100,7 @@ async fn projects_list_by_recency_and_preserve_metadata_timestamps() -> Result<( .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -323,6 +324,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -368,6 +370,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -416,6 +419,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -609,6 +613,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -699,6 +704,7 @@ async fn deleted_project_is_dropped_before_first_durable_thread_persistence() -> .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -888,6 +894,7 @@ async fn projects_validate_filters_cursors_and_sqlite_less_assignment() -> Resul for project_id in [String::new(), Uuid::now_v7().to_string()] { let request_id = server .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -972,6 +979,7 @@ async fn projects_validate_filters_cursors_and_sqlite_less_assignment() -> Resul assert_eq!(error.error.code, -32601); let list_id = unsupported_projects .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: 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 a356e2e544..3b9945fe39 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 @@ -290,6 +290,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist .request(ClientRequest::ThreadList { request_id: RequestId::Integer(3), params: ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index ee7b6fa311..d19eedfdcc 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -94,6 +94,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs async fn list_threads(mcp: &mut TestAppServer) -> Result { let list_id = mcp .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: Some(50), sort_key: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index fb8b590b4e..036dd056a8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -102,6 +102,7 @@ async fn list_threads_with_sort( mcp.request(|request_id| ClientRequest::ThreadList { request_id, params: codex_app_server_protocol::ThreadListParams { + originators: None, cursor, limit, sort_key, @@ -141,6 +142,7 @@ async fn list_threads_for_relation( mcp.request(|request_id| ClientRequest::ThreadList { request_id, params: codex_app_server_protocol::ThreadListParams { + originators: None, cursor, limit: Some(limit), sort_key: None, @@ -546,6 +548,7 @@ async fn thread_list_respects_cwd_filters() -> Result<()> { let mut mcp = init_mcp(codex_home.path()).await?; let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -659,6 +662,7 @@ sqlite = true let mut mcp = init_mcp(codex_home.path()).await?; let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -946,6 +950,7 @@ sqlite = true let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -982,6 +987,7 @@ sqlite = true let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -1011,6 +1017,7 @@ sqlite = true let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -1196,6 +1203,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { let mut mcp = init_mcp(codex_home.path()).await?; let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -1222,6 +1230,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { let thread_id = ThreadId::new().to_string(); let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -1508,6 +1517,7 @@ async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result .request(|request_id| ClientRequest::ThreadList { request_id, params: codex_app_server_protocol::ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -2329,6 +2339,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() } = { let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + originators: None, cursor: None, limit: Some(1), sort_key: Some(ThreadSortKey::UpdatedAt), @@ -2370,6 +2381,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() } = { let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + originators: None, cursor: Some(backwards_cursor), limit: Some(10), sort_key: Some(ThreadSortKey::UpdatedAt), @@ -2598,6 +2610,138 @@ async fn thread_list_archived_filter() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_rejects_originator_filter_but_accepts_empty_allowlist() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + let mut mcp = init_mcp(codex_home.path()).await?; + let request_id = mcp + .send_thread_list_request(serde_json::from_value(json!({ + "originators": ["future_client"] + }))?) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + (error.error.code, error.error.message), + ( + -32602, + "originator filtering is not supported by the local app-server".to_string() + ), + ); + for params in [ + json!({}), + json!({"originators": null}), + json!({"originators": []}), + ] { + let response: ThreadListResponse = mcp + .request(|request_id| ClientRequest::ThreadList { + request_id, + params: serde_json::from_value(params).expect("valid list params"), + }) + .await?; + assert_eq!(response.data, Vec::new()); + } + Ok(()) +} + +#[test_case::test_case("codex_work_desktop")] +#[tokio::test] +async fn thread_originator_is_preserved_in_list_read_and_resume(originator: &str) -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_runtime_config(codex_home.path(), &server.uri())?; + let mut mcp = init_mcp(codex_home.path()).await?; + let ThreadStartResponse { thread, .. } = mcp + .start_thread(ThreadStartParams { + service_name: Some(originator.to_string()), + ..Default::default() + }) + .await?; + assert_eq!(thread.originator.as_deref(), Some(originator)); + let started: codex_app_server_protocol::ThreadStartedNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_notification("thread/started"), + ) + .await??; + assert_eq!(started.thread, thread); + let thread_id = thread.id; + + // The first turn exercises live metadata persistence, not rollout-file backfill. + let _: TurnStartResponse = mcp + .request(|request_id| ClientRequest::TurnStart { + request_id, + params: TurnStartParams { + thread_id: thread_id.clone(), + input: vec![UserInput::Text { + text: "Persist this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await?; + let completed: codex_app_server_protocol::TurnCompletedNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_notification("turn/completed"), + ) + .await??; + assert_eq!( + completed.turn.status, + codex_app_server_protocol::TurnStatus::Completed + ); + + for restart in [false, true] { + if restart { + assert!( + timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()) + .await?? + .success() + ); + mcp = init_mcp(codex_home.path()).await?; + } + let response: ThreadListResponse = mcp + .request(|request_id| ClientRequest::ThreadList { + request_id, + params: serde_json::from_value(json!({"useStateDbOnly": true})) + .expect("valid list params"), + }) + .await?; + assert_eq!( + response + .data + .into_iter() + .map(|thread| (thread.id, thread.originator)) + .collect::>(), + vec![(thread_id.clone(), Some(originator.to_string()))], + ); + let read: ThreadReadResponse = mcp + .request(|request_id| ClientRequest::ThreadRead { + request_id, + params: ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }, + }) + .await?; + assert_eq!(read.thread.originator.as_deref(), Some(originator)); + } + let resumed: ThreadResumeResponse = mcp + .request(|request_id| ClientRequest::ThreadResume { + request_id, + params: ThreadResumeParams { + thread_id: thread_id.clone(), + ..Default::default() + }, + }) + .await?; + assert_eq!(resumed.thread.originator.as_deref(), Some(originator)); + Ok(()) +} + #[tokio::test] async fn thread_list_invalid_cursor_returns_error() -> Result<()> { let codex_home = TempDir::new()?; @@ -2607,6 +2751,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> { let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + originators: None, cursor: Some("not-a-cursor".to_string()), limit: Some(2), sort_key: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index ca44c3380d..a7bc33df5f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -85,6 +85,7 @@ async fn thread_section_move_pins_before_first_turn() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(move_id)).await??; let list_params = ThreadListParams { + originators: None, cursor: None, limit: Some(100), sort_key: None, @@ -261,6 +262,7 @@ async fn thread_section_move_pins_and_unpins_with_filtered_recency_pagination() } let list_params = ThreadListParams { + originators: None, cursor: None, limit: Some(1), sort_key: Some(ThreadSortKey::RecencyAt), @@ -452,6 +454,7 @@ async fn thread_sections_preserve_server_owned_manual_order_across_moves_and_res } let list_params = ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: Some(ThreadSortKey::SectionPosition), 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 1756bf7c91..06aaa9b5f4 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -293,6 +293,7 @@ async fn paginated_stored_thread_routes_projected_turns() -> Result<()> { let list_id = mcp .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: Some(50), sort_key: None, @@ -984,6 +985,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> .request(ClientRequest::ThreadList { request_id: RequestId::Integer(1), params: ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, @@ -1364,6 +1366,7 @@ async fn paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume // List should also surface the name. let list_id = mcp .send_thread_list_request(ThreadListParams { + originators: None, cursor: None, limit: Some(50), sort_key: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 6ce038c859..e8bb9bc470 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -343,6 +343,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.2".to_string()), thread_source: Some(ThreadSource::User), + service_name: Some("codex_work_desktop".to_string()), ..Default::default() }) .await?; @@ -383,6 +384,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { ); assert_eq!(thread.status, ThreadStatus::Idle); assert_eq!(thread.thread_source, Some(ThreadSource::User)); + assert_eq!(thread.originator.as_deref(), Some("codex_work_desktop")); let thread_path = thread.path.clone().expect("thread path should be present"); assert!(thread_path.is_absolute(), "thread path should be absolute"); assert!( diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 6af12cea2e..a0e055bb5e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -4382,6 +4382,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected( .request(|request_id| ClientRequest::ThreadList { request_id, params: codex_app_server_protocol::ThreadListParams { + originators: None, cursor: None, limit: Some(10), sort_key: None, diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index aef899c5b7..0624239b4f 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -29,6 +29,7 @@ use tempfile::TempDir; fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThread { StoredThread { + originator: None, thread_id: ThreadId::new(), extra_config: None, rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 49ecf1edc9..d643975084 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1604,6 +1604,7 @@ async fn resolve_resume_thread_id( ClientRequest::ThreadList { request_id: RequestId::Integer(0), params: ThreadListParams { + originators: None, cursor, limit: Some(100), sort_key: Some(ThreadSortKey::UpdatedAt), @@ -1689,6 +1690,7 @@ async fn resolve_resume_thread_id( ClientRequest::ThreadList { request_id: RequestId::Integer(0), params: ThreadListParams { + originators: None, cursor, limit: Some(100), sort_key: Some(ThreadSortKey::UpdatedAt), diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index c178e40c43..ba273277f5 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -373,6 +373,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { path: None, cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0-test".to_string(), + originator: None, source: codex_app_server_protocol::SessionSource::Exec, can_accept_direct_input: None, thread_source: None, @@ -842,6 +843,7 @@ async fn session_configured_from_thread_response_preserves_parent_thread_id() { fn sample_thread_start_response() -> ThreadStartResponse { ThreadStartResponse { thread: codex_app_server_protocol::Thread { + originator: None, environments: None, id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), extra: None, diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index 29dde5a78e..ffb1c7a5f2 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -50,6 +50,8 @@ pub struct ThreadsPage { /// Summary information for a thread rollout file. #[derive(Debug, PartialEq, Default)] pub struct ThreadItem { + /// Originator recorded at creation, if available. + pub originator: Option, /// Absolute path to the rollout file. pub path: PathBuf, /// Thread ID from session metadata. @@ -106,6 +108,7 @@ pub type ConversationsPage = ThreadsPage; #[derive(Default)] struct HeadTailSummary { + originator: Option, saw_session_meta: bool, thread_id: Option, first_user_message: Option, @@ -815,6 +818,7 @@ async fn build_thread_item( // Apply filters: must have session meta and a discoverable preview. if summary.saw_session_meta && summary.preview.is_some() { let HeadTailSummary { + originator, thread_id, first_user_message, preview, @@ -837,6 +841,7 @@ async fn build_thread_item( summary_updated_at = updated_at.or_else(|| created_at.clone()); } return Some(ThreadItem { + originator, path, thread_id, first_user_message, @@ -1146,6 +1151,8 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { if !summary.saw_session_meta { + summary.originator = (!session_meta_line.meta.originator.is_empty()) + .then(|| session_meta_line.meta.originator.clone()); summary.source = Some(session_meta_line.meta.source.clone()); summary.history_mode = session_meta_line.meta.history_mode; summary.parent_thread_id = session_meta_line.meta.parent_thread_id; diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 727bff87f4..33cf377b8a 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -48,6 +48,8 @@ pub(crate) fn builder_from_session_meta( session_meta.meta.source.clone(), ); builder.history_mode = session_meta.meta.history_mode; + builder.originator = + (!session_meta.meta.originator.is_empty()).then(|| session_meta.meta.originator.clone()); builder.model_provider = session_meta.meta.model_provider.clone(); builder.agent_nickname = session_meta.meta.agent_nickname.clone(); builder.agent_role = session_meta.meta.agent_role.clone(); diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index f6c56f129e..dbbb356c18 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -1282,6 +1282,7 @@ async fn fill_missing_thread_item_metadata_from_state_db( fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadItem) { let ThreadItem { + originator, path: _state_path, thread_id: _state_thread_id, first_user_message, @@ -1306,6 +1307,10 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt recency_at, } = state_item; + if item.originator.is_none() { + item.originator = originator; + } + if item.first_user_message.is_none() { item.first_user_message = first_user_message; } @@ -2024,6 +2029,7 @@ fn thread_item_from_state_metadata( parent_thread_id: Option, ) -> ThreadItem { ThreadItem { + originator: item.originator, path: item.rollout_path, thread_id: Some(item.id), first_user_message: item.first_user_message, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 90c5523222..936955a0dc 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1594,6 +1594,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi let filesystem_path = PathBuf::from("/tmp/filesystem-rollout.jsonl"); let state_path = PathBuf::from("/tmp/state-rollout.jsonl"); let mut item = ThreadItem { + originator: None, path: filesystem_path.clone(), thread_id: Some(filesystem_thread_id), first_user_message: Some("filesystem message".to_string()), @@ -1621,6 +1622,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi updated_at: None, }; let state_item = ThreadItem { + originator: None, path: state_path, thread_id: Some(state_thread_id), first_user_message: Some("state message".to_string()), diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index c0b21c4346..af70da991a 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -719,6 +719,7 @@ async fn test_list_conversations_latest_first() { let expected = ThreadsPage { items: vec![ ThreadItem { + originator: Some("test_originator".to_string()), path: p1, thread_id: Some(thread_id_from_uuid(u3)), first_user_message: Some("Hello from user".to_string()), @@ -743,6 +744,7 @@ async fn test_list_conversations_latest_first() { updated_at: updated_times.first().cloned().flatten(), }, ThreadItem { + originator: Some("test_originator".to_string()), path: p2, thread_id: Some(thread_id_from_uuid(u2)), first_user_message: Some("Hello from user".to_string()), @@ -767,6 +769,7 @@ async fn test_list_conversations_latest_first() { updated_at: updated_times.get(1).cloned().flatten(), }, ThreadItem { + originator: Some("test_originator".to_string()), path: p3, thread_id: Some(thread_id_from_uuid(u1)), first_user_message: Some("Hello from user".to_string()), @@ -884,6 +887,7 @@ async fn test_pagination_cursor() { let expected_page1 = ThreadsPage { items: vec![ ThreadItem { + originator: Some("test_originator".to_string()), path: p5, thread_id: Some(thread_id_from_uuid(u5)), first_user_message: Some("Hello from user".to_string()), @@ -908,6 +912,7 @@ async fn test_pagination_cursor() { updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { + originator: Some("test_originator".to_string()), path: p4, thread_id: Some(thread_id_from_uuid(u4)), first_user_message: Some("Hello from user".to_string()), @@ -968,6 +973,7 @@ async fn test_pagination_cursor() { let expected_page2 = ThreadsPage { items: vec![ ThreadItem { + originator: Some("test_originator".to_string()), path: p3, thread_id: Some(thread_id_from_uuid(u3)), first_user_message: Some("Hello from user".to_string()), @@ -992,6 +998,7 @@ async fn test_pagination_cursor() { updated_at: updated_page2.first().cloned().flatten(), }, ThreadItem { + originator: Some("test_originator".to_string()), path: p2, thread_id: Some(thread_id_from_uuid(u2)), first_user_message: Some("Hello from user".to_string()), @@ -1044,6 +1051,7 @@ async fn test_pagination_cursor() { page3.items.iter().map(|i| i.updated_at.clone()).collect(); let expected_page3 = ThreadsPage { items: vec![ThreadItem { + originator: Some("test_originator".to_string()), path: p1, thread_id: Some(thread_id_from_uuid(u1)), first_user_message: Some("Hello from user".to_string()), @@ -1221,6 +1229,7 @@ async fn test_get_thread_contents() { .join(format!("rollout-2025-04-01T10-30-00-{uuid}.jsonl")); let expected_page = ThreadsPage { items: vec![ThreadItem { + originator: Some("test_originator".to_string()), path: expected_path, thread_id: Some(thread_id_from_uuid(uuid)), first_user_message: Some("Hello from user".to_string()), @@ -1626,6 +1635,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { let expected_page1 = ThreadsPage { items: vec![ ThreadItem { + originator: Some("test_originator".to_string()), path: p3, thread_id: Some(thread_id_from_uuid(u3)), first_user_message: Some("Hello from user".to_string()), @@ -1650,6 +1660,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { + originator: Some("test_originator".to_string()), path: p2, thread_id: Some(thread_id_from_uuid(u2)), first_user_message: Some("Hello from user".to_string()), diff --git a/codex-rs/state/migrations/0053_threads_originator.sql b/codex-rs/state/migrations/0053_threads_originator.sql new file mode 100644 index 0000000000..ed52a0d842 --- /dev/null +++ b/codex-rs/state/migrations/0053_threads_originator.sql @@ -0,0 +1 @@ +ALTER TABLE threads ADD COLUMN originator TEXT; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index eaf670e2ce..e55e4c06d0 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -71,6 +71,9 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi } 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() { + metadata.originator = Some(meta_line.meta.originator.clone()); + } // Later SessionMeta lines do not redefine the canonical history_mode. metadata.thread_source = meta_line.meta.thread_source.clone(); metadata.agent_nickname = meta_line.meta.agent_nickname.clone(); @@ -696,6 +699,7 @@ 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 { + originator: None, id, rollout_path: PathBuf::from("/tmp/a.jsonl"), created_at, diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index 1e2da1c843..b354c06ec7 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -123,6 +123,8 @@ pub struct ExtractionOutcome { /// Canonical persisted thread metadata. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ThreadMetadata { + /// Originator recorded at creation, if available. + pub originator: Option, /// The thread identifier. pub id: ThreadId, /// The absolute rollout path on disk. @@ -190,6 +192,8 @@ pub struct ThreadMetadata { /// Builder data required to construct [`ThreadMetadata`] without parsing filenames. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ThreadMetadataBuilder { + /// Originator recorded at creation, if available. + pub originator: Option, /// The thread identifier. pub id: ThreadId, /// The absolute rollout path on disk. @@ -246,6 +250,7 @@ impl ThreadMetadataBuilder { created_at, updated_at: None, recency_at: None, + originator: None, source, history_mode: ThreadHistoryMode::Legacy, thread_source: None, @@ -279,6 +284,7 @@ impl ThreadMetadataBuilder { .map(canonicalize_datetime) .unwrap_or(updated_at); ThreadMetadata { + originator: self.originator.clone(), id: self.id, rollout_path: self.rollout_path.clone(), created_at, @@ -379,6 +385,9 @@ impl ThreadMetadata { if self.source != other.source { diffs.push("source"); } + if self.originator != other.originator { + diffs.push("originator"); + } if self.agent_nickname != other.agent_nickname { diffs.push("agent_nickname"); } @@ -458,6 +467,7 @@ fn canonicalize_datetime(dt: DateTime) -> DateTime { #[derive(Debug)] pub(crate) struct ThreadRow { + originator: Option, id: String, rollout_path: String, created_at: i64, @@ -496,6 +506,7 @@ pub(crate) struct ThreadRow { impl ThreadRow { pub(crate) fn try_from_row(row: &SqliteRow) -> Result { Ok(Self { + originator: row.try_get("originator")?, id: row.try_get("id")?, rollout_path: row.try_get("rollout_path")?, created_at: row.try_get("created_at")?, @@ -538,6 +549,7 @@ impl TryFrom for ThreadMetadata { fn try_from(row: ThreadRow) -> std::result::Result { let ThreadRow { + originator, id, rollout_path, created_at, @@ -595,6 +607,7 @@ impl TryFrom for ThreadMetadata { }; Ok(Self { id: ThreadId::try_from(id)?, + originator, rollout_path: PathBuf::from(rollout_path), created_at: epoch_millis_to_datetime(created_at)?, updated_at: epoch_millis_to_datetime(updated_at)?, @@ -704,6 +717,7 @@ mod tests { fn thread_row(reasoning_effort: Option<&str>) -> ThreadRow { ThreadRow { + originator: None, id: "00000000-0000-0000-0000-000000000123".to_string(), rollout_path: "/tmp/rollout-123.jsonl".to_string(), created_at: 1_700_000_000, @@ -742,6 +756,7 @@ mod tests { fn expected_thread_metadata(reasoning_effort: Option) -> ThreadMetadata { ThreadMetadata { + originator: 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 fd2119e60a..b8a94f53e7 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -177,6 +177,7 @@ SELECT threads.updated_at_ms AS updated_at, threads.recency_at_ms AS recency_at, threads.source, + threads.originator, threads.history_mode, threads.thread_source, threads.agent_path, @@ -567,6 +568,7 @@ SELECT threads.updated_at_ms AS updated_at, threads.recency_at_ms AS recency_at, threads.source, + threads.originator, 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 43858ddce4..1c530fdfda 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -45,6 +45,7 @@ pub(super) fn test_thread_metadata( ) -> ThreadMetadata { let now = DateTime::::from_timestamp(1_700_000_000, 0).expect("timestamp"); ThreadMetadata { + originator: None, id: thread_id, rollout_path: codex_home.join(format!("rollout-{thread_id}.jsonl")), created_at: now, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index f5fea4a5de..b6338706cc 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -16,6 +16,7 @@ SELECT threads.updated_at_ms AS updated_at, threads.recency_at_ms AS recency_at, threads.source, + threads.originator, threads.history_mode, threads.thread_source, threads.agent_nickname, @@ -618,6 +619,7 @@ INSERT INTO threads ( updated_at_ms, recency_at_ms, source, + originator, history_mode, thread_source, agent_nickname, @@ -645,7 +647,7 @@ INSERT INTO threads ( git_origin_url, memory_mode, project_id -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -658,6 +660,7 @@ ON CONFLICT(id) DO NOTHING .bind(datetime_to_epoch_millis(updated_at)) .bind(datetime_to_epoch_millis(recency_at)) .bind(metadata.source.as_str()) + .bind(metadata.originator.as_deref()) .bind(metadata.history_mode.as_str()) .bind( metadata @@ -895,6 +898,7 @@ INSERT INTO threads ( updated_at_ms, recency_at_ms, source, + originator, history_mode, thread_source, agent_nickname, @@ -922,7 +926,7 @@ INSERT INTO threads ( git_origin_url, memory_mode, project_id -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, @@ -932,6 +936,7 @@ ON CONFLICT(id) DO UPDATE SET updated_at_ms = excluded.updated_at_ms, recency_at_ms = threads.recency_at_ms, source = excluded.source, + originator = COALESCE(threads.originator, excluded.originator), -- 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 @@ -968,6 +973,7 @@ ON CONFLICT(id) DO UPDATE SET .bind(datetime_to_epoch_millis(updated_at)) .bind(datetime_to_epoch_millis(insert_recency_at)) .bind(metadata.source.as_str()) + .bind(metadata.originator.as_deref()) .bind(metadata.history_mode.as_str()) .bind( metadata @@ -1273,6 +1279,7 @@ SELECT threads.updated_at_ms AS updated_at, threads.recency_at_ms AS recency_at, threads.source, + threads.originator, threads.history_mode, threads.thread_source, threads.agent_nickname, @@ -2695,7 +2702,7 @@ mod tests { } #[tokio::test] - async fn upsert_thread_preserves_existing_git_fields_atomically() { + async fn upsert_thread_preserves_existing_git_and_originator_atomically() { let codex_home = unique_temp_dir(); let runtime = StateRuntime::init( crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()), @@ -2719,6 +2726,7 @@ mod tests { .expect("initial upsert should succeed"); let mut rollout_metadata = metadata.clone(); + rollout_metadata.originator = Some("recorded_client".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( @@ -2736,12 +2744,27 @@ mod tests { .await .expect("thread should load") .expect("thread should exist"); + assert_eq!(persisted.originator.as_deref(), Some("recorded_client")); assert_eq!(persisted.git_sha.as_deref(), Some("sqlite-sha")); assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch")); assert_eq!( persisted.git_origin_url.as_deref(), Some("git@example.com:openai/codex.git") ); + + for incoming_originator in [None, Some("resume_client")] { + rollout_metadata.originator = incoming_originator.map(str::to_owned); + runtime + .upsert_thread(&rollout_metadata) + .await + .expect("later upsert should succeed"); + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.originator.as_deref(), Some("recorded_client")); + } } #[tokio::test] diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 9ea7e2bdef..7289cc28fe 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -1033,6 +1033,7 @@ fn stored_thread_from_state( }); Ok(StoredThread { + originator: (!created.originator.is_empty()).then(|| created.originator.clone()), thread_id, extra_config: created.extra_config.clone(), rollout_path: metadata diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index 000c01d1aa..f15081a433 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -176,6 +176,7 @@ pub(super) fn stored_thread_from_rollout_item( project_id: item.project_id, cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), + originator: item.originator, source, history_mode: item.history_mode, thread_source: None, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 02428a1df2..45d0e39580 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -349,6 +349,11 @@ pub(super) async fn stored_thread_from_sqlite_metadata( .unwrap_or(metadata.history_mode); let name = thread_name_from_metadata(store, &metadata, history_mode).await; let mut thread = stored_thread_from_state_metadata(store, metadata, parent_thread_id); + thread.originator = thread.originator.or_else(|| { + session_meta + .map(|meta| meta.originator) + .filter(|originator| !originator.is_empty()) + }); thread.forked_from_id = forked_from_id; thread.history_mode = history_mode; thread.name = name; @@ -397,6 +402,7 @@ pub(super) fn stored_thread_from_state_metadata( project_id: metadata.project_id, cwd: metadata.cwd, cli_version: metadata.cli_version, + originator: metadata.originator, source: parse_session_source(&metadata.source), history_mode: metadata.history_mode, thread_source: metadata.thread_source, @@ -496,6 +502,7 @@ fn stored_thread_from_meta_line( project_id: None, cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, + originator: (!meta_line.meta.originator.is_empty()).then_some(meta_line.meta.originator), source: meta_line.meta.source, history_mode: meta_line.meta.history_mode, thread_source: meta_line.meta.thread_source, @@ -916,6 +923,7 @@ mod tests { thread.first_user_message.as_deref(), thread.model_provider.as_str(), thread.model.as_deref(), + thread.originator.as_deref(), ), ( Some("Canonical SQLite name"), @@ -923,6 +931,7 @@ mod tests { Some("Canonical SQLite first user message"), "Canonical SQLite provider", Some("Canonical SQLite model"), + Some("test_originator"), ) ); } @@ -1193,7 +1202,7 @@ mod tests { std::fs::create_dir_all(&day_dir).expect("sessions dir"); let rollout_path = day_dir.join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl")); let mut file = std::fs::File::create(&rollout_path).expect("session file"); - let meta = serde_json::json!({ + let mut meta = serde_json::json!({ "timestamp": "2025-01-03T12-00-00", "type": "session_meta", "payload": { @@ -1248,9 +1257,28 @@ mod tests { assert_eq!(thread.model_provider, "sqlite-provider"); assert_eq!(thread.cwd, home.path().join("workspace")); assert_eq!(thread.cli_version, "sqlite-cli"); + assert_eq!(thread.originator.as_deref(), Some("test_originator")); let history = thread.history.expect("history should load"); assert_eq!(history.thread_id, thread_id); assert_eq!(history.items.len(), 1); + + for (sqlite_originator, rollout_originator, expected_originator) in [ + ( + Some("saved_client"), + "test_originator", + Some("saved_client"), + ), + (None, "", None), + ] { + meta["payload"]["originator"] = serde_json::json!(rollout_originator); + std::fs::write(&metadata.rollout_path, format!("{meta}\n")) + .expect("write session meta"); + metadata.originator = sqlite_originator.map(str::to_owned); + let thread = stored_thread_from_sqlite_metadata(&store, metadata.clone()) + .await + .expect("read SQLite and session metadata"); + assert_eq!(thread.originator.as_deref(), expected_originator); + } } #[tokio::test] 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 3ec6f4d680..7f6df3b40c 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -406,6 +406,7 @@ async fn apply_metadata_update( if let Some(source) = patch.source { metadata.source = enum_to_string(&source); } + metadata.originator = metadata.originator.or(patch.originator); if let Some(thread_source) = patch.thread_source { metadata.thread_source = thread_source; } @@ -672,6 +673,7 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { || patch.reasoning_effort.is_some() || patch.created_at.is_some() || patch.source.is_some() + || patch.originator.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 4e28993645..0ea33257c0 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -67,6 +67,7 @@ impl ThreadMetadataSync { created_at: Some(created_at), updated_at: Some(created_at), source: Some(params.source.clone()), + originator: (!params.originator.is_empty()).then(|| params.originator.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 +229,9 @@ impl ThreadMetadataSync { 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.source = Some(meta_line.meta.source.clone()); + if !meta_line.meta.originator.is_empty() { + update.originator = Some(meta_line.meta.originator.clone()); + } update.thread_source = Some(meta_line.meta.thread_source.clone()); update.agent_nickname = Some(meta_line.meta.agent_nickname.clone()); update.agent_role = Some(meta_line.meta.agent_role.clone()); @@ -381,6 +385,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.created_at.is_some() || update.advance_recency_at.is_some() || update.source.is_some() + || update.originator.is_some() || update.thread_source.is_some() || update.agent_nickname.is_some() || update.agent_role.is_some() @@ -436,7 +441,7 @@ mod tests { use crate::ThreadPersistenceMetadata; #[tokio::test] - async fn create_without_project_omits_project_patch() { + async fn create_metadata_records_originator_without_project() { let thread_id = ThreadId::new(); let sync = ThreadMetadataSync::for_create(&CreateThreadParams { session_id: thread_id.into(), @@ -464,7 +469,10 @@ mod tests { .await; let update = sync.take_pending_update().expect("pending metadata update"); - assert_eq!(update.patch.project_id, None); + assert_eq!( + (update.patch.project_id, update.patch.originator.as_deref()), + (None, Some("test_originator")), + ); } #[test] @@ -488,6 +496,7 @@ mod tests { "2025-01-03T12:00:00+00:00" ); assert_eq!(update.patch.preview.as_deref(), Some("hello metadata")); + assert_eq!(update.patch.originator.as_deref(), Some("test_originator")); assert_eq!(update.patch.title.as_deref(), Some("hello metadata")); assert_eq!( update.patch.first_user_message.as_deref(), @@ -817,6 +826,7 @@ mod tests { id: thread_id, timestamp: "2025-01-03T12:00:00Z".to_string(), source: SessionSource::Exec, + originator: "test_originator".to_string(), ..Default::default() }, git: None, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 5cedf0be09..be6b8db441 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -569,6 +569,9 @@ pub struct ThreadOccurrenceSearchPage { /// Store-owned thread metadata used by list/read/resume responses. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StoredThread { + /// Originator recorded at creation, if available from the backing store. + #[serde(default)] + pub originator: Option, /// Thread id. pub thread_id: ThreadId, /// Optional extra configuration fields for the thread. @@ -687,7 +690,7 @@ impl GitInfoPatch { /// Patch for thread metadata. /// -/// Every field is literal: `None` leaves that field unchanged, while `Some` +/// Unless noted otherwise, `None` leaves a field unchanged, while `Some` /// applies the supplied value. Fields whose value may itself be cleared use an /// inner `Option`, where `Some(None)` clears the field. #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -724,6 +727,8 @@ pub struct ThreadMetadataPatch { pub advance_recency_at: Option>, /// Session source. pub source: Option, + /// Recorded creation-time originator. Only fills a missing stored value. + pub originator: Option, /// Optional analytics source classification. #[serde( default, @@ -817,6 +822,9 @@ impl ThreadMetadataPatch { if next.source.is_some() { self.source = next.source; } + if next.originator.is_some() { + self.originator = next.originator; + } if next.thread_source.is_some() { self.thread_source = next.thread_source; } @@ -872,6 +880,7 @@ impl ThreadMetadataPatch { && self.updated_at.is_none() && self.advance_recency_at.is_none() && self.source.is_none() + && self.originator.is_none() && self.thread_source.is_none() && self.agent_nickname.is_none() && self.agent_role.is_none() diff --git a/codex-rs/tui/src/app/agent_picker.rs b/codex-rs/tui/src/app/agent_picker.rs index 52ed4e8b8f..d9efcf140d 100644 --- a/codex-rs/tui/src/app/agent_picker.rs +++ b/codex-rs/tui/src/app/agent_picker.rs @@ -37,6 +37,7 @@ impl App { .request_typed::(ClientRequest::ThreadList { request_id: RequestId::String(Uuid::new_v4().to_string()), params: ThreadListParams { + originators: None, cursor, limit: Some(AGENT_PICKER_PAGE_SIZE), sort_key: None, diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index dc79d7e297..38559964bb 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -227,6 +227,7 @@ fn overview_thread( status: ThreadStatus, ) -> Thread { Thread { + originator: None, environments: None, id: thread_id.to_string(), extra: None, diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs index 85e37be74b..e0d3e6d087 100644 --- a/codex-rs/tui/src/app/agents_overview_threads.rs +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -184,6 +184,7 @@ impl App { .request_typed::(ClientRequest::ThreadList { request_id: RequestId::String(Uuid::new_v4().to_string()), params: ThreadListParams { + originators: None, cursor, limit: Some(20), sort_key: Some(sort_key), diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index 1a3b431497..2027af2aad 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -135,6 +135,7 @@ mod tests { fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread { Thread { + originator: None, environments: None, id: thread_id.to_string(), extra: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index bc3c4a12f1..e9568aa87f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4041,6 +4041,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re agent_thread_id, ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { + originator: None, environments: None, id: agent_thread_id.to_string(), extra: None, @@ -4143,6 +4144,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ agent_thread_id, ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { + originator: None, environments: None, id: agent_thread_id.to_string(), extra: None, @@ -4212,6 +4214,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { app.primary_session_configured = Some(primary_session); let thread = Thread { + originator: None, environments: None, id: read_thread_id.to_string(), extra: None, diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index e852a7d4aa..a92304eceb 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1769,13 +1769,14 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> { app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let mut tui = crate::tui::test_support::make_test_tui()?; - app.handle_event( + // Keep the large dispatcher future off the Windows test thread's stack. + Box::pin(app.handle_event( &mut tui, &mut app_server, AppEvent::ExportTranscript { destination: TranscriptExportDestination::File(export_path.clone()), }, - ) + )) .await?; assert!(app.chat_widget.queued_user_message_texts().is_empty()); let markdown = std::fs::read_to_string(export_path)?; @@ -1813,7 +1814,7 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> { .await? .ok_or_else(|| color_eyre::eyre::eyre!("history event channel closed"))?; if matches!(event, AppEvent::OlderThreadHistoryLoaded { .. }) { - app.handle_event(&mut tui, &mut app_server, event).await?; + Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; } } @@ -2234,7 +2235,8 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri None => panic!("scrollback refill request channel closed"), } }; - app.handle_event(&mut tui, &mut app_server, request).await?; + // Keep the large dispatcher future off the Windows test thread's stack. + Box::pin(app.handle_event(&mut tui, &mut app_server, request)).await?; let loaded = loop { match app_event_rx.recv().await { Some(event @ AppEvent::OlderThreadHistoryLoaded { .. }) => break event, @@ -2242,7 +2244,7 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri None => panic!("older history page channel closed"), } }; - app.handle_event(&mut tui, &mut app_server, loaded).await?; + Box::pin(app.handle_event(&mut tui, &mut app_server, loaded)).await?; assert!(app.overlay.is_none()); assert!(app.transcript_cells.len() > initial_cell_count); diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index d27bccc735..ac42050976 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -933,6 +933,7 @@ async fn known_thread_started_preserves_session_without_reading_unmaterialized_r ); let notification = ThreadStartedNotification { thread: Thread { + originator: None, environments: None, id: thread_id.to_string(), extra: None, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index ba09d1a154..db14af47a6 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -410,6 +410,7 @@ mod tests { ..test_thread_session(primary_thread_id, test_path_buf("/tmp/primary")) }; let read_thread = Thread { + originator: None, environments: None, id: read_thread_id.to_string(), extra: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 5a36fa92cd..c1b89c3a3d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -3695,6 +3695,7 @@ mod tests { let read_only_profile = PermissionProfile::read_only(); let response = ThreadResumeResponse { thread: codex_app_server_protocol::Thread { + originator: None, environments: None, id: thread_id.to_string(), extra: None, diff --git a/codex-rs/tui/src/dynamic_tools.rs b/codex-rs/tui/src/dynamic_tools.rs index f50c0d5d25..8633fdaf58 100644 --- a/codex-rs/tui/src/dynamic_tools.rs +++ b/codex-rs/tui/src/dynamic_tools.rs @@ -438,6 +438,7 @@ async fn execute_inner( request(&handle, |request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + originators: None, cursor: arguments.cursor.clone(), limit: Some(limit), sort_key: Some(ThreadSortKey::UpdatedAt), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index cb44bf73a5..3aa701b5cc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -755,6 +755,7 @@ fn latest_session_lookup_params( lookup_mode: LatestSessionLookupMode, ) -> ThreadListParams { ThreadListParams { + originators: None, cursor: None, limit: Some(1), sort_key: Some(AppServerThreadSortKey::UpdatedAt), diff --git a/codex-rs/tui/src/named_session_lookup.rs b/codex-rs/tui/src/named_session_lookup.rs index f58cc1f5d1..741c847abc 100644 --- a/codex-rs/tui/src/named_session_lookup.rs +++ b/codex-rs/tui/src/named_session_lookup.rs @@ -274,6 +274,7 @@ impl<'a> NamedSessionCandidates<'a> { }; let response = app_server .thread_list(ThreadListParams { + originators: None, cursor: self.cursor.clone(), limit: Some(100), sort_key: Some(sort_key), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 752ece9265..7a2e912475 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -2003,6 +2003,7 @@ fn thread_list_params( use_state_db_only: bool, ) -> ThreadListParams { ThreadListParams { + originators: None, cursor, limit: Some(PAGE_SIZE as u32), sort_key: Some(sort_key), @@ -6266,6 +6267,7 @@ session_picker_view = "dense" fn app_server_row_keeps_pathless_threads() { let thread_id = ThreadId::new(); let thread = Thread { + originator: None, environments: None, id: thread_id.to_string(), extra: None, @@ -6311,6 +6313,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { + originator: None, environments: None, id: thread_id.to_string(), extra: None, @@ -6396,6 +6399,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { + originator: None, environments: None, id: thread_id.to_string(), extra: None, @@ -6472,6 +6476,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { + originator: None, environments: None, id: thread_id.to_string(), extra: None, diff --git a/codex-rs/tui/src/task_mentions.rs b/codex-rs/tui/src/task_mentions.rs index b3bc8ffb38..5c1f7900a9 100644 --- a/codex-rs/tui/src/task_mentions.rs +++ b/codex-rs/tui/src/task_mentions.rs @@ -90,6 +90,7 @@ pub(crate) fn spawn_search( "task-title-search-{request_generation}" )), params: ThreadListParams { + originators: None, cursor: None, limit: Some(MAX_SEARCH_RESULTS), sort_key: Some(ThreadSortKey::UpdatedAt),