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).
350 lines
14 KiB
Rust
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()),
|
|
}
|
|
}
|