mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
Scope local history discovery to the current ChatGPT account (#58918)
## What changed - Add `currentAccountOnly` to `thread/list` and `thread/search`, defaulting to `false`, and enable it for TUI history discovery, resume pickers, and task mentions. - Mark newly created threads as account-scoped and require each recorded creator user or account ID to match the current login. Older threads and threads without creator IDs remain shared; API-key logins continue to see all local history. - Preserve account scope through resume and revert, including fallback to original rollout metadata when an older binary drops the marker. Forks receive new-thread scope. - Skip unreadable rollout headers in scoped discovery and filter search candidates before reading snippets. Direct access by thread ID remains available. ## Testing Add coverage for account matching, shared legacy history, list page refilling, search pagination, API-key visibility, and scope preservation across reverts. Extend rollout lookup coverage to compressed active and archived files. GitOrigin-RevId: aa374f5e69165be5a71c0716ded1b105f5fb51e0
This commit is contained in:
@@ -5048,6 +5048,10 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"currentAccountOnly": {
|
||||
"description": "Show local history visible to the current ChatGPT account. Defaults to false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"cursor": {
|
||||
"description": "Opaque pagination cursor returned by a previous call.",
|
||||
"type": [
|
||||
|
||||
+4
@@ -24345,6 +24345,10 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"currentAccountOnly": {
|
||||
"description": "Show local history visible to the current ChatGPT account. Defaults to false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"cursor": {
|
||||
"description": "Opaque pagination cursor returned by a previous call.",
|
||||
"type": [
|
||||
|
||||
+4
@@ -22048,6 +22048,10 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"currentAccountOnly": {
|
||||
"description": "Show local history visible to the current ChatGPT account. Defaults to false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"cursor": {
|
||||
"description": "Opaque pagination cursor returned by a previous call.",
|
||||
"type": [
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"currentAccountOnly": {
|
||||
"description": "Show local history visible to the current ChatGPT account. Defaults to false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"cursor": {
|
||||
"description": "Opaque pagination cursor returned by a previous call.",
|
||||
"type": [
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -6,6 +6,9 @@ import type { ThreadSortKey } from "./ThreadSortKey";
|
||||
import type { ThreadSourceKind } from "./ThreadSourceKind";
|
||||
|
||||
export type ThreadListParams = {/**
|
||||
* Show local history visible to the current ChatGPT account. Defaults to false.
|
||||
*/
|
||||
currentAccountOnly?: boolean, /**
|
||||
* Opaque pagination cursor returned by a previous call.
|
||||
*/
|
||||
cursor?: string | null, /**
|
||||
|
||||
@@ -1427,6 +1427,9 @@ pub struct ThreadSectionDeleteResponse {}
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadListParams {
|
||||
/// Show local history visible to the current ChatGPT account. Defaults to false.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub current_account_only: bool,
|
||||
/// Opaque pagination cursor returned by a previous call.
|
||||
#[ts(optional = nullable)]
|
||||
pub cursor: Option<String>,
|
||||
@@ -1515,6 +1518,9 @@ pub struct ThreadListParams {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadSearchParams {
|
||||
/// Show local history visible to the current ChatGPT account. Defaults to false.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub current_account_only: bool,
|
||||
/// Opaque pagination cursor returned by a previous call.
|
||||
#[ts(optional = nullable)]
|
||||
pub cursor: Option<String>,
|
||||
|
||||
@@ -1167,6 +1167,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let response = client.thread_list(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -23,6 +23,16 @@ resolved memory version, including thread-specific configuration overrides.
|
||||
It is `null` when memory use is disabled or the selected summary is missing,
|
||||
unreadable, or blank. It never falls back to the other memory version.
|
||||
|
||||
# Local history by account
|
||||
|
||||
Pass `currentAccountOnly: true` on `thread/list` or `thread/search` to scope local
|
||||
discovery to the current ChatGPT login. Newly created threads record that discovery
|
||||
is account-scoped, and each recorded creator user or account ID must match.
|
||||
Threads created before this change and threads without creator IDs stay visible
|
||||
across account switches. Resume and revert preserve that distinction; forks are new threads.
|
||||
Scoped requests skip candidates whose rollout header cannot be read. API-key
|
||||
logins see all local history. Direct access by thread ID remains available.
|
||||
|
||||
# Thread list exclusions
|
||||
|
||||
`thread/list` accepts `excludedThreadIds`, an optional array of up to 100
|
||||
|
||||
@@ -46,6 +46,7 @@ use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::error::CodexErrorDetails;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_thread_store::LocalHistoryAccount;
|
||||
use codex_thread_store::PersistContext;
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
@@ -91,6 +92,7 @@ async fn remove_pending_thread_metadata(
|
||||
}
|
||||
|
||||
struct ThreadListFilters {
|
||||
account: Option<LocalHistoryAccount>,
|
||||
model_providers: Option<Vec<String>>,
|
||||
source_kinds: Option<Vec<ThreadSourceKind>>,
|
||||
archived: bool,
|
||||
@@ -2739,6 +2741,7 @@ impl ThreadRequestProcessor {
|
||||
params: ThreadListParams,
|
||||
) -> Result<ThreadListResponse, JSONRPCErrorError> {
|
||||
let ThreadListParams {
|
||||
current_account_only,
|
||||
cursor,
|
||||
limit,
|
||||
excluded_thread_ids,
|
||||
@@ -2860,6 +2863,7 @@ impl ThreadRequestProcessor {
|
||||
store_sort_key,
|
||||
sort_direction,
|
||||
ThreadListFilters {
|
||||
account: self.local_history_account(current_account_only),
|
||||
model_providers,
|
||||
source_kinds,
|
||||
archived: archived.unwrap_or(false),
|
||||
@@ -2909,6 +2913,7 @@ impl ThreadRequestProcessor {
|
||||
params: ThreadSearchParams,
|
||||
) -> Result<ThreadSearchResponse, JSONRPCErrorError> {
|
||||
let ThreadSearchParams {
|
||||
current_account_only,
|
||||
cursor,
|
||||
limit,
|
||||
sort_key,
|
||||
@@ -2935,6 +2940,7 @@ impl ThreadRequestProcessor {
|
||||
let mut cursor_obj = cursor;
|
||||
let mut last_cursor = cursor_obj.clone();
|
||||
let mut remaining = requested_page_size;
|
||||
let account = self.local_history_account(current_account_only);
|
||||
let mut search_results = Vec::with_capacity(requested_page_size);
|
||||
let mut next_cursor = None;
|
||||
|
||||
@@ -2942,6 +2948,7 @@ impl ThreadRequestProcessor {
|
||||
let page = self
|
||||
.thread_store
|
||||
.search_threads(StoreSearchThreadsParams {
|
||||
account: account.clone(),
|
||||
page_size: remaining.min(THREAD_LIST_MAX_LIMIT),
|
||||
cursor: cursor_obj.clone(),
|
||||
sort_key: store_sort_key,
|
||||
@@ -5996,6 +6003,22 @@ impl ThreadRequestProcessor {
|
||||
Ok(GetConversationSummaryResponse { summary })
|
||||
}
|
||||
|
||||
/// Local discovery matches each recorded creator ID to the current login. Threads with
|
||||
/// no creator IDs or predating enforcement are shared; API-key logins see all local history.
|
||||
fn local_history_account(&self, current_account_only: bool) -> Option<LocalHistoryAccount> {
|
||||
if !current_account_only || !self.thread_store.as_any().is::<LocalThreadStore>() {
|
||||
return None;
|
||||
}
|
||||
let auth = self.auth_manager.auth_cached();
|
||||
if auth.as_ref().is_some_and(CodexAuth::is_api_key_auth) {
|
||||
return None;
|
||||
}
|
||||
Some(LocalHistoryAccount {
|
||||
user_id: auth.as_ref().and_then(CodexAuth::get_chatgpt_user_id),
|
||||
account_id: auth.as_ref().and_then(CodexAuth::get_account_id),
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_threads_common(
|
||||
&self,
|
||||
requested_page_size: usize,
|
||||
@@ -6005,6 +6028,7 @@ impl ThreadRequestProcessor {
|
||||
filters: ThreadListFilters,
|
||||
) -> Result<(Vec<StoredThread>, Option<String>), JSONRPCErrorError> {
|
||||
let ThreadListFilters {
|
||||
account,
|
||||
model_providers,
|
||||
source_kinds,
|
||||
archived,
|
||||
@@ -6067,8 +6091,23 @@ impl ThreadRequestProcessor {
|
||||
.await
|
||||
.map_err(thread_store_list_error)?;
|
||||
|
||||
let page_thread_ids: Vec<_> = page.items.iter().map(|item| item.thread_id).collect();
|
||||
let mut original_paths = None;
|
||||
let mut filtered = Vec::with_capacity(page.items.len());
|
||||
for it in page.items {
|
||||
if let Some(account) = &account
|
||||
&& !account
|
||||
.includes(
|
||||
&self.config.codex_home,
|
||||
it.thread_id,
|
||||
it.rollout_path.as_deref(),
|
||||
&page_thread_ids,
|
||||
&mut original_paths,
|
||||
)
|
||||
.await
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let source = with_thread_spawn_agent_metadata(
|
||||
it.source.clone(),
|
||||
it.agent_nickname.clone(),
|
||||
|
||||
@@ -254,6 +254,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id(
|
||||
|
||||
// Build JSONL lines
|
||||
let meta = SessionMeta {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
@@ -351,6 +352,7 @@ pub fn create_fake_rollout_with_text_elements(
|
||||
|
||||
// Build JSONL lines
|
||||
let meta = SessionMeta {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -610,6 +610,7 @@ source = {:?}
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -2066,6 +2067,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -2259,6 +2261,7 @@ required = true
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -2347,6 +2350,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -2432,6 +2436,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions()
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -2563,6 +2568,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -2682,6 +2688,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -100,6 +100,7 @@ async fn projects_list_by_recency_and_preserve_metadata_timestamps() -> Result<(
|
||||
.request(|request_id| ClientRequest::ThreadList {
|
||||
request_id,
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -326,6 +327,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> {
|
||||
.request(|request_id| ClientRequest::ThreadList {
|
||||
request_id,
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -375,6 +377,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> {
|
||||
.request(|request_id| ClientRequest::ThreadList {
|
||||
request_id,
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -427,6 +430,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> {
|
||||
.request(|request_id| ClientRequest::ThreadList {
|
||||
request_id,
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -624,6 +628,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> {
|
||||
.request(|request_id| ClientRequest::ThreadList {
|
||||
request_id,
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -717,6 +722,7 @@ async fn deleted_project_is_dropped_before_first_durable_thread_persistence() ->
|
||||
.request(|request_id| ClientRequest::ThreadList {
|
||||
request_id,
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -909,6 +915,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -997,6 +1004,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -254,6 +254,7 @@ async fn thread_list_repeated_cursor_returns_internal_error() -> Result<()> {
|
||||
.request(ClientRequest::ThreadList {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -313,6 +314,7 @@ async fn thread_list_repeated_cursor_returns_internal_error() -> Result<()> {
|
||||
.request(ClientRequest::ThreadList {
|
||||
request_id: RequestId::Integer(2),
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -496,6 +498,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -159,6 +159,7 @@ async fn state_recovery_restores_saved_threads() -> Result<()> {
|
||||
.await?;
|
||||
let request = server
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -116,6 +116,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
|
||||
async fn list_threads(mcp: &mut TestAppServer) -> Result<ThreadListResponse> {
|
||||
let list_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -238,6 +239,20 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
let thread_path = thread.path.clone().expect("thread path");
|
||||
assert!(thread_path.as_path().is_absolute());
|
||||
assert_ne!(thread_path.as_path(), original_path);
|
||||
assert_eq!(
|
||||
(
|
||||
read_session_meta_line(&original_path)
|
||||
.await?
|
||||
.meta
|
||||
.account_scoped,
|
||||
read_session_meta_line(thread_path.as_path())
|
||||
.await?
|
||||
.meta
|
||||
.account_scoped,
|
||||
),
|
||||
(false, true),
|
||||
"forking legacy history creates a newly account-scoped thread"
|
||||
);
|
||||
assert!(thread.cwd.as_path().is_absolute());
|
||||
assert_eq!(thread.source, SessionSource::VsCode);
|
||||
assert_eq!(thread.thread_source, Some(ThreadSource::User));
|
||||
|
||||
@@ -102,6 +102,7 @@ async fn list_threads_with_sort(
|
||||
mcp.request(|request_id| ClientRequest::ThreadList {
|
||||
request_id,
|
||||
params: codex_app_server_protocol::ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -144,6 +145,7 @@ async fn list_threads_for_relation(
|
||||
mcp.request(|request_id| ClientRequest::ThreadList {
|
||||
request_id,
|
||||
params: codex_app_server_protocol::ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -777,6 +779,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -893,6 +896,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -964,6 +968,7 @@ async fn thread_search_returns_content_matches() -> Result<()> {
|
||||
let mut mcp = init_mcp(codex_home.path()).await?;
|
||||
let request_id = mcp
|
||||
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
|
||||
current_account_only: false,
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
@@ -1019,6 +1024,7 @@ async fn thread_search_returns_content_matches() -> Result<()> {
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
|
||||
current_account_only: false,
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
@@ -1081,6 +1087,7 @@ async fn thread_search_matches_json_escaped_content() -> Result<()> {
|
||||
let mut mcp = init_mcp(codex_home.path()).await?;
|
||||
let request_id = mcp
|
||||
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
|
||||
current_account_only: false,
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
@@ -1126,6 +1133,7 @@ async fn thread_search_filters_by_source_kind() -> Result<()> {
|
||||
let mut mcp = init_mcp(codex_home.path()).await?;
|
||||
let request_id = mcp
|
||||
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
|
||||
current_account_only: false,
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
@@ -1300,6 +1308,7 @@ sqlite = true
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -1339,6 +1348,7 @@ sqlite = true
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -1371,6 +1381,7 @@ sqlite = true
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -1559,6 +1570,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -1588,6 +1600,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -1828,6 +1841,7 @@ async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
|
||||
current_account_only: false,
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
@@ -1881,6 +1895,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -2705,6 +2720,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -2749,6 +2765,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -3183,6 +3200,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -3212,3 +3230,116 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_account_history_refills_list_and_search() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
create_minimal_config(home.path())?;
|
||||
app_test_support::write_chatgpt_auth(
|
||||
home.path(),
|
||||
app_test_support::ChatGptAuthFixture::new("token")
|
||||
.chatgpt_user_id("me")
|
||||
.chatgpt_account_id("team")
|
||||
.account_id("team"),
|
||||
codex_config::types::AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
let (stamp, timestamp) = timestamp_at(
|
||||
/*year*/ 2025, /*month*/ 1, /*day*/ 5, /*hour*/ 12, /*minute*/ 0,
|
||||
/*second*/ 0,
|
||||
);
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..6 {
|
||||
ids.push(app_test_support::create_fake_paginated_rollout(
|
||||
home.path(),
|
||||
&stamp,
|
||||
×tamp,
|
||||
"fixture-preview",
|
||||
Some("openai"),
|
||||
/*git_info*/ None,
|
||||
)?);
|
||||
}
|
||||
// With tied creation times, the largest ID is first. Give it to the other user.
|
||||
ids.sort_unstable_by(|a, b| b.cmp(a));
|
||||
for (id, (user, account, preview, scoped)) in ids.iter().zip([
|
||||
(Some("other-needle-user"), Some("team"), "needle", true),
|
||||
(Some("me"), Some("team"), "needle", true),
|
||||
(Some("me"), None, "partial", true),
|
||||
(None, None, "shared", true),
|
||||
(Some("legacy-user"), Some("legacy-team"), "needle", false),
|
||||
(Some("me"), Some("other-team"), "other workspace", true),
|
||||
]) {
|
||||
let path = rollout_path(home.path(), &stamp, id);
|
||||
let text = fs::read_to_string(&path)?;
|
||||
let (header, rest) = text.split_once('\n').expect("session header");
|
||||
let mut header: serde_json::Value = serde_json::from_str(header)?;
|
||||
header["payload"]["creator_user_id"] = json!(user);
|
||||
header["payload"]["creator_account_id"] = json!(account);
|
||||
// Attribution predates enforcement: an absent marker keeps old history shared.
|
||||
if scoped {
|
||||
header["payload"]["account_scoped"] = json!(true);
|
||||
}
|
||||
let rest = rest.replace("fixture-preview", preview);
|
||||
fs::write(path, format!("{header}\n{rest}"))?;
|
||||
}
|
||||
|
||||
let mut server = init_mcp(home.path()).await?;
|
||||
for (limit, db_only) in [(4, false), (4, true), (1, false), (1, true)] {
|
||||
let params = json!({
|
||||
"currentAccountOnly": true,
|
||||
"sortKey": "created_at",
|
||||
"limit": limit,
|
||||
"useStateDbOnly": db_only,
|
||||
});
|
||||
let request = server.send_raw_request("thread/list", Some(params)).await?;
|
||||
let list: ThreadListResponse = server.read_response(request).await?;
|
||||
assert_eq!(
|
||||
list.data.into_iter().map(|t| t.id).collect::<Vec<_>>(),
|
||||
ids[1..=limit]
|
||||
);
|
||||
}
|
||||
|
||||
// The foreign header matches the raw search, but reading its snippet would hit invalid UTF-8.
|
||||
let foreign = rollout_path(home.path(), &stamp, &ids[0]);
|
||||
let intact = fs::read_to_string(&foreign)?;
|
||||
let (header, rest) = intact.split_once('\n').expect("session header");
|
||||
fs::write(
|
||||
&foreign,
|
||||
[header.as_bytes(), b"\n\xff\n", rest.as_bytes()].concat(),
|
||||
)?;
|
||||
let mut cursor = None;
|
||||
for expected_id in [&ids[1], &ids[4]] {
|
||||
let params = json!({
|
||||
"currentAccountOnly": true,
|
||||
"sortKey": "created_at",
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"searchTerm": "needle",
|
||||
});
|
||||
let request = server
|
||||
.send_raw_request("thread/search", Some(params))
|
||||
.await?;
|
||||
let search: ThreadSearchResponse = server.read_response(request).await?;
|
||||
assert_eq!(
|
||||
search
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|t| t.thread.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![expected_id.clone()]
|
||||
);
|
||||
cursor = search.next_cursor;
|
||||
}
|
||||
assert_eq!(cursor, None);
|
||||
fs::write(foreign, intact)?;
|
||||
|
||||
let request = server
|
||||
.send_login_account_api_key_request("sk-test-key")
|
||||
.await?;
|
||||
let _: codex_app_server_protocol::LoginAccountResponse = server.read_response(request).await?;
|
||||
let request = server
|
||||
.send_raw_request("thread/list", Some(json!({"currentAccountOnly": true})))
|
||||
.await?;
|
||||
let list: ThreadListResponse = server.read_response(request).await?;
|
||||
assert_eq!(list.data.len(), 6);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ async fn thread_section_move_pins_and_archives_before_first_turn() -> Result<()>
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(move_id)).await??;
|
||||
|
||||
let list_params = ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -294,6 +295,7 @@ async fn thread_section_move_pins_and_unpins_with_filtered_recency_pagination()
|
||||
}
|
||||
|
||||
let list_params = ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -488,6 +490,7 @@ async fn thread_sections_preserve_server_owned_manual_order_across_moves_and_res
|
||||
}
|
||||
|
||||
let list_params = ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -411,6 +411,7 @@ async fn paginated_stored_thread_routes_projected_turns() -> Result<()> {
|
||||
|
||||
let list_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -1153,6 +1154,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()>
|
||||
.request(ClientRequest::ThreadList {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -1536,6 +1538,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -259,6 +259,14 @@ async fn thread_resume_paginated_model_context_preserves_original_metadata() ->
|
||||
.await??;
|
||||
timeout(DEFAULT_READ_TIMEOUT, primary.shutdown_gracefully()).await??;
|
||||
|
||||
assert!(
|
||||
!codex_rollout::read_session_meta_line(&path)
|
||||
.await?
|
||||
.meta
|
||||
.account_scoped,
|
||||
"resuming legacy history must keep its cross-account visibility"
|
||||
);
|
||||
|
||||
let mut secondary = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
@@ -4793,6 +4801,7 @@ 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 {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -4723,6 +4723,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 {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -472,6 +472,7 @@ async fn remote_session_commands_with_workload_identity_use_server_auth() -> Res
|
||||
.request_typed(ClientRequest::ThreadList {
|
||||
request_id: RequestId::Integer(4),
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
cursor: None,
|
||||
|
||||
@@ -557,6 +557,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
|
||||
fs::create_dir_all(parent).expect("should create rollout directory");
|
||||
let session_meta_line = SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -1929,6 +1929,7 @@ async fn resolve_resume_thread_id(
|
||||
ClientRequest::ThreadList {
|
||||
request_id: RequestId::Integer(0),
|
||||
params: ThreadListParams {
|
||||
current_account_only: true,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
@@ -2017,6 +2018,7 @@ async fn resolve_resume_thread_id(
|
||||
ClientRequest::ThreadList {
|
||||
request_id: RequestId::Integer(0),
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -3416,6 +3416,9 @@ pub struct HistoryPosition {
|
||||
/// and should be used when there is no config override.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]
|
||||
pub struct SessionMeta {
|
||||
/// Scope discovery to the creator. Absent on threads created before account filtering.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub account_scoped: bool,
|
||||
/// 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<String>,
|
||||
@@ -3499,6 +3502,7 @@ impl Default for SessionMeta {
|
||||
fn default() -> Self {
|
||||
let id = ThreadId::default();
|
||||
SessionMeta {
|
||||
account_scoped: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
session_id: id.into(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::fs::FileTimes;
|
||||
#[cfg(unix)]
|
||||
@@ -1070,13 +1071,31 @@ async fn find_thread_path_by_id_handles_compressed_rollout_filenames() -> anyhow
|
||||
);
|
||||
assert_eq!(
|
||||
crate::find_rollout_path_by_rollout_id(home.path(), thread_id).await?,
|
||||
Some(rollout_path)
|
||||
Some(rollout_path.clone())
|
||||
);
|
||||
let missing_rollout_id = ThreadId::from_string(&Uuid::from_u128(2000).to_string())?;
|
||||
assert_eq!(
|
||||
crate::find_rollout_path_by_rollout_id(home.path(), missing_rollout_id).await?,
|
||||
None
|
||||
);
|
||||
let archived_id = ThreadId::new();
|
||||
let archived_path = home
|
||||
.path()
|
||||
.join(crate::ARCHIVED_SESSIONS_SUBDIR)
|
||||
.join(format!("rollout-2025-01-03T12-00-00-{archived_id}.jsonl"));
|
||||
write_rollout(&archived_path, archived_id, "archived original")?;
|
||||
compress_now(&archived_path)?;
|
||||
assert_eq!(
|
||||
crate::find_rollout_paths_by_rollout_ids(
|
||||
home.path(),
|
||||
&[thread_id, archived_id, missing_rollout_id],
|
||||
)
|
||||
.await,
|
||||
HashMap::from([
|
||||
(thread_id, rollout_path),
|
||||
(archived_id, compressed_rollout_path(&archived_path)),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
crate::find_thread_path_by_id_str(home.path(), "not-a-uuid", /*state_db_ctx*/ None).await?,
|
||||
None
|
||||
@@ -1121,6 +1140,7 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) ->
|
||||
fs::create_dir_all(parent)?;
|
||||
let session_meta_line = SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -123,6 +123,7 @@ pub use list::ThreadSortKey;
|
||||
pub use list::ThreadsPage;
|
||||
pub use list::find_archived_thread_path_by_id_str;
|
||||
pub use list::find_rollout_path_by_rollout_id;
|
||||
pub use list::find_rollout_paths_by_rollout_ids;
|
||||
pub use list::find_thread_path_by_id_str;
|
||||
#[deprecated(note = "use find_thread_path_by_id_str")]
|
||||
pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str;
|
||||
|
||||
@@ -1579,6 +1579,41 @@ pub async fn find_rollout_path_by_rollout_id(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Locate a page's rollout IDs with one filename traversal per storage root.
|
||||
///
|
||||
/// Keeps the first match, searching active storage before archives. On a read error, stops
|
||||
/// scanning and retains earlier matches, just as individual lookups would have found them.
|
||||
pub async fn find_rollout_paths_by_rollout_ids(
|
||||
codex_home: &Path,
|
||||
rollout_ids: &[RolloutId],
|
||||
) -> HashMap<RolloutId, PathBuf> {
|
||||
let ids: std::collections::HashSet<_> = rollout_ids.iter().copied().collect();
|
||||
let mut paths = HashMap::new();
|
||||
for subdir in [SESSIONS_SUBDIR, ARCHIVED_SESSIONS_SUBDIR] {
|
||||
if paths.len() == ids.len() {
|
||||
break;
|
||||
}
|
||||
if visit_rollout_filenames(
|
||||
&codex_home.join(subdir),
|
||||
|file_name| ids.contains(&file_name.rollout_id()),
|
||||
|file_name, path| {
|
||||
paths.entry(file_name.rollout_id()).or_insert(path);
|
||||
if paths.len() == ids.len() {
|
||||
ControlFlow::Break(())
|
||||
} else {
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// Prefetch a bounded set of active rollout paths for a history page.
|
||||
///
|
||||
/// The sibling directory supplies candidate IDs. They are resolved in the same traversal order as
|
||||
|
||||
@@ -117,6 +117,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() {
|
||||
.join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl"));
|
||||
|
||||
let session_meta = SessionMeta {
|
||||
account_scoped: false,
|
||||
creator_user_id: Some("creator-user".to_string()),
|
||||
attached_thread: false,
|
||||
creator_account_id: Some("creator-account".to_string()),
|
||||
@@ -242,6 +243,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() {
|
||||
.join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl"));
|
||||
|
||||
let session_meta = SessionMeta {
|
||||
account_scoped: false,
|
||||
creator_user_id: None,
|
||||
attached_thread: false,
|
||||
creator_account_id: None,
|
||||
@@ -592,6 +594,7 @@ 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 {
|
||||
account_scoped: false,
|
||||
creator_user_id: None,
|
||||
attached_thread: false,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -106,6 +106,7 @@ pub enum RolloutRecorderParams {
|
||||
source: Box<SessionSource>,
|
||||
thread_source: Option<ThreadSource>,
|
||||
originator: String,
|
||||
account_scoped: bool,
|
||||
creator_user_id: Option<String>,
|
||||
creator_account_id: Option<String>,
|
||||
base_instructions: BaseInstructions,
|
||||
@@ -227,6 +228,7 @@ impl RolloutRecorderParams {
|
||||
source: Box::new(source),
|
||||
thread_source,
|
||||
originator,
|
||||
account_scoped: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
base_instructions,
|
||||
@@ -242,13 +244,20 @@ impl RolloutRecorderParams {
|
||||
}
|
||||
|
||||
/// Record the authenticated identity at thread creation, or preserve it on revert.
|
||||
pub fn with_creator(mut self, user_id: Option<String>, account_id: Option<String>) -> Self {
|
||||
pub fn with_creator(
|
||||
mut self,
|
||||
user_id: Option<String>,
|
||||
account_id: Option<String>,
|
||||
account_scoped: bool,
|
||||
) -> Self {
|
||||
if let Self::Create {
|
||||
creator_user_id,
|
||||
creator_account_id,
|
||||
account_scoped: scoped,
|
||||
..
|
||||
} = &mut self
|
||||
{
|
||||
*scoped = account_scoped;
|
||||
*creator_user_id = user_id;
|
||||
*creator_account_id = account_id;
|
||||
}
|
||||
@@ -920,6 +929,7 @@ impl RolloutRecorder {
|
||||
source,
|
||||
thread_source,
|
||||
originator,
|
||||
account_scoped,
|
||||
creator_user_id,
|
||||
creator_account_id,
|
||||
base_instructions,
|
||||
@@ -957,6 +967,7 @@ impl RolloutRecorder {
|
||||
cwd: cwd.clone(),
|
||||
runtime_workspace_roots,
|
||||
originator,
|
||||
account_scoped,
|
||||
creator_user_id,
|
||||
creator_account_id,
|
||||
cli_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
|
||||
@@ -214,6 +214,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> {
|
||||
|
||||
let session_meta_line = SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -35,6 +35,7 @@ fn write_rollout_with_source_and_provider(
|
||||
ordinal: None,
|
||||
item: RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -325,6 +325,7 @@ fn write_rollout_with_user_message(
|
||||
ordinal: None,
|
||||
item: RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -1593,6 +1593,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
|
||||
ordinal: None,
|
||||
item: RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -534,6 +534,7 @@ mod tests {
|
||||
&mut metadata,
|
||||
&RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
account_scoped: false,
|
||||
creator_user_id: None,
|
||||
attached_thread: false,
|
||||
creator_account_id: None,
|
||||
@@ -785,6 +786,7 @@ mod tests {
|
||||
&mut metadata,
|
||||
&RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
account_scoped: false,
|
||||
creator_user_id: None,
|
||||
attached_thread: false,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -2920,6 +2920,7 @@ mod tests {
|
||||
);
|
||||
let items = vec![RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
@@ -2995,6 +2996,7 @@ mod tests {
|
||||
);
|
||||
let items = vec![RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
account_scoped: false,
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
|
||||
@@ -8,6 +8,7 @@ mod error;
|
||||
mod in_memory;
|
||||
mod live_thread;
|
||||
mod local;
|
||||
mod local_history_account;
|
||||
mod projects;
|
||||
mod queue_store;
|
||||
mod store;
|
||||
@@ -42,6 +43,7 @@ pub use local::RolloutMigrationOutcome;
|
||||
pub use local::RolloutMigrationProgress;
|
||||
pub use local::RolloutMigrationReport;
|
||||
pub use local::RolloutMigrationStatus;
|
||||
pub use local_history_account::LocalHistoryAccount;
|
||||
pub use projects::CreateProjectParams;
|
||||
pub use projects::CreatedProject;
|
||||
pub use projects::DeletedProject;
|
||||
|
||||
@@ -39,7 +39,11 @@ pub(super) async fn create_thread(
|
||||
params.base_instructions,
|
||||
params.dynamic_tools,
|
||||
)
|
||||
.with_creator(params.creator_user_id, params.creator_account_id)
|
||||
.with_creator(
|
||||
params.creator_user_id,
|
||||
params.creator_account_id,
|
||||
/*account_scoped*/ true,
|
||||
)
|
||||
.with_session_id(params.session_id)
|
||||
.with_attached_thread(params.attached_thread)
|
||||
.with_selected_capability_roots(params.selected_capability_roots)
|
||||
|
||||
@@ -179,7 +179,11 @@ 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_creator(
|
||||
source_meta.creator_user_id,
|
||||
source_meta.creator_account_id,
|
||||
source_meta.account_scoped,
|
||||
)
|
||||
.with_session_id(source_meta.session_id)
|
||||
.with_attached_thread(source_meta.attached_thread)
|
||||
.with_rollout_id(rollout_id)
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::ArchiveThreadParams;
|
||||
use crate::CreateThreadParams;
|
||||
use crate::DeleteThreadParams;
|
||||
use crate::ListTurnsParams;
|
||||
use crate::LocalHistoryAccount;
|
||||
use crate::RevertThreadParams;
|
||||
use crate::SortDirection;
|
||||
use crate::StoredTurnItemsView;
|
||||
@@ -96,10 +97,11 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() {
|
||||
assert_eq!(replacement_meta.id, thread_id);
|
||||
assert_eq!(
|
||||
(
|
||||
replacement_meta.account_scoped,
|
||||
replacement_meta.creator_user_id.as_deref(),
|
||||
replacement_meta.creator_account_id.as_deref()
|
||||
),
|
||||
(Some("creator-user"), Some("creator-account")),
|
||||
(true, Some("creator-user"), Some("creator-account")),
|
||||
);
|
||||
assert_eq!(replacement_meta.memory_mode, None);
|
||||
assert_eq!(
|
||||
@@ -117,6 +119,7 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() {
|
||||
let payload = meta_line["payload"]
|
||||
.as_object_mut()
|
||||
.expect("metadata payload");
|
||||
payload.remove("account_scoped");
|
||||
payload.remove("creator_user_id");
|
||||
payload.remove("creator_account_id");
|
||||
tokio::fs::write(
|
||||
@@ -126,6 +129,25 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() {
|
||||
.await
|
||||
.expect("write legacy replacement rollout");
|
||||
|
||||
for (user, expected) in [("creator-user", true), ("other-user", false)] {
|
||||
let account = LocalHistoryAccount {
|
||||
user_id: Some(user.to_string()),
|
||||
account_id: Some("creator-account".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
account
|
||||
.includes(
|
||||
home.path(),
|
||||
thread_id,
|
||||
Some(&first_replacement_path),
|
||||
&[thread_id],
|
||||
/*original_paths*/ &mut None,
|
||||
)
|
||||
.await,
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
store
|
||||
.revert_thread(RevertThreadParams {
|
||||
thread_id,
|
||||
@@ -184,7 +206,7 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revert_preserves_resolved_multi_agent_version_in_session_metadata() {
|
||||
async fn revert_preserves_legacy_scope_and_resolved_multi_agent_version() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let state_db = codex_state::StateRuntime::init(
|
||||
@@ -194,6 +216,11 @@ async fn revert_preserves_resolved_multi_agent_version_in_session_metadata() {
|
||||
.await
|
||||
.expect("initialize state database");
|
||||
let store = LocalThreadStore::new(config, Some(state_db.clone()));
|
||||
let other_account = LocalHistoryAccount {
|
||||
user_id: Some("other-user".to_string()),
|
||||
account_id: Some("other-account".to_string()),
|
||||
};
|
||||
let mut archived_threads = Vec::new();
|
||||
|
||||
for version in [
|
||||
MultiAgentVersion::V1,
|
||||
@@ -222,6 +249,17 @@ async fn revert_preserves_resolved_multi_agent_version_in_session_metadata() {
|
||||
.shutdown_thread(thread_id)
|
||||
.await
|
||||
.expect("close source writer");
|
||||
let contents = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("read rollout");
|
||||
// Preserve indexed offsets while turning the fixture into pre-enforcement history.
|
||||
let marker = "\"account_scoped\":true,";
|
||||
tokio::fs::write(
|
||||
&path,
|
||||
contents.replacen(marker, &" ".repeat(marker.len()), 1),
|
||||
)
|
||||
.await
|
||||
.expect("write legacy rollout");
|
||||
codex_rollout::state_db::reconcile_rollout(
|
||||
Some(state_db.as_ref()),
|
||||
path.as_path(),
|
||||
@@ -252,8 +290,48 @@ async fn revert_preserves_resolved_multi_agent_version_in_session_metadata() {
|
||||
let meta = codex_rollout::read_session_meta_line(&path)
|
||||
.await
|
||||
.expect("read replacement metadata");
|
||||
assert_eq!(meta.meta.multi_agent_version, Some(version));
|
||||
assert_eq!(
|
||||
(meta.meta.multi_agent_version, meta.meta.account_scoped),
|
||||
(Some(version), false)
|
||||
);
|
||||
assert!(
|
||||
other_account
|
||||
.includes(
|
||||
home.path(),
|
||||
thread_id,
|
||||
Some(&path),
|
||||
&[thread_id],
|
||||
/*original_paths*/ &mut None,
|
||||
)
|
||||
.await
|
||||
);
|
||||
}
|
||||
store
|
||||
.archive_thread(ArchiveThreadParams { thread_id })
|
||||
.await
|
||||
.expect("archive reverted legacy thread");
|
||||
let path = state_db
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("read archived metadata")
|
||||
.expect("archived thread")
|
||||
.rollout_path;
|
||||
archived_threads.push((thread_id, path));
|
||||
}
|
||||
let page_thread_ids: Vec<_> = archived_threads.iter().map(|(id, _)| *id).collect();
|
||||
let mut original_paths = None;
|
||||
for (thread_id, path) in archived_threads {
|
||||
assert!(
|
||||
other_account
|
||||
.includes(
|
||||
home.path(),
|
||||
thread_id,
|
||||
Some(&path),
|
||||
&page_thread_ids,
|
||||
&mut original_paths,
|
||||
)
|
||||
.await
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,19 +125,42 @@ pub(super) async fn search_threads(
|
||||
sort_direction,
|
||||
)
|
||||
.await?;
|
||||
let page_thread_ids: Vec<_> = page
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| item.thread_id)
|
||||
.collect();
|
||||
let mut original_paths = None;
|
||||
for item in page.items {
|
||||
let logical_path = rollout_search_path(item.path.as_path());
|
||||
let Some(snippet) = (match remaining_rollouts.remove(logical_path.as_path()) {
|
||||
Some(Some(snippet)) => Some(snippet),
|
||||
Some(None) => first_rollout_content_match_snippet(item.path.as_path(), search_term)
|
||||
let Some(mut snippet) = remaining_rollouts.remove(logical_path.as_path()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(account) = ¶ms.account {
|
||||
let Some(thread_id) = item.thread_id else {
|
||||
continue;
|
||||
};
|
||||
if !account
|
||||
.includes(
|
||||
&store.config.codex_home,
|
||||
thread_id,
|
||||
Some(&item.path),
|
||||
&page_thread_ids,
|
||||
&mut original_paths,
|
||||
)
|
||||
.await
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if snippet.is_none() {
|
||||
snippet = first_rollout_content_match_snippet(item.path.as_path(), search_term)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to read rollout search match: {err}"),
|
||||
})?,
|
||||
None => None,
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
})?;
|
||||
}
|
||||
let Some(snippet) = snippet else { continue };
|
||||
matching_items.push(ThreadSearchItem { item, snippet });
|
||||
if matching_items.len() > params.page_size {
|
||||
break;
|
||||
|
||||
@@ -141,6 +141,7 @@ async fn search_matches_selected_rollout_across_path_spellings_and_compression()
|
||||
] {
|
||||
let page = store
|
||||
.search_threads(SearchThreadsParams {
|
||||
account: None,
|
||||
page_size: 10,
|
||||
cursor: None,
|
||||
sort_key: ThreadSortKey::CreatedAt,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Account matching for local discovery. Pre-enforcement threads remain shared.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
/// The current ChatGPT identity for account-scoped local history discovery.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalHistoryAccount {
|
||||
pub user_id: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl LocalHistoryAccount {
|
||||
/// Newly scoped threads must match each creator ID; older history stays shared.
|
||||
/// Reuse `original_paths` only within the page identified by `page_thread_ids`.
|
||||
pub async fn includes(
|
||||
&self,
|
||||
codex_home: &Path,
|
||||
thread_id: ThreadId,
|
||||
rollout_path: Option<&Path>,
|
||||
page_thread_ids: &[ThreadId],
|
||||
original_paths: &mut Option<HashMap<ThreadId, PathBuf>>,
|
||||
) -> bool {
|
||||
let Some(path) = rollout_path else {
|
||||
return false;
|
||||
};
|
||||
let Ok(header) = codex_rollout::read_session_meta_line(path).await else {
|
||||
return false;
|
||||
};
|
||||
let mut meta = header.meta;
|
||||
if meta.id != thread_id {
|
||||
return false;
|
||||
}
|
||||
if !meta.account_scoped
|
||||
&& codex_rollout::rollout_id_from_path(path).is_some_and(|id| id != thread_id)
|
||||
{
|
||||
// An older binary can drop the marker on revert; consult the original rollout.
|
||||
// Resolve the page together so each reverted thread does not rescan all history.
|
||||
if original_paths.is_none() {
|
||||
*original_paths = Some(
|
||||
codex_rollout::find_rollout_paths_by_rollout_ids(codex_home, page_thread_ids)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
let Some(original) = original_paths
|
||||
.as_ref()
|
||||
.and_then(|paths| paths.get(&thread_id))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(header) = codex_rollout::read_session_meta_line(original).await else {
|
||||
return false;
|
||||
};
|
||||
meta = header.meta;
|
||||
}
|
||||
meta.id == thread_id
|
||||
&& (!meta.account_scoped
|
||||
|| ((meta.creator_user_id.is_none() || meta.creator_user_id == self.user_id)
|
||||
&& (meta.creator_account_id.is_none()
|
||||
|| meta.creator_account_id == self.account_id)))
|
||||
}
|
||||
}
|
||||
@@ -360,6 +360,9 @@ pub struct ListThreadsParams {
|
||||
/// Parameters for searching thread content.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SearchThreadsParams {
|
||||
/// The current login when searching account-scoped local history.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub account: Option<crate::LocalHistoryAccount>,
|
||||
/// Maximum number of threads to return.
|
||||
pub page_size: usize,
|
||||
/// Opaque cursor returned by a previous search call.
|
||||
|
||||
@@ -144,6 +144,7 @@ pub(super) async fn roots(
|
||||
|
||||
pub(super) fn list_params() -> ThreadListParams {
|
||||
ThreadListParams {
|
||||
current_account_only: true,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
limit: Some(100),
|
||||
|
||||
@@ -190,6 +190,7 @@ async fn descendants(
|
||||
.request_typed(ClientRequest::ThreadList {
|
||||
request_id: RequestId::String(uuid::Uuid::new_v4().to_string()),
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
ancestor_thread_id: Some(root.id.clone()),
|
||||
archived: Some(archived),
|
||||
cursor,
|
||||
|
||||
@@ -40,6 +40,7 @@ impl App {
|
||||
.request_typed::<ThreadListResponse>(ClientRequest::ThreadList {
|
||||
request_id: RequestId::String(Uuid::new_v4().to_string()),
|
||||
params: ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(crate) async fn list_pinned_threads(
|
||||
.request_typed::<ThreadListResponse>(ClientRequest::ThreadList {
|
||||
request_id: RequestId::String(Uuid::new_v4().to_string()),
|
||||
params: ThreadListParams {
|
||||
current_account_only: true,
|
||||
excluded_thread_ids: None,
|
||||
originators: None,
|
||||
cursor: cursor.clone(),
|
||||
@@ -186,6 +187,7 @@ impl SourcePage {
|
||||
.request_typed::<ThreadListResponse>(ClientRequest::ThreadList {
|
||||
request_id: RequestId::String(Uuid::new_v4().to_string()),
|
||||
params: ThreadListParams {
|
||||
current_account_only: true,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -351,6 +351,7 @@ async fn execute_inner(
|
||||
request(&handle, |request_id| ClientRequest::ThreadList {
|
||||
request_id,
|
||||
params: ThreadListParams {
|
||||
current_account_only: true,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -932,6 +932,7 @@ fn latest_session_lookup_params(
|
||||
lookup_mode: LatestSessionLookupMode,
|
||||
) -> ThreadListParams {
|
||||
ThreadListParams {
|
||||
current_account_only: true,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(super) async fn lookup(
|
||||
loop {
|
||||
let response = app_server
|
||||
.thread_list(ThreadListParams {
|
||||
current_account_only: false,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -2515,6 +2515,7 @@ fn thread_list_params(
|
||||
use_state_db_only: bool,
|
||||
) -> ThreadListParams {
|
||||
ThreadListParams {
|
||||
current_account_only: true,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -73,6 +73,7 @@ pub(crate) fn spawn_search(
|
||||
handle.request_typed::<ThreadSearchResponse>(ClientRequest::ThreadSearch {
|
||||
request_id: RequestId::String(format!("task-search-{request_generation}")),
|
||||
params: ThreadSearchParams {
|
||||
current_account_only: true,
|
||||
cursor: None,
|
||||
limit: Some(MAX_SEARCH_RESULTS),
|
||||
sort_key: Some(ThreadSearchSortKey::RecencyAt),
|
||||
@@ -90,6 +91,7 @@ pub(crate) fn spawn_search(
|
||||
"task-title-search-{request_generation}"
|
||||
)),
|
||||
params: ThreadListParams {
|
||||
current_account_only: true,
|
||||
excluded_thread_ids: None,
|
||||
attached_to_thread_id: None,
|
||||
originators: None,
|
||||
|
||||
@@ -10857,6 +10857,13 @@ class ThreadListParams(BaseModel):
|
||||
description="Optional archived filter; when set to true, only archived threads are returned. If false or null, only non-archived threads are returned."
|
||||
),
|
||||
] = None
|
||||
current_account_only: Annotated[
|
||||
bool | None,
|
||||
Field(
|
||||
alias="currentAccountOnly",
|
||||
description="Show local history visible to the current ChatGPT account. Defaults to false.",
|
||||
),
|
||||
] = None
|
||||
cursor: Annotated[
|
||||
str | None, Field(description="Opaque pagination cursor returned by a previous call.")
|
||||
] = None
|
||||
|
||||
Reference in New Issue
Block a user