//! `kigi/feedback`, `kigi/feedback/dismiss`, `kigi/btw`, and `kigi/review/*` //! extension handlers. //! //! - `feedback`/`feedback/dismiss`: persist user ratings/text locally; text //! feedback from subscription (OAuth) sessions is forwarded to the Kimi //! Code feedback endpoint (`POST {base}/feedback`, kimi-cli slash.py //! parity). Without a subscription session the record stays local and the //! response points at the GitHub issue tracker. //! - `btw`: dispatch a side question to the active session via //! `SessionCommand::SideQuestion` and return the answer. //! - `review/comment` and `review/comment/delete`: record inline code review //! events locally. use std::sync::Arc; use agent_client_protocol as acp; use tokio::sync::oneshot; use super::{ExtResult, parse_params}; use crate::agent::MvpAgent; use crate::agent::feedback_client::FEEDBACK_ISSUES_URL; use crate::session::persistence::{LocalFeedbackEntry, UserFeedbackEntry}; use crate::session::{ ClientFeedbackInput, CommentDeleteRequest, CommentDeleteResponse, CommentRequest, CommentResponse, FeedbackRequestDismiss, FeedbackResponse, SessionCommand, }; #[tracing::instrument(skip_all, fields(method = %args.method))] pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { match args.method.as_ref() { "kigi/btw" => { tracing::info!("handling /btw side question"); handle_btw(agent, args).await } "kigi/feedback" | "kigi/feedback/dismiss" => { tracing::info!("handling user feedback"); handle_feedback(agent, args).await } m if m.starts_with("kigi/review") => { tracing::info!("handling review comment"); handle_review(args).await } _ => Err(acp::Error::method_not_found()), } } /// Handle `kigi/btw` -- a side question that doesn't interrupt the current turn. async fn handle_btw(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct BtwRequest { session_id: String, question: String, } let req: BtwRequest = parse_params(args)?; let sid: acp::SessionId = req.session_id.clone().into(); let session_handle = { let sessions = agent.sessions.borrow(); sessions.get(&sid).cloned() }; let Some(session) = session_handle else { return Err( acp::Error::invalid_params().data(format!("session not found: {}", req.session_id)) ); }; let (tx, rx) = oneshot::channel(); let _ = session.cmd_tx.send(SessionCommand::SideQuestion { question: req.question, respond_to: tx, }); let result = rx .await .map_err(|_| acp::Error::internal_error().data("session failed to respond"))?; match result { Ok(answer) => super::to_ext_response(Ok(serde_json::json!({ "answer": answer, }))), Err(e) => Err(acp::Error::internal_error().data(e)), } } async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { if !agent.cfg.borrow().is_feedback_enabled() { return Err(acp::Error::internal_error().data( "Feedback is disabled. To enable, set KIGI_FEEDBACK_ENABLED=true or \ [features] feedback = true in config.toml.", )); } match args.method.as_ref() { "kigi/feedback" => { // Parse the input -- try the full ClientFeedbackInput first, // then fall back to the simple FeedbackRequest (from /feedback slash command) // which only has {session_id, feedback_text} and no client_type. let feedback_input: ClientFeedbackInput = match serde_json::from_str::(args.params.get()) { Ok(input) => input, Err(_) => { let simple: crate::session::FeedbackRequest = parse_params(args)?; ClientFeedbackInput { session_id: simple.session_id, client_type: crate::session::feedback_types::ClientType::Tui, rating_type: None, rating_value: None, feedback_text: Some(simple.feedback_text), feedback_categories: vec![], context_type: None, turn_number: None, request_id: None, client_version: None, metadata: None, terminal_info: None, } } }; let session_id = acp::SessionId::new(feedback_input.session_id.clone()); let session_handle = agent.sessions.borrow().get(&session_id).cloned(); let (model_id, model_metadata) = if let Some(ref session) = session_handle { let (tx1, rx1) = tokio::sync::oneshot::channel(); let _ = session .cmd_tx .send(SessionCommand::GetCurrentModel { responds_to: tx1 }); let model_id = rx1.await.ok(); let model_metadata = session.get_model_metadata().await; (model_id, model_metadata) } else { let sampling_config = agent.sampling_config.borrow().clone(); (Some(sampling_config.model.clone()), Default::default()) }; let turn_number = feedback_input.turn_number.or_else(|| { agent .session_turn_number(&session_id) .map(|t| t.saturating_sub(1) as i64) }); let mut submission = feedback_input.to_submission( model_id.clone(), model_metadata.resolved_model_id, model_metadata.model_fingerprint, turn_number, ); let turn_number = submission.turn_number; if let Some(user_meta) = crate::agent::mvp_agent::parse_json_object_env("KIGI_USER_METADATA") { submission.merge_metadata(user_meta); } // Enrich with session context (persisted alongside the record). if let Some(ref session_handle) = session_handle { let (tx, rx) = tokio::sync::oneshot::channel(); let _ = session_handle .cmd_tx .send(SessionCommand::GetFeedbackContext { turn_number, responds_to: tx, }); if let Ok(ctx) = rx.await { submission.tool_outcomes = ctx.tool_outcomes; submission.session_cwd = Some(ctx.session_cwd); submission.compaction_count = Some(ctx.compaction_count); submission.context_window_usage = Some(ctx.context_window_usage); submission.context_tokens_used = Some(ctx.context_tokens_used); submission.context_window_tokens = Some(ctx.context_window_tokens); } } if let (Some(session_handle), Some(rating_value)) = (&session_handle, feedback_input.rating_value) { use crate::session::feedback_types::RatingType; let (is_positive, is_negative) = match feedback_input.rating_type { // Thumbs: -1 = down, 0 = neutral, 1 = up Some(RatingType::Thumbs) | None => (rating_value > 0, rating_value < 0), // Stars (1-5): >= 4 positive, <= 2 negative, 3 neutral Some(RatingType::Stars) => (rating_value >= 4, rating_value <= 2), // NPS (0-10): 9-10 promoter, 0-6 detractor, 7-8 passive Some(RatingType::Nps) => (rating_value >= 9, rating_value <= 6), }; if is_positive { session_handle.signals_handle.record_positive_rating(); } else if is_negative { session_handle.signals_handle.record_negative_rating(); } } if feedback_input.is_solicited() { tracing::info!( session_id = %feedback_input.session_id, request_id = ?feedback_input.request_id(), turn_number = ?turn_number, "Solicited feedback received (response to feedback request)" ); } else { tracing::info!( session_id = %feedback_input.session_id, turn_number = ?turn_number, "Spontaneous user feedback received" ); } let client = agent.feedback_client(); if client.is_none() { tracing::info!( "no subscription session; feedback saved locally — submit at {FEEDBACK_ISSUES_URL}" ); } let outcome = crate::session::feedback_manager::submit_feedback_workflow( &mut submission, client.as_ref(), session_handle.as_ref().map(|h| &h.persistence_tx), feedback_input.is_solicited(), ) .await; match &outcome { crate::session::feedback_manager::SubmitOutcome::Submitted => { tracing::info!("feedback submitted to the Kimi Code endpoint"); } crate::session::feedback_manager::SubmitOutcome::LocalOnly => { tracing::info!("feedback saved locally only"); } crate::session::feedback_manager::SubmitOutcome::Failed(e) => { tracing::error!(error = %e, "feedback submission failed"); return Err(acp::Error::internal_error().data(format!( "Feedback submission failed: {e}. \ Please submit feedback at {FEEDBACK_ISSUES_URL}" ))); } } let value = serde_json::to_value(FeedbackResponse { success: true }) .map(|value| serde_json::value::to_raw_value(&value).map(Arc::from)) .expect("to work") .expect("to work"); Ok(acp::ExtResponse::new(value)) } "kigi/feedback/dismiss" => { let dismiss_input: FeedbackRequestDismiss = parse_params(args)?; tracing::info!( session_id = %dismiss_input.session_id, request_id = %dismiss_input.request_id, "Feedback request dismissed by user" ); // Count dismissals too (else event_type is always "responded" and // response-rate is unknowable). { tracing::info_span!( "feedback.survey", survey_type = "session", event_type = "dismissed", appearance_id = %dismiss_input.request_id, has_feedback_text = false, is_solicited = true, ) .in_scope(|| {}); } // Persist dismiss locally; flushed before storage CopyFile by the persistence actor. { let session_id = acp::SessionId::new(dismiss_input.session_id.clone()); if let Some(session_handle) = agent.sessions.borrow().get(&session_id) { session_handle.persist_feedback(LocalFeedbackEntry::UserFeedback( UserFeedbackEntry { submitted_at: chrono::Utc::now(), session_id: dismiss_input.session_id.clone(), turn_number: None, solicited: true, request_id: Some(dismiss_input.request_id.clone()), dismissed: true, submission: None, }, )); } } let value = serde_json::to_value(serde_json::json!({ "requestId": dismiss_input.request_id, "status": "dismissed", })) .map(|value| serde_json::value::to_raw_value(&value).map(Arc::from)) .expect("to work") .expect("to work"); Ok(acp::ExtResponse::new(value)) } _ => Err(acp::Error::method_not_found()), } } /// Record inline code review events. /// /// Methods: /// - `kigi/review/comment`: record a new inline code comment /// - `kigi/review/comment/delete`: record a tombstone event for a deleted comment async fn handle_review(args: &acp::ExtRequest) -> ExtResult { match args.method.as_ref() { "kigi/review/comment" => { let request: CommentRequest = parse_params(args)?; let comment_id = uuid::Uuid::now_v7().to_string(); tracing::info!( comment_id = %comment_id, session_id = %request.session_id, prompt_index = request.prompt_index, path = %request.citation.path, lines = %format!("{}-{}", request.citation.start_line, request.citation.end_line), "Comment received" ); let value = serde_json::to_value(CommentResponse { comment_id, recorded: true, }) .map(|value| serde_json::value::to_raw_value(&value).map(Arc::from)) .expect("to work") .expect("to work"); Ok(acp::ExtResponse::new(value)) } "kigi/review/comment/delete" => { let request: CommentDeleteRequest = parse_params(args)?; tracing::info!( comment_id = %request.comment_id, session_id = %request.session_id, "Comment delete received" ); let value = serde_json::to_value(CommentDeleteResponse { comment_id: request.comment_id, deleted: true, }) .map(|value| serde_json::value::to_raw_value(&value).map(Arc::from)) .expect("to work") .expect("to work"); Ok(acp::ExtResponse::new(value)) } _ => Err(acp::Error::method_not_found()), } }