diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index b87cfbb95a9..0d1e5bbe238 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -323,6 +323,13 @@ pub struct BlocklistAIHistoryModel { /// In-flight optimistic conversation rename state keyed by conversation. in_flight_conversation_renames: HashMap, + /// Server conversation tokens for which a background metadata fetch has been + /// started but not yet resolved. Used to deduplicate concurrent fetch + /// attempts (e.g. repeated `TasksUpdated` events before the first response + /// arrives). Cleared on completion — whether success or failure — so a + /// later call retries after a transient error. + pending_server_metadata_fetch_tokens: HashSet, + #[cfg(feature = "local_fs")] db_connection: Option>>, } @@ -370,6 +377,27 @@ impl BlocklistAIHistoryModel { Self::default() } + /// Test helper: inserts server conversation metadata keyed by `server_token` directly into + /// `all_conversations_metadata`, simulating the side-effect produced by + /// `fetch_server_conversation_metadata_if_needed` after a successful server response. This lets + /// unit tests exercise `resolve_cloud_conversation_continuation_ui_state` with the metadata + /// the background fetch would provide (e.g. for API-triggered Claude Code runs whose + /// conversations were never materialized as a local Oz conversation). + #[cfg(test)] + pub(crate) fn insert_server_metadata_for_test( + &mut self, + server_token: ServerConversationToken, + metadata: ServerAIConversationMetadata, + ) { + let conversation_id = + self.get_or_set_canonical_conversation_id_for_server_token(&server_token); + let entry = AIConversationMetadata::from_server_metadata(conversation_id, metadata); + self.all_conversations_metadata + .insert(conversation_id, entry); + self.server_token_to_conversation_id + .insert(server_token, conversation_id); + } + /// Returns a flattened and ordered (oldest first) list of live conversations for a terminal surface. /// This works for terminal surfaces that have been closed. pub fn all_live_conversations_for_terminal_surface( @@ -2573,6 +2601,83 @@ impl BlocklistAIHistoryModel { }) } + /// Starts a background fetch of server conversation metadata for `token` if it is not already + /// in the local model. + /// + /// This is used to populate the metadata for ambient agent tasks whose conversations were + /// never materialized as a local Oz conversation (e.g. third-party harness runs such as + /// Claude Code that were started via the Warp REST API). Without this, the tombstone CTA + /// resolver (`resolve_cloud_conversation_continuation_ui_state`) cannot determine the + /// correct access level and falls back to `Tombstone { cta: None }` instead of showing the + /// "Continue" button. + /// + /// On success the metadata is inserted into `all_conversations_metadata` under a canonical + /// conversation ID keyed by the token, and `UpdatedConversationMetadata` is emitted so that + /// `TerminalView::maybe_insert_tombstone_for_non_running_shared_ambient_task` re-resolves the + /// tombstone CTA. Concurrent and duplicate calls are no-ops while a fetch is in flight. + pub fn fetch_server_conversation_metadata_if_needed( + &mut self, + token: ServerConversationToken, + ctx: &mut ModelContext, + ) { + // Nothing to do if we already have the metadata. + if self + .get_server_conversation_metadata_by_server_token(&token) + .is_some() + { + return; + } + // Don't start a duplicate fetch while one is already in flight. + if self.pending_server_metadata_fetch_tokens.contains(&token) { + return; + } + self.pending_server_metadata_fetch_tokens + .insert(token.clone()); + let token_str = token.as_str().to_string(); + let server_api = ServerApiProvider::as_ref(ctx).get_ai_client(); + ctx.spawn( + async move { + server_api + .list_ai_conversation_metadata(Some(vec![token_str])) + .await + }, + move |model, result, ctx| { + model.pending_server_metadata_fetch_tokens.remove(&token); + let Ok(mut metadata_list) = result else { + return; + }; + let Some(metadata) = metadata_list.pop() else { + return; + }; + // Use the token embedded in the returned metadata to key the entry so the + // reverse-index lookup in `get_server_conversation_metadata_by_server_token` + // finds it. (The server may normalize the token slightly.) + let metadata_token = metadata.server_conversation_token.clone(); + // Skip if another path already stored metadata for this token while the fetch + // was in flight. + if model + .get_server_conversation_metadata_by_server_token(&metadata_token) + .is_some() + { + return; + } + let conversation_id = + model.get_or_set_canonical_conversation_id_for_server_token(&metadata_token); + let entry = AIConversationMetadata::from_server_metadata(conversation_id, metadata); + model + .all_conversations_metadata + .insert(conversation_id, entry); + model + .server_token_to_conversation_id + .insert(metadata_token, conversation_id); + ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationMetadata { + terminal_surface_id: None, + conversation_id, + }); + }, + ); + } + /// Finds an AIConversationId by its server conversation token. /// /// O(1) lookup via `server_token_to_conversation_id`, which is maintained diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index bd58a70ab58..54cd5af6ecf 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -8109,6 +8109,26 @@ impl TerminalView { return; } + // For ambient agent tasks whose conversations were never materialized as a local Oz + // conversation (e.g. third-party harness runs started via the Warp REST API), the + // server conversation metadata won't be in `BlocklistAIHistoryModel`. Without it, + // `resolve_cloud_conversation_continuation_ui_state` may fall back to checking only + // `task.creator.uid` — which can mismatch for API-key-based auth — and end up + // returning no CTA. Kick off a background metadata fetch so the tombstone can be + // re-resolved with the correct access level once the server responds. + if let Some(conversation_token_str) = task.conversation_id() { + let conversation_token = + ServerConversationToken::new(conversation_token_str.to_string()); + if BlocklistAIHistoryModel::as_ref(ctx) + .get_server_conversation_metadata_by_server_token(&conversation_token) + .is_none() + { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.fetch_server_conversation_metadata_if_needed(conversation_token, ctx); + }); + } + } + if FeatureFlag::HandoffCloudCloud.is_enabled() { if is_active_shared_session { return; diff --git a/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs b/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs index 564fa3a7f08..ea0bcc8e9e7 100644 --- a/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs +++ b/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs @@ -736,6 +736,78 @@ fn owned_third_party_task_without_metadata_shows_continue_in_cloud_tombstone() { }); } +/// Simulates the end-to-end fix for API-triggered Claude Code runs. When the task was started +/// via the Oz REST API (so `task.creator.uid` is a service-account UID that does NOT match the +/// logged-in user's Firebase UID) and no local Oz conversation was ever created, server +/// conversation metadata is initially absent from `BlocklistAIHistoryModel`. The background +/// fetch triggered by `maybe_insert_tombstone_for_non_running_shared_ambient_task` (via +/// `fetch_server_conversation_metadata_if_needed`) populates the metadata, which is stored in +/// `all_conversations_metadata`. After that, `resolve_cloud_conversation_continuation_ui_state` +/// finds the metadata, sees the current user is the space owner, and returns the Continue CTA. +#[test] +fn api_triggered_third_party_run_shows_continue_cta_after_metadata_fetch() { + App::test((), |mut app| async move { + let _agent_management_guard = FeatureFlag::AgentManagementView.override_enabled(false); + // Auth: current user is logged in as TEST_USER_UID. + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(UserWorkspaces::default_mock); + app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + app.add_singleton_model(AgentConversationsModel::new); + + let terminal_view_id = EntityId::new(); + let task_id = ambient_task_id(1); + + // The task was created by a service account whose UID does NOT match the current user. + // This is the API-triggered case where `task_creator_access` returns `Unknown`. + let task = ambient_agent_task( + task_id, + CONVERSATION_TOKEN, + AmbientAgentTaskState::Succeeded, + ) + .with_creator("some-api-service-account-uid") + .with_harness(Harness::Claude); + AgentConversationsModel::handle(&app).update(&mut app, |model, _| { + model.insert_task_for_test(task); + }); + + // Verify the initial state: no server metadata → returns an error (no CTA). + app.update(|ctx| { + assert_eq!( + resolve_cloud_conversation_continuation_ui_state(terminal_view_id, task_id, ctx), + Err(CloudConversationContinuationError::MissingServerConversationMetadata), + "should fail before metadata is fetched" + ); + }); + + // Simulate what `fetch_server_conversation_metadata_if_needed` does when the server + // returns metadata showing the current user as the space owner. This represents the + // state AFTER the background fetch has completed and populated the model. + let metadata = server_conversation_metadata( + AIAgentHarness::ClaudeCode, + ConversationPermissionFixture::CurrentUserOwner, + Some(task_id), + None, + ); + BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, _| { + model.insert_server_metadata_for_test( + ServerConversationToken::new(CONVERSATION_TOKEN.to_string()), + metadata, + ); + }); + + // After metadata arrives the resolver finds the current user as owner → Continue CTA. + app.update(|ctx| { + assert_eq!( + resolve_cloud_conversation_continuation_ui_state(terminal_view_id, task_id, ctx), + Ok(CloudConversationContinuationUiState::Tombstone { + cta: Some(TombstoneCta::ContinueInCloud { task_id }), + }), + "should show Continue CTA once metadata is populated" + ); + }); + }); +} + #[test] fn active_task_execution_returns_error() { App::test((), |mut app| async move {