edit_prediction: Add Mercury accept/reject tracking (#48306) (cherry-pick to stable) (#48403)

zed-zippy[bot] , Kenan Hasanaliyev , and Ben Kunkle created

Cherry-pick of #48306 to stable

----
### Summary
Adds accept/reject tracking for Mercury edit predictions.

### Changes
Sends events to https://api-feedback.inceptionlabs.ai/feedback when:

Accept β€” user presses Tab
Reject β€” user presses Escape
Ignore β€” prediction dismissed implicitly (typing, cursor move, etc.)

Added `discard_explicit` method to the delegate trait to distinguish
explicit vs implicit dismissal. Updated `reject_prediction` and
`reject_current_prediction` methods with an `explicit` bool parameter to
thread this through to the Mercury feedback logic. Other providers are
unaffectedβ€”they use the default implementation.

Feedback is fire-and-forget in a background thread, only sent for
predictions that were shown.

### Data Collected
- Request ID (returned from Inception API)
- User action (either accept/reject/ignore)
- Client Zed version (to track updates made to Zed client which could
potentially affect nextedit implementation)


Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>

Co-authored-by: Kenan Hasanaliyev <claserken@gmail.com>
Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

crates/codestral/src/codestral.rs                            |  6 
crates/copilot/src/copilot_edit_prediction_delegate.rs       |  5 
crates/edit_prediction/src/edit_prediction.rs                | 68 +++-
crates/edit_prediction/src/edit_prediction_tests.rs          | 27 +
crates/edit_prediction/src/mercury.rs                        | 95 +++++
crates/edit_prediction/src/zed_edit_prediction_delegate.rs   | 16 
crates/edit_prediction_types/src/edit_prediction_types.rs    | 14 
crates/editor/src/edit_prediction_tests.rs                   | 14 
crates/editor/src/editor.rs                                  | 11 
crates/supermaven/src/supermaven_edit_prediction_delegate.rs |  6 
10 files changed, 219 insertions(+), 43 deletions(-)

Detailed changes

crates/codestral/src/codestral.rs πŸ”—

@@ -1,6 +1,8 @@
 use anyhow::Result;
 use edit_prediction::cursor_excerpt;
-use edit_prediction_types::{EditPrediction, EditPredictionDelegate, EditPredictionIconSet};
+use edit_prediction_types::{
+    EditPrediction, EditPredictionDelegate, EditPredictionDismissReason, EditPredictionIconSet,
+};
 use futures::AsyncReadExt;
 use gpui::{App, Context, Entity, Task};
 use http_client::HttpClient;
@@ -313,7 +315,7 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate {
         self.current_completion = None;
     }
 
-    fn discard(&mut self, _cx: &mut Context<Self>) {
+    fn discard(&mut self, _reason: EditPredictionDismissReason, _cx: &mut Context<Self>) {
         log::debug!("Codestral: Completion discarded");
         self.pending_request = None;
         self.current_completion = None;

crates/copilot/src/copilot_edit_prediction_delegate.rs πŸ”—

@@ -7,7 +7,8 @@ use crate::{
 };
 use anyhow::Result;
 use edit_prediction_types::{
-    EditPrediction, EditPredictionDelegate, EditPredictionIconSet, interpolate_edits,
+    EditPrediction, EditPredictionDelegate, EditPredictionDismissReason, EditPredictionIconSet,
+    interpolate_edits,
 };
 use gpui::{App, Context, Entity, Task};
 use icons::IconName;
@@ -128,7 +129,7 @@ impl EditPredictionDelegate for CopilotEditPredictionDelegate {
         }
     }
 
-    fn discard(&mut self, _: &mut Context<Self>) {}
+    fn discard(&mut self, _reason: EditPredictionDismissReason, _: &mut Context<Self>) {}
 
     fn suggest(
         &mut self,

crates/edit_prediction/src/edit_prediction.rs πŸ”—

@@ -307,11 +307,13 @@ impl ProjectState {
                     return;
                 };
 
-                this.update(cx, |this, _cx| {
+                this.update(cx, |this, cx| {
                     this.reject_prediction(
                         prediction_id,
                         EditPredictionRejectReason::Canceled,
                         false,
+                        edit_prediction_types::EditPredictionDismissReason::Ignored,
+                        cx,
                     );
                 })
                 .ok();
@@ -1214,7 +1216,14 @@ impl EditPredictionStore {
             EditPredictionModel::Sweep => {
                 sweep_ai::edit_prediction_accepted(self, current_prediction, cx)
             }
-            EditPredictionModel::Mercury | EditPredictionModel::Ollama => {}
+            EditPredictionModel::Mercury => {
+                mercury::edit_prediction_accepted(
+                    current_prediction.prediction.id,
+                    self.client.http_client(),
+                    cx,
+                );
+            }
+            EditPredictionModel::Ollama => {}
             EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 { .. } => {
                 zeta2::edit_prediction_accepted(self, current_prediction, cx)
             }
@@ -1284,11 +1293,19 @@ impl EditPredictionStore {
         &mut self,
         reason: EditPredictionRejectReason,
         project: &Entity<Project>,
+        dismiss_reason: edit_prediction_types::EditPredictionDismissReason,
+        cx: &App,
     ) {
         if let Some(project_state) = self.projects.get_mut(&project.entity_id()) {
             project_state.pending_predictions.clear();
             if let Some(prediction) = project_state.current_prediction.take() {
-                self.reject_prediction(prediction.prediction.id, reason, prediction.was_shown);
+                self.reject_prediction(
+                    prediction.prediction.id,
+                    reason,
+                    prediction.was_shown,
+                    dismiss_reason,
+                    cx,
+                );
             }
         };
     }
@@ -1347,25 +1364,32 @@ impl EditPredictionStore {
         prediction_id: EditPredictionId,
         reason: EditPredictionRejectReason,
         was_shown: bool,
+        dismiss_reason: edit_prediction_types::EditPredictionDismissReason,
+        cx: &App,
     ) {
         match self.edit_prediction_model {
             EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 { .. } => {
-                if self.custom_predict_edits_url.is_some() {
-                    return;
+                if self.custom_predict_edits_url.is_none() {
+                    self.reject_predictions_tx
+                        .unbounded_send(EditPredictionRejection {
+                            request_id: prediction_id.to_string(),
+                            reason,
+                            was_shown,
+                        })
+                        .log_err();
                 }
             }
-            EditPredictionModel::Sweep
-            | EditPredictionModel::Mercury
-            | EditPredictionModel::Ollama => return,
+            EditPredictionModel::Sweep | EditPredictionModel::Ollama => {}
+            EditPredictionModel::Mercury => {
+                mercury::edit_prediction_rejected(
+                    prediction_id,
+                    was_shown,
+                    dismiss_reason,
+                    self.client.http_client(),
+                    cx,
+                );
+            }
         }
-
-        self.reject_predictions_tx
-            .unbounded_send(EditPredictionRejection {
-                request_id: prediction_id.to_string(),
-                reason,
-                was_shown,
-            })
-            .log_err();
     }
 
     fn is_refreshing(&self, project: &Entity<Project>) -> bool {
@@ -1614,6 +1638,8 @@ impl EditPredictionStore {
                                     this.reject_current_prediction(
                                         EditPredictionRejectReason::Replaced,
                                         &project,
+                                        edit_prediction_types::EditPredictionDismissReason::Ignored,
+                                        cx,
                                     );
 
                                     Some(new_prediction)
@@ -1622,6 +1648,8 @@ impl EditPredictionStore {
                                         new_prediction.prediction.id,
                                         EditPredictionRejectReason::CurrentPreferred,
                                         false,
+                                        edit_prediction_types::EditPredictionDismissReason::Ignored,
+                                        cx,
                                     );
                                     None
                                 }
@@ -1630,7 +1658,13 @@ impl EditPredictionStore {
                             }
                         }
                         Err(reject_reason) => {
-                            this.reject_prediction(prediction_result.id, reject_reason, false);
+                            this.reject_prediction(
+                                prediction_result.id,
+                                reject_reason,
+                                false,
+                                edit_prediction_types::EditPredictionDismissReason::Ignored,
+                                cx,
+                            );
                             None
                         }
                     }

crates/edit_prediction/src/edit_prediction_tests.rs πŸ”—

@@ -92,8 +92,13 @@ async fn test_current_state(cx: &mut TestAppContext) {
         assert_matches!(prediction, BufferEditPrediction::Local { .. });
     });
 
-    ep_store.update(cx, |ep_store, _cx| {
-        ep_store.reject_current_prediction(EditPredictionRejectReason::Discarded, &project);
+    ep_store.update(cx, |ep_store, cx| {
+        ep_store.reject_current_prediction(
+            EditPredictionRejectReason::Discarded,
+            &project,
+            edit_prediction_types::EditPredictionDismissReason::Ignored,
+            cx,
+        );
     });
 
     // Prediction for diagnostic in another file
@@ -1124,16 +1129,20 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) {
 async fn test_rejections_flushing(cx: &mut TestAppContext) {
     let (ep_store, mut requests) = init_test_with_fake_client(cx);
 
-    ep_store.update(cx, |ep_store, _cx| {
+    ep_store.update(cx, |ep_store, cx| {
         ep_store.reject_prediction(
             EditPredictionId("test-1".into()),
             EditPredictionRejectReason::Discarded,
             false,
+            edit_prediction_types::EditPredictionDismissReason::Ignored,
+            cx,
         );
         ep_store.reject_prediction(
             EditPredictionId("test-2".into()),
             EditPredictionRejectReason::Canceled,
             true,
+            edit_prediction_types::EditPredictionDismissReason::Ignored,
+            cx,
         );
     });
 
@@ -1163,12 +1172,14 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) {
     );
 
     // Reaching batch size limit sends without debounce
-    ep_store.update(cx, |ep_store, _cx| {
+    ep_store.update(cx, |ep_store, cx| {
         for i in 0..70 {
             ep_store.reject_prediction(
                 EditPredictionId(format!("batch-{}", i).into()),
                 EditPredictionRejectReason::Discarded,
                 false,
+                edit_prediction_types::EditPredictionDismissReason::Ignored,
+                cx,
             );
         }
     });
@@ -1194,11 +1205,13 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) {
     assert_eq!(reject_request.rejections[19].request_id, "batch-69");
 
     // Request failure
-    ep_store.update(cx, |ep_store, _cx| {
+    ep_store.update(cx, |ep_store, cx| {
         ep_store.reject_prediction(
             EditPredictionId("retry-1".into()),
             EditPredictionRejectReason::Discarded,
             false,
+            edit_prediction_types::EditPredictionDismissReason::Ignored,
+            cx,
         );
     });
 
@@ -1212,11 +1225,13 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) {
     drop(_respond_tx);
 
     // Add another rejection
-    ep_store.update(cx, |ep_store, _cx| {
+    ep_store.update(cx, |ep_store, cx| {
         ep_store.reject_prediction(
             EditPredictionId("retry-2".into()),
             EditPredictionRejectReason::Discarded,
             false,
+            edit_prediction_types::EditPredictionDismissReason::Ignored,
+            cx,
         );
     });
 

crates/edit_prediction/src/mercury.rs πŸ”—

@@ -4,14 +4,18 @@ use crate::{
     prediction::EditPredictionResult, zeta1::compute_edits,
 };
 use anyhow::{Context as _, Result};
+use edit_prediction_types::EditPredictionDismissReason;
 use futures::AsyncReadExt as _;
 use gpui::{
     App, AppContext as _, Entity, Global, SharedString, Task,
-    http_client::{self, AsyncBody, Method},
+    http_client::{self, AsyncBody, HttpClient, Method},
 };
 use language::{OffsetRangeExt as _, ToOffset, ToPoint as _};
 use language_model::{ApiKeyState, EnvVar, env_var};
+use release_channel::AppVersion;
+use serde::Serialize;
 use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant};
+
 use zeta_prompt::ZetaPromptInput;
 
 const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions";
@@ -321,3 +325,92 @@ pub fn load_mercury_api_token(cx: &mut App) -> Task<Result<(), language_model::A
         key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx)
     })
 }
+
+const FEEDBACK_API_URL: &str = "https://api-feedback.inceptionlabs.ai/feedback";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+enum MercuryUserAction {
+    Accept,
+    Reject,
+    Ignore,
+}
+
+#[derive(Serialize)]
+struct FeedbackRequest {
+    request_id: SharedString,
+    provider_name: &'static str,
+    user_action: MercuryUserAction,
+    provider_version: String,
+}
+
+pub(crate) fn edit_prediction_accepted(
+    prediction_id: EditPredictionId,
+    http_client: Arc<dyn HttpClient>,
+    cx: &App,
+) {
+    send_feedback(prediction_id, MercuryUserAction::Accept, http_client, cx);
+}
+
+pub(crate) fn edit_prediction_rejected(
+    prediction_id: EditPredictionId,
+    was_shown: bool,
+    dismiss_reason: EditPredictionDismissReason,
+    http_client: Arc<dyn HttpClient>,
+    cx: &App,
+) {
+    if !was_shown {
+        return;
+    }
+    let action = match dismiss_reason {
+        EditPredictionDismissReason::Rejected => MercuryUserAction::Reject,
+        EditPredictionDismissReason::Ignored => MercuryUserAction::Ignore,
+    };
+    send_feedback(prediction_id, action, http_client, cx);
+}
+
+fn send_feedback(
+    prediction_id: EditPredictionId,
+    action: MercuryUserAction,
+    http_client: Arc<dyn HttpClient>,
+    cx: &App,
+) {
+    let request_id = prediction_id.0;
+    let app_version = AppVersion::global(cx);
+    cx.background_spawn(async move {
+        if !request_id.starts_with("cmpl-") {
+            log::warn!(
+                "Mercury feedback: invalid request_id '{}' - must start with 'cmpl-'",
+                request_id
+            );
+            return anyhow::Ok(());
+        }
+
+        let body = FeedbackRequest {
+            request_id,
+            provider_name: "zed",
+            user_action: action,
+            provider_version: app_version.to_string(),
+        };
+
+        let request = http_client::Request::builder()
+            .uri(FEEDBACK_API_URL)
+            .method(Method::POST)
+            .header("Content-Type", "application/json")
+            .body(AsyncBody::from(serde_json::to_vec(&body)?))?;
+
+        let response = http_client.send(request).await?;
+        if !response.status().is_success() {
+            anyhow::bail!("Feedback API returned status: {}", response.status());
+        }
+
+        log::debug!(
+            "Mercury feedback sent: request_id={}, action={:?}",
+            body.request_id,
+            body.user_action
+        );
+
+        anyhow::Ok(())
+    })
+    .detach_and_log_err(cx);
+}

crates/edit_prediction/src/zed_edit_prediction_delegate.rs πŸ”—

@@ -3,7 +3,8 @@ use std::{cmp, sync::Arc};
 use client::{Client, UserStore};
 use cloud_llm_client::EditPredictionRejectReason;
 use edit_prediction_types::{
-    DataCollectionState, EditPredictionDelegate, EditPredictionIconSet, SuggestionDisplayType,
+    DataCollectionState, EditPredictionDelegate, EditPredictionDismissReason,
+    EditPredictionIconSet, SuggestionDisplayType,
 };
 use gpui::{App, Entity, prelude::*};
 use language::{Buffer, ToPoint as _};
@@ -167,9 +168,14 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
         });
     }
 
-    fn discard(&mut self, cx: &mut Context<Self>) {
-        self.store.update(cx, |store, _cx| {
-            store.reject_current_prediction(EditPredictionRejectReason::Discarded, &self.project);
+    fn discard(&mut self, reason: EditPredictionDismissReason, cx: &mut Context<Self>) {
+        self.store.update(cx, |store, cx| {
+            store.reject_current_prediction(
+                EditPredictionRejectReason::Discarded,
+                &self.project,
+                reason,
+                cx,
+            );
         });
     }
 
@@ -207,6 +213,8 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
                 store.reject_current_prediction(
                     EditPredictionRejectReason::InterpolatedEmpty,
                     &self.project,
+                    EditPredictionDismissReason::Ignored,
+                    cx,
                 );
                 return None;
             };

crates/edit_prediction_types/src/edit_prediction_types.rs πŸ”—

@@ -2,6 +2,12 @@ use std::{ops::Range, sync::Arc};
 
 use client::EditPredictionUsage;
 use gpui::{App, Context, Entity, SharedString};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum EditPredictionDismissReason {
+    Rejected,
+    Ignored,
+}
 use icons::IconName;
 use language::{Anchor, Buffer, OffsetRangeExt};
 
@@ -177,7 +183,7 @@ pub trait EditPredictionDelegate: 'static + Sized {
         cx: &mut Context<Self>,
     );
     fn accept(&mut self, cx: &mut Context<Self>);
-    fn discard(&mut self, cx: &mut Context<Self>);
+    fn discard(&mut self, reason: EditPredictionDismissReason, cx: &mut Context<Self>);
     fn did_show(&mut self, _display_type: SuggestionDisplayType, _cx: &mut Context<Self>) {}
     fn suggest(
         &mut self,
@@ -213,7 +219,7 @@ pub trait EditPredictionDelegateHandle {
     );
     fn did_show(&self, display_type: SuggestionDisplayType, cx: &mut App);
     fn accept(&self, cx: &mut App);
-    fn discard(&self, cx: &mut App);
+    fn discard(&self, reason: EditPredictionDismissReason, cx: &mut App);
     fn suggest(
         &self,
         buffer: &Entity<Buffer>,
@@ -291,8 +297,8 @@ where
         self.update(cx, |this, cx| this.accept(cx))
     }
 
-    fn discard(&self, cx: &mut App) {
-        self.update(cx, |this, cx| this.discard(cx))
+    fn discard(&self, reason: EditPredictionDismissReason, cx: &mut App) {
+        self.update(cx, |this, cx| this.discard(reason, cx))
     }
 
     fn did_show(&self, display_type: SuggestionDisplayType, cx: &mut App) {

crates/editor/src/edit_prediction_tests.rs πŸ”—

@@ -492,7 +492,12 @@ impl EditPredictionDelegate for FakeEditPredictionDelegate {
 
     fn accept(&mut self, _cx: &mut gpui::Context<Self>) {}
 
-    fn discard(&mut self, _cx: &mut gpui::Context<Self>) {}
+    fn discard(
+        &mut self,
+        _reason: edit_prediction_types::EditPredictionDismissReason,
+        _cx: &mut gpui::Context<Self>,
+    ) {
+    }
 
     fn suggest<'a>(
         &mut self,
@@ -563,7 +568,12 @@ impl EditPredictionDelegate for FakeNonZedEditPredictionDelegate {
 
     fn accept(&mut self, _cx: &mut gpui::Context<Self>) {}
 
-    fn discard(&mut self, _cx: &mut gpui::Context<Self>) {}
+    fn discard(
+        &mut self,
+        _reason: edit_prediction_types::EditPredictionDismissReason,
+        _cx: &mut gpui::Context<Self>,
+    ) {
+    }
 
     fn suggest<'a>(
         &mut self,

crates/editor/src/editor.rs πŸ”—

@@ -92,8 +92,8 @@ use convert_case::{Case, Casing};
 use dap::TelemetrySpawnLocation;
 use display_map::*;
 use edit_prediction_types::{
-    EditPredictionDelegate, EditPredictionDelegateHandle, EditPredictionGranularity,
-    SuggestionDisplayType,
+    EditPredictionDelegate, EditPredictionDelegateHandle, EditPredictionDismissReason,
+    EditPredictionGranularity, SuggestionDisplayType,
 };
 use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings};
 use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap, layout_line};
@@ -8021,7 +8021,12 @@ impl Editor {
         }
 
         if let Some(provider) = self.edit_prediction_provider() {
-            provider.discard(cx);
+            let reason = if should_report_edit_prediction_event {
+                EditPredictionDismissReason::Rejected
+            } else {
+                EditPredictionDismissReason::Ignored
+            };
+            provider.discard(reason, cx);
         }
 
         self.take_active_edit_prediction(cx)

crates/supermaven/src/supermaven_edit_prediction_delegate.rs πŸ”—

@@ -1,6 +1,8 @@
 use crate::{Supermaven, SupermavenCompletionStateId};
 use anyhow::Result;
-use edit_prediction_types::{EditPrediction, EditPredictionDelegate, EditPredictionIconSet};
+use edit_prediction_types::{
+    EditPrediction, EditPredictionDelegate, EditPredictionDismissReason, EditPredictionIconSet,
+};
 use futures::StreamExt as _;
 use gpui::{App, Context, Entity, EntityId, Task};
 use language::{Anchor, Buffer, BufferSnapshot};
@@ -200,7 +202,7 @@ impl EditPredictionDelegate for SupermavenEditPredictionDelegate {
         reset_completion_cache(self, _cx);
     }
 
-    fn discard(&mut self, _cx: &mut Context<Self>) {
+    fn discard(&mut self, _reason: EditPredictionDismissReason, _cx: &mut Context<Self>) {
         reset_completion_cache(self, _cx);
     }