Skip to content
Open
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
1 change: 1 addition & 0 deletions src-tauri/src/commands/feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@ mod tests {
enabled: Some(false),
project_key: Some("CUSTOM".to_string()),
response_rating_enabled: None,
session_survey_sampling_rate_basis_points: None,
}));
assert!(!feedback_enabled(&disabled));
assert_eq!(feedback_project_key(&disabled), "CUSTOM");
Expand Down
171 changes: 171 additions & 0 deletions src-tauri/src/commands/feedback_survey.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
use std::{fs, io::Write, path::PathBuf, sync::Mutex, time::SystemTime};

use serde::Deserialize;

const SESSION_SURVEY_COOLDOWN_FILE: &str = "session-feedback-survey-cooldown-v1";
const SESSION_SURVEY_COOLDOWN_MINIMUM_MS: u64 = 27 * 60 * 60 * 1_000;
const SESSION_SURVEY_COOLDOWN_JITTER_MS: u64 = 2 * 60 * 60 * 1_000;

pub struct SessionFeedbackSurveyCooldownState {
path: PathBuf,
next_eligible_at_ms: Mutex<u64>,
}

impl SessionFeedbackSurveyCooldownState {
pub fn new(app_data_dir: PathBuf) -> Self {
let path = app_data_dir.join(SESSION_SURVEY_COOLDOWN_FILE);
let next_eligible_at_ms = fs::read_to_string(&path)
.ok()
.and_then(|value| value.trim().parse().ok())
.unwrap_or(0);
Self {
path,
next_eligible_at_ms: Mutex::new(next_eligible_at_ms),
}
}

fn claim(
&self,
input: SessionFeedbackSurveyCooldownInput,
now_ms: u64,
) -> Result<bool, String> {
if input.sampling_rate_basis_points > 10_000
|| !input.random.is_finite()
|| !(0.0..=1.0).contains(&input.random)
|| !input.cooldown_random.is_finite()
|| !(0.0..=1.0).contains(&input.cooldown_random)
{
return Err("invalid session feedback survey cooldown input".to_string());
}
let mut next_eligible_at_ms = self
.next_eligible_at_ms
.lock()
.map_err(|_| "session feedback survey cooldown lock poisoned".to_string())?;
if input.sampling_rate_basis_points == 0
|| now_ms < *next_eligible_at_ms
|| input.random * 10_000.0 >= f64::from(input.sampling_rate_basis_points)
{
return Ok(false);
}

let jitter_ms = (input.cooldown_random * SESSION_SURVEY_COOLDOWN_JITTER_MS as f64) as u64;
let claimed_until = now_ms
.saturating_add(SESSION_SURVEY_COOLDOWN_MINIMUM_MS)
.saturating_add(jitter_ms);
let parent = self
.path
.parent()
.ok_or_else(|| "survey cooldown path has no parent".to_string())?;
let mut part_file = tempfile::NamedTempFile::new_in(parent)
.map_err(|error| format!("failed to persist survey cooldown: {error}"))?;
part_file
.write_all(claimed_until.to_string().as_bytes())
.map_err(|error| format!("failed to persist survey cooldown: {error}"))?;
part_file
.persist(&self.path)
.map_err(|error| format!("failed to finalize survey cooldown: {error}"))?;
*next_eligible_at_ms = claimed_until;
Ok(true)
}
}

#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SessionFeedbackSurveyCooldownInput {
sampling_rate_basis_points: u16,
random: f64,
cooldown_random: f64,
}

#[tauri::command]
pub fn claim_session_feedback_survey_cooldown(
state: tauri::State<'_, SessionFeedbackSurveyCooldownState>,
input: SessionFeedbackSurveyCooldownInput,
) -> Result<bool, String> {
let now_ms = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|error| format!("system clock is before Unix epoch: {error}"))?
.as_millis()
.try_into()
.map_err(|_| "system time does not fit in milliseconds".to_string())?;
state.claim(input, now_ms)
}

#[cfg(test)]
mod tests {
use std::sync::{Arc, Barrier};

use super::*;

#[test]
fn cooldown_claim_is_atomic_and_persistent() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(SessionFeedbackSurveyCooldownState::new(
dir.path().to_path_buf(),
));
let barrier = Arc::new(Barrier::new(3));
let input = SessionFeedbackSurveyCooldownInput {
sampling_rate_basis_points: 250,
random: 0.0,
cooldown_random: 0.0,
};
let handles: Vec<_> = (0..2)
.map(|_| {
let state = Arc::clone(&state);
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
state.claim(input, 1_000).unwrap()
})
})
.collect();
barrier.wait();
let selected = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.filter(|selected| *selected)
.count();
assert_eq!(selected, 1);

let reloaded = SessionFeedbackSurveyCooldownState::new(dir.path().to_path_buf());
assert!(!reloaded
.claim(input, 1_000 + SESSION_SURVEY_COOLDOWN_MINIMUM_MS - 1)
.unwrap());
assert!(reloaded
.claim(input, 1_000 + SESSION_SURVEY_COOLDOWN_MINIMUM_MS)
.unwrap());
}

#[test]
fn cooldown_applies_jitter_and_validates_input() {
let dir = tempfile::tempdir().unwrap();
let state = SessionFeedbackSurveyCooldownState::new(dir.path().to_path_buf());
let input = SessionFeedbackSurveyCooldownInput {
sampling_rate_basis_points: 10_000,
random: 0.0,
cooldown_random: 1.0,
};
assert!(state.claim(input, 1_000).unwrap());
assert!(!state
.claim(
input,
1_000 + SESSION_SURVEY_COOLDOWN_MINIMUM_MS + SESSION_SURVEY_COOLDOWN_JITTER_MS - 1,
)
.unwrap());
assert!(state
.claim(
input,
1_000 + SESSION_SURVEY_COOLDOWN_MINIMUM_MS + SESSION_SURVEY_COOLDOWN_JITTER_MS,
)
.unwrap());
assert!(state
.claim(
SessionFeedbackSurveyCooldownInput {
random: f64::NAN,
..input
},
u64::MAX,
)
.is_err());
}
}
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod distro;
pub mod doctor;
#[cfg(feature = "block-feedback")]
pub mod feedback;
pub mod feedback_survey;
pub mod git;
pub mod git_changes;
pub mod global_shortcut;
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/commands/runtime_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ pub struct RuntimeFeedbackConfig {
pub project_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub response_rating_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub session_survey_sampling_rate_basis_points: Option<u16>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down Expand Up @@ -773,6 +775,11 @@ fn validate_runtime_config(config: &RuntimeConfig) -> Result<(), String> {
}
if let Some(feedback) = &config.feedback {
validate_optional_non_empty(feedback.project_key.as_deref(), "feedback.projectKey")?;
if feedback.session_survey_sampling_rate_basis_points > Some(10_000) {
return Err(
"feedback.sessionSurveySamplingRateBasisPoints must be at most 10000".to_string(),
);
}
}
if let Some(kgoose) = &config.kgoose {
validate_kgoose(kgoose)?;
Expand Down Expand Up @@ -1170,6 +1177,7 @@ mod tests {
enabled: Some(true),
project_key: Some("BOT".to_string()),
response_rating_enabled: Some(true),
session_survey_sampling_rate_basis_points: Some(250),
}),
kgoose: Some(RuntimeKgooseConfig {
base_url: Some("https://kgoose.example.test".to_string()),
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ pub fn run() {
app_data_dir.clone(),
bundled_runtime_config_path,
));
app.manage(
commands::feedback_survey::SessionFeedbackSurveyCooldownState::new(
app_data_dir.clone(),
),
);
// Construct and register the distro bundle up front (goose serve and
// runtime-config readiness both depend on it). Seeding its bundled
// skills/agents is filesystem work and is deferred below.
Expand Down Expand Up @@ -535,6 +540,7 @@ pub fn run() {
commands::doctor::run_doctor_fix,
#[cfg(feature = "block-feedback")]
commands::feedback::submit_feedback_issue,
commands::feedback_survey::claim_session_feedback_survey_cooldown,
commands::git::get_git_state,
commands::git_changes::get_changed_files,
commands::git::git_switch_branch,
Expand Down
120 changes: 120 additions & 0 deletions src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionFeedbackSurvey } from "./SessionFeedbackSurvey";
import {
markSessionFeedbackSurveyAppeared,
recordSessionFeedbackSurveyResponse,
} from "./sessionFeedbackSurveyState";

vi.mock("./sessionFeedbackSurveyState", () => ({
isSessionFeedbackSurveyActive: vi.fn(() => true),
markSessionFeedbackSurveyAppeared: vi.fn(),
recordSessionFeedbackSurveyResponse: vi.fn(),
}));

class MockIntersectionObserver {
static isIntersecting = true;

constructor(private callback: IntersectionObserverCallback) {}
observe() {
this.callback(
[
{
isIntersecting: MockIntersectionObserver.isIntersecting,
} as IntersectionObserverEntry,
],
this as never,
);
}
disconnect() {}
}

describe("SessionFeedbackSurvey", () => {
beforeEach(() => {
MockIntersectionObserver.isIntersecting = true;
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
});

afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});

it("defaults focus to dismiss and records its appearance", () => {
render(
<SessionFeedbackSurvey
sessionId="session"
survey={{ appearanceId: "appearance", messageId: "message" }}
/>,
);

expect(screen.getByRole("button", { name: "Dismiss" })).toHaveFocus();
expect(markSessionFeedbackSurveyAppeared).toHaveBeenCalledWith(
"session",
"appearance",
);
});

it("renders without interaction while measuring offscreen", () => {
render(
<SessionFeedbackSurvey
sessionId="session"
survey={{ appearanceId: "appearance", messageId: "message" }}
measurementOnly
/>,
);

const dismiss = screen.getByRole("button", { name: "Dismiss" });
expect(dismiss).not.toHaveFocus();
expect(dismiss).toHaveAttribute("tabindex", "-1");
expect(markSessionFeedbackSurveyAppeared).not.toHaveBeenCalled();

fireEvent.click(dismiss);
fireEvent.keyDown(window, { key: "Escape" });
expect(recordSessionFeedbackSurveyResponse).not.toHaveBeenCalled();
});

it("treats Escape as dismiss", () => {
render(
<SessionFeedbackSurvey
sessionId="session"
survey={{ appearanceId: "appearance", messageId: "message" }}
/>,
);
fireEvent.keyDown(window, { key: "Escape" });

expect(recordSessionFeedbackSurveyResponse).toHaveBeenCalledWith(
"session",
"appearance",
"dismissed",
);
});

it("ignores Escape outside the viewport", () => {
MockIntersectionObserver.isIntersecting = false;
render(
<SessionFeedbackSurvey
sessionId="session"
survey={{ appearanceId: "appearance", messageId: "message" }}
/>,
);
fireEvent.keyDown(window, { key: "Escape" });
expect(recordSessionFeedbackSurveyResponse).not.toHaveBeenCalled();
});

it("ignores Escape after focus leaves", () => {
render(
<SessionFeedbackSurvey
sessionId="session"
survey={{ appearanceId: "appearance", messageId: "message" }}
/>,
);
const otherButton = document.createElement("button");
document.body.append(otherButton);
otherButton.focus();
fireEvent.keyDown(window, { key: "Escape" });
otherButton.remove();
expect(recordSessionFeedbackSurveyResponse).not.toHaveBeenCalled();
});
});
Loading