Files
Kigi-CLI/crates/codegen/kigi-shell/src/extensions/feedback.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

350 lines
14 KiB
Rust

//! `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::<ClientFeedbackInput>(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()),
}
}