Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions app/src/ai/blocklist/history_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,13 @@ pub struct BlocklistAIHistoryModel {
/// In-flight optimistic conversation rename state keyed by conversation.
in_flight_conversation_renames: HashMap<AIConversationId, InFlightConversationRename>,

/// 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<ServerConversationToken>,

#[cfg(feature = "local_fs")]
db_connection: Option<Arc<Mutex<SqliteConnection>>>,
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<Self>,
) {
// 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
Expand Down
20 changes: 20 additions & 0 deletions app/src/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down