diff --git a/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt b/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt index 941fae29b..1cf130f7c 100644 --- a/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt +++ b/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt @@ -143,6 +143,7 @@ class AppModel private constructor(context: android.content.Context) { // directory. Without setting it at launch the hook is a silent no-op. client.setSavedAppsDirectory(SavedAppsDirectory.path(context)) client.setSlingshotCredentialsDirectory(MobilePreferencesDirectory.path(context)) + client.setMobilePreferencesDirectory(MobilePreferencesDirectory.path(context)) serverBridge = ServerBridge() ssh = SshBridge() sshSessionStore = SshSessionStore(ssh) diff --git a/apps/ios/Sources/Litter/Models/AppModel.swift b/apps/ios/Sources/Litter/Models/AppModel.swift index 8fc52d53e..aff247b9d 100644 --- a/apps/ios/Sources/Litter/Models/AppModel.swift +++ b/apps/ios/Sources/Litter/Models/AppModel.swift @@ -209,6 +209,7 @@ final class AppModel { // Without this, auto-save silently no-ops. self.client.setSavedAppsDirectory(directory: SavedAppsDirectory.path) self.client.setSlingshotCredentialsDirectory(directory: MobilePreferencesDirectory.path) + self.client.setMobilePreferencesDirectory(directory: MobilePreferencesDirectory.path) // Route Swift presentation lookups through the Rust-owned // `AgentMetadataStore`. Any view rendering an agent label / diff --git a/docs/issues/plan-mode-crash-relaunch.md b/docs/issues/plan-mode-crash-relaunch.md new file mode 100644 index 000000000..df86d2078 --- /dev/null +++ b/docs/issues/plan-mode-crash-relaunch.md @@ -0,0 +1,95 @@ +# Mobile: Plan Mode State Is Lost After App Crash/Relaunch + +Issue target: https://github.com/dnakov/litter + +Created issue: https://github.com/dnakov/litter/issues/100 + +Fix branch: https://github.com/julianpistorius/litter/tree/fix/persist-thread-plan-mode + +Suggested issue title: + +```text +Mobile: plan mode state is lost after app crash/relaunch +``` + +## Summary + +Mobile threads can lose their local Plan-mode state after app crash or process restart. The conversation can still be in Plan mode on the Codex side, but Litter rehydrates the thread as Default mode. + +Result: when the user types "implement this" or similar after relaunch, Codex responds that it is in planning mode and cannot implement. The user has to toggle Plan mode in Litter and send another message before the app catches up and offers the implement-plan affordance. + +## Reproduction + +1. Connect Litter to a Codex server/desktop IPC session. +2. Open a thread. +3. Switch the thread to Plan mode. +4. Ask Codex for a plan and wait for a proposed plan plus the implement prompt. +5. Kill the app process: + +```bash +xcrun simctl terminate booted com.sigkitten.litter +adb -e shell am force-stop com.sigkitten.litter.android +``` + +6. Relaunch Litter, reconnect, and open the same thread. +7. Type something intended to implement the plan. + +Actual: Codex reports it is in planning mode and cannot implement. The app does not reliably show the implement-plan prompt. + +Workaround: Toggle Plan mode in the app, send another message, then Codex says it is already in Plan mode and the app eventually asks whether to implement the plan. + +Expected: Litter restores the thread's Plan mode after app relaunch and shows the implement-plan affordance when the loaded history contains an unimplemented proposed plan. + +## Root Cause + +The app server thread snapshots do not currently include the local collaboration mode as thread metadata. + +Relevant code paths: + +- `ThreadSnapshot::from_info` defaults `collaboration_mode` to `AppModeKind::Default`. +- Hydration from upstream `thread/read`, `thread/resume`, `thread/fork`, and paged turns reconstructs items/model/runtime state, but does not restore Plan mode from durable local state. +- `MobileClient::start_turn` injects `collaboration_mode: Plan` only when the local `ThreadSnapshot` is already Plan. +- The implement prompt is transient: live `TurnCompleted` sets `pending_plan_implementation_turn_id`, but cold hydration previously did not reconstruct that prompt from the loaded proposed-plan item. + +So after app process death, the new store has no memory that the thread was Plan, and the next `turn/start` goes out without a Plan collaboration-mode override. + +## Fixed Branch + +Branch: `fix/persist-thread-plan-mode` + +Fork link: https://github.com/julianpistorius/litter/tree/fix/persist-thread-plan-mode + +Fix outline: + +- Add Rust-owned `thread_modes.json` under the existing mobile preferences directory. +- Persist only non-default per-thread collaboration modes. +- Register the preferences directory from iOS and Android startup. +- Persist Plan mode on explicit mode change and when a received `ThreadItem::Plan` auto-detects planning mode. +- Remove the persisted entry when `implement_plan` switches back to Default. +- Apply persisted mode during thread list/read/resume/fork/rollback/turn-page reconciliation and as a last check before `turn/start`. +- Restore the implement-plan prompt from hydrated history when a Plan-mode thread contains a latest proposed-plan item with no later user turn. + +## Tests + +Added tests cover: + +- persisted Plan mode round-trips from disk; +- setting Default removes persisted Plan mode; +- auto-detected `ThreadItem::Plan` persists Plan mode; +- restored Plan mode causes next `turn/start` to include `collaboration_mode: Plan`; +- hydrated proposed plan restores the implement prompt; +- dismissed prompt does not reappear in the same runtime; +- a later user turn suppresses restored implement prompt. + +Local verification: + +- `rustfmt --edition 2024 --check ...` passed for changed Rust files. +- `git diff --check` passed. +- `cargo test -p codex-mobile-client --lib thread_modes` was attempted after initializing `shared/third_party/codex`, but the first-time workspace build failed with `No space left on device` after filling the Rust target directory. `cargo clean` removed the generated target artifacts and recovered 4.1 GiB. + +## Follow-Ups + +Personal TODOs: + +- Learn how to deploy Litter iOS/Android apps on my own phone. +- Investigate porting Litter to PWA with WASM. diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs index a6d58b4bd..c9b36cd3d 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs @@ -614,6 +614,12 @@ impl AppClient { self.inner.shutdown_alleycat_endpoint().await; } + /// Register the directory where Rust can persist small local app + /// preferences that are not part of the public preferences record. + pub fn set_mobile_preferences_directory(&self, directory: String) { + self.inner.set_mobile_preferences_directory(directory); + } + pub async fn fork_thread( &self, server_id: String, diff --git a/shared/rust-bridge/codex-mobile-client/src/lib.rs b/shared/rust-bridge/codex-mobile-client/src/lib.rs index 27428778f..5f6f93d1d 100644 --- a/shared/rust-bridge/codex-mobile-client/src/lib.rs +++ b/shared/rust-bridge/codex-mobile-client/src/lib.rs @@ -166,6 +166,7 @@ pub mod ssh_detached_launcher; pub mod ssh_launcher; pub mod store; pub mod terminal; +mod thread_modes; pub mod transport; pub mod types; pub mod widget_guidelines; diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs index 621215176..251affd0a 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs @@ -78,6 +78,10 @@ pub struct MobileClient { /// session token so cold launches can reconnect without another browser /// step-up while the token remains valid. pub(crate) slingshot_credentials_directory: Arc>>, + /// Directory where small local app preferences live. Used by Rust-only + /// helpers that need process-restart persistence without expanding the + /// public preferences record. + pub(crate) mobile_preferences_directory: Arc>>, direct_resumed_threads: Arc>>, resume_locks: Arc>>>>, thread_runtime_routes: Arc>>, @@ -756,9 +760,11 @@ impl MobileClient { let event_processor = Arc::new(EventProcessor::new()); let app_store = Arc::new(AppStoreReducer::new()); let sessions = Arc::new(RwLock::new(HashMap::new())); + let mobile_preferences_directory = Arc::new(StdMutex::new(None)); spawn_store_listener( Arc::clone(&app_store), Arc::clone(&sessions), + Arc::clone(&mobile_preferences_directory), event_processor.subscribe(), ); Self { @@ -771,6 +777,7 @@ impl MobileClient { recorder: Arc::new(crate::recorder::MessageRecorder::new()), widget_waiters: Arc::new(StdMutex::new(HashMap::new())), saved_apps_directory: Arc::new(StdMutex::new(None)), + mobile_preferences_directory, slingshot_credentials_directory: Arc::new(StdMutex::new(None)), direct_resumed_threads: Arc::new(StdMutex::new(HashSet::new())), resume_locks: Arc::new(StdMutex::new(HashMap::new())), @@ -976,6 +983,62 @@ impl MobileClient { } } + pub(crate) fn set_mobile_preferences_directory(&self, directory: String) { + let mut guard = self + .mobile_preferences_directory + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *guard = if directory.is_empty() { + None + } else { + Some(directory) + }; + } + + fn mobile_preferences_directory(&self) -> Option { + self.mobile_preferences_directory + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn persisted_thread_collaboration_mode(&self, key: &ThreadKey) -> Option { + let directory = self.mobile_preferences_directory()?; + crate::thread_modes::read_mode(&directory, key) + } + + fn persist_thread_collaboration_mode(&self, key: &ThreadKey, mode: AppModeKind) { + let Some(directory) = self.mobile_preferences_directory() else { + return; + }; + crate::thread_modes::set_mode(&directory, key, mode); + } + + pub(crate) fn apply_persisted_thread_collaboration_mode(&self, thread: &mut ThreadSnapshot) { + if let Some(mode) = self.persisted_thread_collaboration_mode(&thread.key) { + thread.collaboration_mode = mode; + } + } + + pub(crate) fn apply_persisted_thread_modes_to_infos( + &self, + server_id: &str, + threads: &[ThreadInfo], + ) { + if self.mobile_preferences_directory().is_none() { + return; + } + for info in threads { + let key = ThreadKey { + server_id: server_id.to_string(), + thread_id: info.id.clone(), + }; + if let Some(mode) = self.persisted_thread_collaboration_mode(&key) { + self.app_store.set_thread_collaboration_mode(&key, mode); + } + } + } + fn direct_resumed_threads(&self) -> std::sync::MutexGuard<'_, HashSet> { match self.direct_resumed_threads.lock() { Ok(guard) => guard, @@ -2996,6 +3059,7 @@ impl MobileClient { } reconcile_active_turn(existing.as_ref(), &mut snapshot, &turns); snapshot.is_resumed = true; + self.apply_persisted_thread_collaboration_mode(&mut snapshot); self.app_store.upsert_thread_snapshot(snapshot); self.mark_direct_resumed_thread(key.clone()); Ok(()) @@ -3267,7 +3331,15 @@ impl MobileClient { }; self.app_store .dismiss_plan_implementation_prompt(&thread_key); - let thread_snapshot = self.snapshot_thread(&thread_key).ok(); + let mut thread_snapshot = self.snapshot_thread(&thread_key).ok(); + if let Some(thread) = thread_snapshot.as_mut() + && thread.collaboration_mode != AppModeKind::Plan + && self.persisted_thread_collaboration_mode(&thread_key) == Some(AppModeKind::Plan) + { + thread.collaboration_mode = AppModeKind::Plan; + self.app_store + .set_thread_collaboration_mode(&thread_key, AppModeKind::Plan); + } if let Some(thread) = thread_snapshot.as_ref() && thread.collaboration_mode == AppModeKind::Plan && params.collaboration_mode.is_none() @@ -3524,6 +3596,7 @@ impl MobileClient { .map_err(RpcError::Deserialization)?; copy_thread_runtime_fields(¤t, &mut snapshot); reconcile_active_turn(Some(¤t), &mut snapshot, &turns); + self.apply_persisted_thread_collaboration_mode(&mut snapshot); self.app_store.upsert_thread_snapshot(snapshot); } @@ -3611,6 +3684,7 @@ impl MobileClient { .map_err(RpcError::Deserialization)?; } + self.apply_persisted_thread_collaboration_mode(&mut snapshot); self.app_store.upsert_thread_snapshot(snapshot); self.set_active_thread(Some(next_key.clone())); Ok(next_key) @@ -3948,6 +4022,7 @@ impl MobileClient { ) -> Result<(), RpcError> { self.get_session(&key.server_id)?; self.app_store.set_thread_collaboration_mode(key, mode); + self.persist_thread_collaboration_mode(key, mode); Ok(()) } @@ -3960,6 +4035,7 @@ impl MobileClient { let thread = self.snapshot_thread(key).ok(); self.app_store .set_thread_collaboration_mode(key, AppModeKind::Default); + self.persist_thread_collaboration_mode(key, AppModeKind::Default); let collaboration_mode = thread .as_ref() .and_then(|t| collaboration_mode_from_thread(t, AppModeKind::Default, None, None)); diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs index 1be81bbd8..a63233f9e 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs @@ -6,6 +6,7 @@ const IDLE_THREAD_RECONCILE_DELAYS_MS: [u64; 3] = [100, 500, 1_500]; pub(super) fn spawn_store_listener( app_store: Arc, sessions: Arc>>>, + mobile_preferences_directory: Arc>>, mut rx: broadcast::Receiver, ) { MobileClient::spawn_detached(async move { @@ -18,6 +19,7 @@ pub(super) fn spawn_store_listener( Arc::clone(&sessions), &event, ); + maybe_persist_thread_mode_from_event(&mobile_preferences_directory, &event); maybe_hydrate_collab_agent_metadata( Arc::clone(&app_store), Arc::clone(&sessions), @@ -132,6 +134,28 @@ fn idle_thread_key(event: &UiEvent) -> Option<&ThreadKey> { } } +fn maybe_persist_thread_mode_from_event( + mobile_preferences_directory: &Arc>>, + event: &UiEvent, +) { + let UiEvent::ItemCompleted { key, notification } = event else { + return; + }; + if !matches!(notification.item, upstream::ThreadItem::Plan { .. }) { + return; + } + let directory = { + let guard = mobile_preferences_directory + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + guard.clone() + }; + let Some(directory) = directory else { + return; + }; + crate::thread_modes::set_mode(&directory, key, AppModeKind::Plan); +} + fn maybe_hydrate_collab_agent_metadata( app_store: Arc, sessions: Arc>>>, @@ -330,6 +354,39 @@ pub(super) async fn maybe_send_next_local_queued_follow_up( #[cfg(test)] mod tests { use super::*; + use tempfile::tempdir; + + #[test] + fn item_completed_plan_persists_plan_mode() { + let tempdir = tempdir().expect("tempdir"); + let directory = tempdir.path().to_string_lossy().to_string(); + let key = ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread".to_string(), + }; + let event = UiEvent::ItemCompleted { + key: key.clone(), + notification: upstream::ItemCompletedNotification { + item: upstream::ThreadItem::Plan { + id: "plan".to_string(), + text: "plan text".to_string(), + }, + thread_id: key.thread_id.clone(), + turn_id: "turn-plan".to_string(), + completed_at_ms: 0, + }, + }; + + maybe_persist_thread_mode_from_event( + &Arc::new(StdMutex::new(Some(directory.clone()))), + &event, + ); + + assert_eq!( + crate::thread_modes::read_mode(&directory, &key), + Some(AppModeKind::Plan) + ); + } #[test] fn only_idle_status_changes_request_authoritative_reconciliation() { diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs index 6981e858a..ef6c16858 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs @@ -2171,4 +2171,122 @@ mod mobile_client_tests { assert_eq!(preview.kind, AppQueuedFollowUpKind::PendingSteer); assert_eq!(preview.text, "Please try the same search again."); } + + #[tokio::test] + async fn start_turn_uses_persisted_plan_mode_after_cold_restore() { + let client = MobileClient::new(); + let tempdir = tempfile::tempdir().expect("tempdir"); + let preferences_dir = tempdir.path().to_string_lossy().to_string(); + client.set_mobile_preferences_directory(preferences_dir.clone()); + let server_id = "srv"; + let thread_id = "thread-1"; + let key = ThreadKey { + server_id: server_id.to_string(), + thread_id: thread_id.to_string(), + }; + crate::thread_modes::set_mode(&preferences_dir, &key, AppModeKind::Plan); + + let config = make_server_config(server_id); + client + .app_store + .upsert_server(&config, ServerHealthSnapshot::Connected); + let mut thread = ThreadSnapshot::from_info(server_id, make_thread_info(thread_id)); + thread.info.status = ThreadSummaryStatus::Idle; + thread.model = Some("gpt-5".to_string()); + client.app_store.upsert_thread_snapshot(thread); + + let turn_start_calls = Arc::new(StdMutex::new(Vec::::new())); + let request_handler: TestRequestHandler = { + let turn_start_calls = Arc::clone(&turn_start_calls); + Arc::new(move |request| { + turn_start_calls + .lock() + .expect("turn start calls lock should not be poisoned") + .push(request.clone()); + match request { + upstream::ClientRequest::TurnStart { .. } => { + serde_json::to_value(upstream::TurnStartResponse { + turn: upstream::Turn { + id: "turn-next".to_string(), + items: Vec::new(), + status: upstream::TurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + items_view: upstream::TurnItemsView::default(), + }, + }) + .map_err(|error| RpcError::Deserialization(error.to_string())) + } + other => Err(RpcError::Deserialization(format!( + "unexpected request in test: {}", + other.method() + ))), + } + }) + }; + let session = Arc::new(ServerSession::test_stub_with_handlers( + config, + Some(request_handler), + None, + None, + )); + client + .sessions + .write() + .expect("sessions lock should not be poisoned") + .insert(server_id.to_string(), session); + + client + .start_turn( + server_id, + upstream::TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![upstream::UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: None, + cwd: None, + runtime_workspace_roots: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + environments: None, + permissions: None, + model: None, + service_tier: None, + effort: None, + summary: None, + personality: None, + output_schema: None, + collaboration_mode: None, + }, + ) + .await + .expect("start turn should succeed"); + + let captured = turn_start_calls + .lock() + .expect("turn start calls lock should not be poisoned"); + let upstream::ClientRequest::TurnStart { params, .. } = &captured[0] else { + panic!("expected turn/start request"); + }; + assert_eq!( + params + .collaboration_mode + .as_ref() + .map(|mode| mode.mode.clone()), + Some(codex_protocol::config_types::ModeKind::Plan) + ); + assert_eq!( + client + .snapshot_thread(&key) + .expect("thread snapshot") + .collaboration_mode, + AppModeKind::Plan + ); + } + } diff --git a/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs b/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs index 50708448c..2105444cf 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs @@ -176,6 +176,7 @@ impl MobileClient { .filter_map(crate::thread_info_from_upstream_thread) .collect::>(); self.app_store.sync_thread_list(server_id, &threads); + self.apply_persisted_thread_modes_to_infos(server_id, &threads); Ok(threads) } @@ -200,6 +201,7 @@ impl MobileClient { .collect::>(); self.app_store .upsert_thread_list_page_for_runtime(server_id, runtime_kind, &threads); + self.apply_persisted_thread_modes_to_infos(server_id, &threads); threads } @@ -251,6 +253,7 @@ impl MobileClient { let key = snapshot.key.clone(); let existing = self.app_store.thread_snapshot(&key); crate::reconcile_active_turn(existing.as_ref(), &mut snapshot, &response.thread.turns); + self.apply_persisted_thread_collaboration_mode(&mut snapshot); self.app_store.upsert_thread_snapshot(snapshot); Ok(key) } @@ -304,6 +307,7 @@ impl MobileClient { snapshot.initial_turns_loaded = true; } crate::reconcile_active_turn(existing.as_ref(), &mut snapshot, &upstream_turns); + self.apply_persisted_thread_collaboration_mode(&mut snapshot); self.app_store.upsert_thread_snapshot(snapshot); Ok(key) } @@ -329,6 +333,7 @@ impl MobileClient { let existing = self.app_store.thread_snapshot(&key); apply_pagination_merge(existing.as_ref(), &mut snapshot, &response.thread.turns); crate::reconcile_active_turn(existing.as_ref(), &mut snapshot, &response.thread.turns); + self.apply_persisted_thread_collaboration_mode(&mut snapshot); self.app_store.upsert_thread_snapshot(snapshot); Ok(key) } @@ -354,6 +359,7 @@ impl MobileClient { let existing = self.app_store.thread_snapshot(&key); apply_pagination_merge(existing.as_ref(), &mut snapshot, &response.thread.turns); crate::reconcile_active_turn(existing.as_ref(), &mut snapshot, &response.thread.turns); + self.apply_persisted_thread_collaboration_mode(&mut snapshot); self.app_store.upsert_thread_snapshot(snapshot); Ok(key) } @@ -394,6 +400,7 @@ impl MobileClient { initial_turns_loaded = thread.initial_turns_loaded, "apply_thread_turns_page merged" ); + self.apply_persisted_thread_collaboration_mode(&mut thread); self.app_store.upsert_thread_snapshot(thread); Ok(()) } @@ -433,6 +440,7 @@ impl MobileClient { crate::reconcile_active_turn(Some(current), &mut snapshot, &response.thread.turns); } let next_key = snapshot.key.clone(); + self.apply_persisted_thread_collaboration_mode(&mut snapshot); self.app_store.upsert_thread_snapshot(snapshot); Ok(next_key) } diff --git a/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs b/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs index c945b43b9..eb93422f4 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs @@ -789,7 +789,8 @@ impl AppStoreReducer { // entry can be read in place. Cloning it here duplicated every // item of the thread on every upsert just to read a handful of // preserved fields. - if let Some(existing) = snapshot.threads.get(&key) { + let existing = snapshot.threads.get(&key); + if let Some(existing) = existing { preserve_thread_title(&existing.info, &mut thread.info); preserve_thread_preview(&existing.info, &mut thread.info); preserve_thread_created_at(&existing.info, &mut thread.info); @@ -808,6 +809,7 @@ impl AppStoreReducer { thread.items = existing.items.clone(); } } + restore_plan_implementation_prompt_from_history(&mut thread, existing); if !thread.queued_follow_up_drafts.is_empty() || thread.queued_follow_ups.is_empty() { sync_thread_follow_up_projection(&mut thread); } @@ -3625,6 +3627,51 @@ fn preserve_thread_runtime_state(source: &ThreadSnapshot, target: &mut ThreadSna } } +fn restore_plan_implementation_prompt_from_history( + target: &mut ThreadSnapshot, + existing: Option<&ThreadSnapshot>, +) { + if target.collaboration_mode != AppModeKind::Plan + || target.active_turn_id.is_some() + || target.pending_plan_implementation_turn_id.is_some() + { + return; + } + + let Some((plan_index, plan_turn_id)) = latest_proposed_plan_turn(&target.items) else { + return; + }; + if existing.is_some_and(|thread| { + latest_proposed_plan_turn(&thread.items).is_some_and(|(_, turn_id)| { + turn_id == plan_turn_id && thread.pending_plan_implementation_turn_id.is_none() + }) + }) { + return; + } + if target.items.iter().skip(plan_index + 1).any(|item| { + item.is_from_user_turn_boundary + || matches!(&item.content, HydratedConversationItemContent::User(_)) + }) { + return; + } + target.pending_plan_implementation_turn_id = Some(plan_turn_id); +} + +fn latest_proposed_plan_turn(items: &[HydratedConversationItem]) -> Option<(usize, String)> { + items.iter().enumerate().rev().find_map(|(index, item)| { + if matches!( + &item.content, + HydratedConversationItemContent::ProposedPlan(_) + ) { + item.source_turn_id + .as_ref() + .map(|turn_id| (index, turn_id.clone())) + } else { + None + } + }) +} + fn preserve_thread_title(existing: &ThreadInfo, incoming: &mut ThreadInfo) { let incoming_blank = incoming .title @@ -6481,6 +6528,105 @@ mod tests { assert_eq!(entry_a.item_id, "item-A"); assert_eq!(entry_b.item_id, "item-B"); } + + // Fork-added plan-mode persistence tests (issue #100). + + fn plan_item(item_id: &str, turn_id: &str) -> HydratedConversationItem { + HydratedConversationItem { + id: item_id.to_string(), + content: HydratedConversationItemContent::ProposedPlan(HydratedProposedPlanData { + content: "plan".to_string(), + }), + source_turn_id: Some(turn_id.to_string()), + source_turn_index: None, + timestamp: None, + is_from_user_turn_boundary: false, + } + } + + fn user_item(item_id: &str, turn_id: &str) -> HydratedConversationItem { + HydratedConversationItem { + id: item_id.to_string(), + content: HydratedConversationItemContent::User(HydratedUserMessageData { + text: "next turn".to_string(), + image_data_uris: Vec::new(), + }), + source_turn_id: Some(turn_id.to_string()), + source_turn_index: None, + timestamp: None, + is_from_user_turn_boundary: true, + } + } + + #[test] + fn upsert_thread_snapshot_restores_plan_prompt_from_history() { + let reducer = AppStoreReducer::new(); + let key = ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread".to_string(), + }; + let mut thread = ThreadSnapshot::from_info("srv", make_thread_info("thread")); + thread.collaboration_mode = AppModeKind::Plan; + thread.items.push(plan_item("plan", "turn-plan")); + + reducer.upsert_thread_snapshot(thread); + + assert_eq!( + reducer + .thread_snapshot(&key) + .unwrap() + .pending_plan_implementation_turn_id + .as_deref(), + Some("turn-plan") + ); + } + + #[test] + fn upsert_thread_snapshot_does_not_restore_dismissed_plan_prompt() { + let reducer = AppStoreReducer::new(); + let key = ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread".to_string(), + }; + let mut thread = ThreadSnapshot::from_info("srv", make_thread_info("thread")); + thread.collaboration_mode = AppModeKind::Plan; + thread.items.push(plan_item("plan", "turn-plan")); + reducer.upsert_thread_snapshot(thread.clone()); + reducer.dismiss_plan_implementation_prompt(&key); + + reducer.upsert_thread_snapshot(thread); + + assert_eq!( + reducer + .thread_snapshot(&key) + .unwrap() + .pending_plan_implementation_turn_id, + None + ); + } + + #[test] + fn upsert_thread_snapshot_does_not_restore_old_plan_prompt_after_user_turn() { + let reducer = AppStoreReducer::new(); + let key = ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread".to_string(), + }; + let mut thread = ThreadSnapshot::from_info("srv", make_thread_info("thread")); + thread.collaboration_mode = AppModeKind::Plan; + thread.items.push(plan_item("plan", "turn-plan")); + thread.items.push(user_item("user", "turn-user")); + + reducer.upsert_thread_snapshot(thread); + + assert_eq!( + reducer + .thread_snapshot(&key) + .unwrap() + .pending_plan_implementation_turn_id, + None + ); + } } fn appended_text_delta(existing: &str, projected: &str) -> Option { diff --git a/shared/rust-bridge/codex-mobile-client/src/thread_modes.rs b/shared/rust-bridge/codex-mobile-client/src/thread_modes.rs new file mode 100644 index 000000000..508ff048f --- /dev/null +++ b/shared/rust-bridge/codex-mobile-client/src/thread_modes.rs @@ -0,0 +1,154 @@ +//! Local per-thread runtime mode persistence. +//! +//! The app server does not currently return a thread's collaboration mode in +//! thread list/read snapshots, so mobile stores the user's last local choice +//! beside other app preferences. Only non-default modes are persisted. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +use crate::types::{AppModeKind, ThreadKey}; + +const THREAD_MODES_FILE: &str = "thread_modes.json"; +const CURRENT_VERSION: u32 = 1; + +static WRITE_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersistedThreadMode { + server_id: String, + thread_id: String, + mode: AppModeKind, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersistedThreadModes { + version: u32, + #[serde(default)] + modes: Vec, +} + +fn thread_modes_path(directory: &str) -> PathBuf { + PathBuf::from(directory).join(THREAD_MODES_FILE) +} + +fn read_thread_modes(path: &Path) -> PersistedThreadModes { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(_) => return PersistedThreadModes::default(), + }; + serde_json::from_slice::(&bytes).unwrap_or_default() +} + +fn write_thread_modes(path: &Path, value: &PersistedThreadModes) { + let Some(parent) = path.parent() else { return }; + if let Err(error) = fs::create_dir_all(parent) { + tracing::warn!(error = %error, "thread_modes: create dir failed"); + return; + } + + let json = match serde_json::to_vec_pretty(value) { + Ok(bytes) => bytes, + Err(error) => { + tracing::warn!(error = %error, "thread_modes: serialize failed"); + return; + } + }; + + let tmp_path = path.with_extension("json.tmp"); + match fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&tmp_path) + { + Ok(mut file) => { + if let Err(error) = file.write_all(&json) { + tracing::warn!(error = %error, "thread_modes: write failed"); + let _ = fs::remove_file(&tmp_path); + return; + } + let _ = file.sync_all(); + } + Err(error) => { + tracing::warn!(error = %error, "thread_modes: open tmp failed"); + return; + } + } + if let Err(error) = fs::rename(&tmp_path, path) { + tracing::warn!(error = %error, "thread_modes: rename failed"); + let _ = fs::remove_file(&tmp_path); + } +} + +pub(crate) fn read_mode(directory: &str, key: &ThreadKey) -> Option { + let path = thread_modes_path(directory); + let _guard = WRITE_LOCK.lock().ok(); + read_thread_modes(&path) + .modes + .into_iter() + .find(|entry| entry.server_id == key.server_id && entry.thread_id == key.thread_id) + .map(|entry| entry.mode) +} + +pub(crate) fn set_mode(directory: &str, key: &ThreadKey, mode: AppModeKind) { + let path = thread_modes_path(directory); + let _guard = WRITE_LOCK.lock().ok(); + let mut persisted = read_thread_modes(&path); + persisted.version = CURRENT_VERSION; + persisted + .modes + .retain(|entry| !(entry.server_id == key.server_id && entry.thread_id == key.thread_id)); + if mode != AppModeKind::Default { + persisted.modes.push(PersistedThreadMode { + server_id: key.server_id.clone(), + thread_id: key.thread_id.clone(), + mode, + }); + } + write_thread_modes(&path, &persisted); +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn dir(tempdir: &tempfile::TempDir) -> String { + tempdir.path().to_string_lossy().to_string() + } + + fn key() -> ThreadKey { + ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread".to_string(), + } + } + + #[test] + fn plan_mode_round_trips() { + let tempdir = tempdir().unwrap(); + let key = key(); + + set_mode(&dir(&tempdir), &key, AppModeKind::Plan); + + assert_eq!(read_mode(&dir(&tempdir), &key), Some(AppModeKind::Plan)); + } + + #[test] + fn default_mode_removes_entry() { + let tempdir = tempdir().unwrap(); + let key = key(); + + set_mode(&dir(&tempdir), &key, AppModeKind::Plan); + set_mode(&dir(&tempdir), &key, AppModeKind::Default); + + assert_eq!(read_mode(&dir(&tempdir), &key), None); + } +}