diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index 641c4318d..8484a4c0f 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -118,7 +118,12 @@ jobs: - name: Shared Rust tests env: CARGO_INCREMENTAL: "0" - run: cargo test --manifest-path shared/rust-bridge/Cargo.toml -p codex-mobile-client -p codex-slingshot + run: | + cargo test --manifest-path shared/rust-bridge/Cargo.toml -p codex-mobile-client -p codex-slingshot + # The codex submodule is checked out pristine on cache hits, so apply + # the patch set before testing patched submodule crates. + ./apps/ios/scripts/sync-codex.sh --recorded-gitlink + cargo test --manifest-path shared/third_party/codex/codex-rs/Cargo.toml -p codex-thread-store - name: Release script tests run: | diff --git a/apps/ios/scripts/sync-codex.sh b/apps/ios/scripts/sync-codex.sh index b3775c834..a24743585 100755 --- a/apps/ios/scripts/sync-codex.sh +++ b/apps/ios/scripts/sync-codex.sh @@ -9,6 +9,7 @@ PATCH_FILES=( "$REPO_DIR/patches/codex/ios-exec-hook.patch" "$REPO_DIR/patches/codex/mobile-code-mode-stub.patch" "$REPO_DIR/patches/codex/thread-read-permissions.patch" + "$REPO_DIR/patches/codex/thread-list-fork-lineage.patch" "$REPO_DIR/patches/codex/mobile-shell-snapshot-timeout.patch" "$REPO_DIR/patches/codex/remote-app-server-websocket-cap.patch" "$REPO_DIR/patches/codex/absolute-path-cross-platform.patch" diff --git a/patches/codex/thread-list-fork-lineage.patch b/patches/codex/thread-list-fork-lineage.patch new file mode 100644 index 000000000..c562c1b28 --- /dev/null +++ b/patches/codex/thread-list-fork-lineage.patch @@ -0,0 +1,284 @@ +diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs +index d4f067789..d3119771f 100644 +--- a/codex-rs/rollout/src/list.rs ++++ b/codex-rs/rollout/src/list.rs +@@ -48,6 +48,9 @@ pub struct ThreadItem { + pub path: PathBuf, + /// Thread ID from session metadata. + pub thread_id: Option, ++ /// Immutable parent thread id from session metadata, when this rollout ++ /// is a fork. ++ pub forked_from_id: Option, + /// First user message captured for this thread, if any. + pub first_user_message: Option, + /// Best available user-facing preview for discovery and list display. +@@ -88,6 +91,7 @@ pub type ConversationsPage = ThreadsPage; + struct HeadTailSummary { + saw_session_meta: bool, + thread_id: Option, ++ forked_from_id: Option, + first_user_message: Option, + preview: Option, + cwd: Option, +@@ -771,6 +775,7 @@ async fn build_thread_item( + if summary.saw_session_meta && summary.preview.is_some() { + let HeadTailSummary { + thread_id, ++ forked_from_id, + first_user_message, + preview, + cwd, +@@ -792,6 +797,7 @@ async fn build_thread_item( + return Some(ThreadItem { + path, + thread_id, ++ forked_from_id, + first_user_message, + preview, + cwd, +@@ -1105,6 +1111,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result ThreadI + ThreadItem { + path: item.rollout_path, + thread_id: Some(item.id), ++ forked_from_id: None, + first_user_message: item.first_user_message, + preview: item.preview, + cwd: Some(item.cwd), +diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs +index 8199f290d..7030d753d 100644 +--- a/codex-rs/rollout/src/recorder_tests.rs ++++ b/codex-rs/rollout/src/recorder_tests.rs +@@ -961,9 +961,11 @@ async fn list_threads_metadata_filter_overlays_state_db_list_metadata() -> std:: + fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fields() { + let filesystem_thread_id = ThreadId::new(); + let state_thread_id = ThreadId::new(); ++ let state_fork_thread_id = ThreadId::new(); + let filesystem_path = PathBuf::from("/tmp/filesystem-rollout.jsonl"); + let state_path = PathBuf::from("/tmp/state-rollout.jsonl"); + let mut item = ThreadItem { ++ forked_from_id: None, + path: filesystem_path.clone(), + thread_id: Some(filesystem_thread_id), + first_user_message: Some("filesystem message".to_string()), +@@ -981,6 +983,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi + updated_at: None, + }; + let state_item = ThreadItem { ++ forked_from_id: Some(state_fork_thread_id), + path: state_path, + thread_id: Some(state_thread_id), + first_user_message: Some("state message".to_string()), +@@ -1021,6 +1024,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi + assert_eq!(item.cli_version.as_deref(), Some("state-version")); + assert_eq!(item.created_at.as_deref(), Some("2025-01-03T16:00:00Z")); + assert_eq!(item.updated_at.as_deref(), Some("2025-01-03T16:01:02.003Z")); ++ assert_eq!(item.forked_from_id, Some(state_fork_thread_id)); + } + + #[tokio::test] +diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs +index bcd395d82..e640ef1ed 100644 +--- a/codex-rs/rollout/src/tests.rs ++++ b/codex-rs/rollout/src/tests.rs +@@ -724,6 +724,7 @@ async fn test_list_conversations_latest_first() { + let expected = ThreadsPage { + items: vec![ + ThreadItem { ++ forked_from_id: None, + path: p1, + thread_id: Some(thread_id_from_uuid(u3)), + first_user_message: Some("Hello from user".to_string()), +@@ -741,6 +742,7 @@ async fn test_list_conversations_latest_first() { + updated_at: updated_times.first().cloned().flatten(), + }, + ThreadItem { ++ forked_from_id: None, + path: p2, + thread_id: Some(thread_id_from_uuid(u2)), + first_user_message: Some("Hello from user".to_string()), +@@ -758,6 +760,7 @@ async fn test_list_conversations_latest_first() { + updated_at: updated_times.get(1).cloned().flatten(), + }, + ThreadItem { ++ forked_from_id: None, + path: p3, + thread_id: Some(thread_id_from_uuid(u1)), + first_user_message: Some("Hello from user".to_string()), +@@ -868,6 +871,7 @@ async fn test_pagination_cursor() { + let expected_page1 = ThreadsPage { + items: vec![ + ThreadItem { ++ forked_from_id: None, + path: p5, + thread_id: Some(thread_id_from_uuid(u5)), + first_user_message: Some("Hello from user".to_string()), +@@ -885,6 +889,7 @@ async fn test_pagination_cursor() { + updated_at: updated_page1.first().cloned().flatten(), + }, + ThreadItem { ++ forked_from_id: None, + path: p4, + thread_id: Some(thread_id_from_uuid(u4)), + first_user_message: Some("Hello from user".to_string()), +@@ -938,6 +943,7 @@ async fn test_pagination_cursor() { + let expected_page2 = ThreadsPage { + items: vec![ + ThreadItem { ++ forked_from_id: None, + path: p3, + thread_id: Some(thread_id_from_uuid(u3)), + first_user_message: Some("Hello from user".to_string()), +@@ -955,6 +961,7 @@ async fn test_pagination_cursor() { + updated_at: updated_page2.first().cloned().flatten(), + }, + ThreadItem { ++ forked_from_id: None, + path: p2, + thread_id: Some(thread_id_from_uuid(u2)), + first_user_message: Some("Hello from user".to_string()), +@@ -1000,6 +1007,7 @@ async fn test_pagination_cursor() { + page3.items.iter().map(|i| i.updated_at.clone()).collect(); + let expected_page3 = ThreadsPage { + items: vec![ThreadItem { ++ forked_from_id: None, + path: p1, + thread_id: Some(thread_id_from_uuid(u1)), + first_user_message: Some("Hello from user".to_string()), +@@ -1170,6 +1178,7 @@ async fn test_get_thread_contents() { + .join(format!("rollout-2025-04-01T10-30-00-{uuid}.jsonl")); + let expected_page = ThreadsPage { + items: vec![ThreadItem { ++ forked_from_id: None, + path: expected_path, + thread_id: Some(thread_id_from_uuid(uuid)), + first_user_message: Some("Hello from user".to_string()), +@@ -1520,6 +1529,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { + let expected_page1 = ThreadsPage { + items: vec![ + ThreadItem { ++ forked_from_id: None, + path: p3, + thread_id: Some(thread_id_from_uuid(u3)), + first_user_message: Some("Hello from user".to_string()), +@@ -1537,6 +1547,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { + updated_at: updated_page1.first().cloned().flatten(), + }, + ThreadItem { ++ forked_from_id: None, + path: p2, + thread_id: Some(thread_id_from_uuid(u2)), + first_user_message: Some("Hello from user".to_string()), +diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs +index bfdc94fbf..4e8ed287e 100644 +--- a/codex-rs/thread-store/src/local/helpers.rs ++++ b/codex-rs/thread-store/src/local/helpers.rs +@@ -119,7 +119,7 @@ pub(super) fn stored_thread_from_rollout_item( + Some(StoredThread { + thread_id, + rollout_path: Some(item.path), +- forked_from_id: None, ++ forked_from_id: item.forked_from_id, + preview, + name: None, + model_provider: item +diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs +index ede9e9e9c..d7b68b844 100644 +--- a/codex-rs/thread-store/src/local/list_threads.rs ++++ b/codex-rs/thread-store/src/local/list_threads.rs +@@ -199,6 +199,7 @@ mod tests { + use crate::local::test_support::write_archived_session_file; + use crate::local::test_support::write_session_file; + use crate::local::test_support::write_session_file_with; ++ use crate::local::test_support::write_session_file_with_fork; + + #[tokio::test] + async fn list_threads_uses_default_provider_when_rollout_omits_provider() { +@@ -234,6 +235,58 @@ mod tests { + assert_eq!(page.items[0].model_provider, "test-provider"); + } + ++ #[tokio::test] ++ async fn cold_list_threads_preserves_fork_lineage_from_rollout() { ++ let home = TempDir::new().expect("temp dir"); ++ let parent_id = Uuid::from_u128(103); ++ let fork_id = Uuid::from_u128(104); ++ let plain_id = Uuid::from_u128(105); ++ write_session_file_with_fork( ++ home.path(), ++ home.path().join("sessions/2025/01/03"), ++ "2025-01-03T12-00-00", ++ fork_id, ++ "Forked user message", ++ Some("test-provider"), ++ Some(parent_id), ++ ) ++ .expect("fork rollout"); ++ write_session_file(home.path(), "2025-01-02T12-00-00", plain_id) ++ .expect("plain rollout"); ++ ++ // A fresh store has no warm in-memory fork response to preserve. The ++ // list response must recover lineage from the persisted rollout. ++ let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); ++ let page = store ++ .list_threads(ListThreadsParams { ++ page_size: 10, ++ cursor: None, ++ sort_key: ThreadSortKey::CreatedAt, ++ sort_direction: SortDirection::Desc, ++ allowed_sources: Vec::new(), ++ model_providers: None, ++ cwd_filters: None, ++ archived: false, ++ search_term: None, ++ use_state_db_only: false, ++ }) ++ .await ++ .expect("cold thread listing"); ++ ++ assert_eq!(page.items.len(), 2); ++ assert_eq!(page.items[0].thread_id.to_string(), fork_id.to_string()); ++ assert_eq!( ++ page.items[0].forked_from_id.map(|id| id.to_string()), ++ Some(parent_id.to_string()) ++ ); ++ let plain = page ++ .items ++ .iter() ++ .find(|item| item.thread_id.to_string() == plain_id.to_string()) ++ .expect("plain thread listed"); ++ assert!(plain.forked_from_id.is_none()); ++ } ++ + #[tokio::test] + async fn list_threads_preserves_sqlite_title_search_results() { + let home = TempDir::new().expect("temp dir");