edit_prediction: Add Mercury accept/reject tracking (#48306)

### 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>
This commit is contained in:
Kenan Hasanaliyev 2026-02-04 13:19:33 -08:00 committed by GitHub
parent 477bb89f10
commit e39c1906e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 219 additions and 43 deletions

View file

@ -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;

View file

@ -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,

View file

@ -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
}
}

View file

@ -93,8 +93,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
@ -1125,16 +1130,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,
);
});
@ -1164,12 +1173,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,
);
}
});
@ -1195,11 +1206,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,
);
});
@ -1213,11 +1226,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,
);
});

View file

@ -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";
@ -324,3 +328,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);
}

View file

@ -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;
};

View file

@ -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};
@ -178,7 +184,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,
@ -214,7 +220,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>,
@ -292,8 +298,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) {

View file

@ -623,7 +623,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,
@ -694,7 +699,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,

View file

@ -96,8 +96,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};
@ -8086,7 +8086,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)

View file

@ -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};
@ -201,7 +203,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);
}