F3: Kimi inference pipeline + full grok cloud-surface excision

Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
  dialect (thinking-field mapping, model_id stripping, empty-content
  tool-call message fix, stream_options.include_usage), with kimi-cli
  source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
  refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
  x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)

Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
  share command, credit bar: deleted (single local session lane;
  paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
  gate/tier/team/ZDR fields, app/subscription.rs watch loop,
  dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
  chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
  SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
  RPC; Kigi is zero-egress — nothing to share or retain remotely)

Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
  (https://www.kimi.com/code/authorize_device?user_code=..., verified
  against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
  URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
  wiremock fixture URLs into the real browser (root cause of the
  "garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
  kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
This commit is contained in:
2026-07-17 16:05:51 -04:00
parent fe1f885bb3
commit ea0ce9d15f
231 changed files with 4730 additions and 26358 deletions
@@ -679,8 +679,6 @@ pub(crate) struct SessionActor {
pub(crate) origin_client: Option<crate::http::OriginClientInfo>,
/// Feedback manager for signal tracking and feedback request heuristics
pub(crate) feedback_manager: Arc<FeedbackManager>,
/// Cancellation token for the feedback sync loop (None if no feedback client)
pub(crate) sync_loop_cancel: Option<tokio_util::sync::CancellationToken>,
/// The fully-built Agent: owns the ToolBridge, system prompt, policies,
/// and the AgentDefinition. Replaces the old `tool_bridge` + `agent_definition` fields.
/// Wrapped in `RefCell` for mid-session mutation (skill refresh, prompt regen).
@@ -1,5 +1,5 @@
use super::*;
use crate::remote::DEFAULT_CONTEXT_WINDOW;
use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
use kigi_chat_state::conversation_util::replace_or_insert_system_head;
impl SessionActor {
pub(super) async fn handle_set_session_model(
@@ -72,7 +72,6 @@ impl SessionActor {
existing.auth_type,
),
alpha_test_key: existing.alpha_test_key,
client_version: sampling_config.client_version.clone(),
});
self.model_auth_facts.replace(None);
self.signals_handle()
@@ -691,7 +691,6 @@ impl SessionActor {
crate::agent::config::finalize_image_describe_sampler_config(
resolved_describe,
&active_session_config,
self.client_identifier.clone(),
Some(self.max_retries),
);
let client = kigi_sampler::SamplingClient::new(sampler_config).map_err(|e| {
@@ -3,7 +3,7 @@
use super::*;
use crate::remote::DEFAULT_CONTEXT_WINDOW;
use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
impl SessionActor {
/// Handle a /btw side question — single-turn model call using the
@@ -134,7 +134,7 @@ pub(super) fn build_todo_gate_reminder(pending: &[&str], unbacked_in_progress: &
/// (which is disabled). Extracted from `spawn_session_actor` so the
/// precedence rules are unit-testable. Named `resolve_*` to match the
/// sibling precedence helpers in `crate::util::config`
/// (`resolve_zdr_access_enabled`, `resolve_restore_code`, …).
/// (`resolve_restore_code`, …).
pub(crate) fn resolve_reminder_policy(
remote: Option<&crate::util::config::RemoteSettings>,
todo_gate: bool,
@@ -205,8 +205,7 @@ pub(super) async fn run_session(
.emit_buffered(notification). await; }
if let Some(tx) = respond_to { let _ =
tx.send(()); } } } } } maybe_completion = completion_rx.recv() => { let
Some((prompt_id, result)) = maybe_completion else { if let Some(cancel) = &
session.sync_loop_cancel { cancel.cancel(); } cleanup_session_scratch(&
Some((prompt_id, result)) = maybe_completion else { cleanup_session_scratch(&
session); return; }; if let Some(notification) = replay_buffer.flush() {
session.emit_buffered(notification). await; } let (turn_succeeded,
infra_pause_message) = SessionActor::post_turn_goal_degradation_plan(&
@@ -265,8 +264,7 @@ pub(super) async fn run_session(
{ let
model_id = session.current_model_id(). await; if let Some(signals) = session
.signals_handle().snapshot(). await {
} } if let
Some(cancel) = & session.sync_loop_cancel { cancel.cancel(); } session
} } session
.feedback_manager.shutdown(). await; if ! session
.startup_hints.is_subagent { session.persist_background_task_manifest().
await; } cleanup_session_scratch(& session); return; }; match cmd {
@@ -327,8 +325,7 @@ pub(super) async fn run_session(
::agent::config::try_resolve_model_credentials(model_name.as_str(), existing
.api_key.as_deref()) { session.chat_state_handle
.update_credentials(kigi_chat_state::Credentials { api_key : r.api_key,
auth_type : r.auth_type, alpha_test_key : existing.alpha_test_key,
client_version : existing.client_version, }); } session.model_auth_facts
auth_type : r.auth_type, alpha_test_key : existing.alpha_test_key, }); } session.model_auth_facts
.replace(None); } } SessionCommand::GetCurrentModel { responds_to } => { let
model = session.chat_state_handle.get_sampling_config(). await .map(| c | c
.model).unwrap_or_default(); let _ = responds_to.send(model); }
@@ -697,7 +694,7 @@ pub(super) async fn run_session(
await; session.send_hook_execution("session_start", None, None, & results).
await; } } SessionCommand::GetFeedbackContext { turn_number, responds_to } =>
{ let s = session.clone(); tokio::task::spawn_local(async move { use
prod_mc_cli_chat_proxy_types::feedback_types::FeedbackToolOutcome; let
crate::session::feedback_types::FeedbackToolOutcome; let
turn_idx = turn_number.and_then(| n | usize::try_from(n).ok()); let
(last_user_message, last_assistant_message) = match turn_idx { Some(n) => {
let conv = s.chat_state_handle.get_conversation(). await;
@@ -809,8 +806,7 @@ pub(super) async fn run_session(
"MEMORY_SUBAGENT_SKIP: skipping on_session_end for subagent session"); }
session.maybe_run_dream(). await; let telem = session.memory
.telemetry_snapshot(); session.emit_memory_session_summary(& telem,
total_chunks_at_end, session_end_result); if let Some(cancel) = & session
.sync_loop_cancel { cancel.cancel(); } session.feedback_manager
total_chunks_at_end, session_end_result); session.feedback_manager
.shutdown(). await; if ! session.startup_hints
.is_subagent { session.persist_background_task_manifest(). await; }
cleanup_session_scratch(& session); return; } } }
@@ -49,7 +49,7 @@ impl SessionTokenAuthGate {
is_session_based: auth_method_id
.is_some_and(crate::agent::auth_method::is_session_based_method),
model_byok,
endpoint_is_first_party: crate::util::is_first_party_xai_url(base_url),
endpoint_is_first_party: crate::util::is_first_party_url(base_url),
}
}
fn active(self) -> bool {
@@ -314,22 +314,11 @@ impl SessionActor {
auth_scheme,
extra_headers,
context_window: cfg.context_window.get(),
client_version: creds.client_version,
reasoning_effort: cfg.reasoning_effort,
force_http1: false,
max_retries: Some(self.max_retries),
stream_tool_calls: cfg.stream_tool_calls.unwrap_or(false),
idle_timeout_secs: None,
client_identifier: self.client_identifier.clone(),
deployment_id: crate::managed_config::resolve_deployment_id(
crate::managed_config::resolve_deployment_key().as_deref(),
),
user_id: self
.auth_manager
.as_ref()
.and_then(|am| am.current_or_expired())
.filter(|a| a.is_session_auth())
.map(|a| a.user_id),
origin_client: self.origin_client.clone(),
attribution_callback: self.attribution_callback.clone(),
bearer_resolver: if use_bearer_resolver {
@@ -482,7 +471,6 @@ impl SessionActor {
&endpoints,
session_key.as_deref(),
creds.alpha_test_key.clone(),
creds.client_version.clone(),
)
}
/// Resolve a dedicated sampler for the Auto-mode classifier model `slug`,
@@ -499,7 +487,6 @@ impl SessionActor {
crate::agent::config::stamp_session_local_sampler_fields(
&mut cfg,
&active_session_config,
self.client_identifier.clone(),
Some(self.max_retries),
);
let model = cfg.model.clone();
@@ -323,10 +323,10 @@ impl SessionActor {
/// Check if the session has been idle and proactively refresh model metadata.
///
/// Called at the start of each turn. If idle > `IDLE_REFRESH_THRESHOLD_SECS`,
/// fetches `/models-v2` from cli-chat-proxy and updates the cached
/// fetches `/models` from cli-chat-proxy and updates the cached
/// context_window / max_completion_tokens if remote settings changed them.
///
/// Skipped for BYOK users (no remote settings, no `/models-v2`).
/// Skipped for BYOK users (no remote settings, no `/models`).
pub(super) async fn maybe_refresh_model_metadata_on_resume(&self) {
if !self.is_session_based_auth() {
return;
@@ -353,7 +353,7 @@ impl SessionActor {
tracing::info!(
idle_secs,
threshold_secs = Self::IDLE_REFRESH_THRESHOLD_SECS,
"Session resumed after idle — refreshing model metadata from cli-chat-proxy"
"Session resumed after idle — refreshing model metadata"
);
let creds = self.chat_state_handle.get_credentials().await;
let Some(ref am) = self.auth_manager else {
@@ -370,27 +370,21 @@ impl SessionActor {
);
let middleware_client =
crate::http::with_auth_retry(crate::http::shared_client(), provider);
let url = format!("{}/models-v2", base_url);
let url = format!("{}/models", base_url);
let parse_models_response =
|json: serde_json::Value| -> Option<(std::num::NonZeroU64, Option<u32>)> {
let data = json.get("data")?.as_array()?;
for entry in data {
let parsed = crate::remote::client::parse_remote_model_value(entry, base_url)?;
let parsed =
crate::agent::models_fetch::parse_remote_model_value(entry, base_url)?;
if parsed.model == *current_model {
return Some((parsed.context_window, parsed.max_completion_tokens));
}
}
None
};
#[allow(unused_mut)]
let mut request = middleware_client
let request = middleware_client
.get(&url)
.header("X-XAI-Token-Auth", "xai-grok-cli")
.header("x-grok-client-version", kigi_version::VERSION)
.header(
crate::http::CLIENT_MODE_HEADER,
crate::http::process_client_mode(),
)
.timeout(std::time::Duration::from_secs(5));
let response = match request.send().await {
Ok(r) => r,
@@ -828,7 +828,7 @@ impl SessionActor {
);
let model_id = sampling_config.map(|c| c.model);
let resolved_model_id = model_metadata.resolved_model_id;
let client_version = credentials.client_version;
let client_version = Some(kigi_version::VERSION.to_string());
use crate::session::feedback_manager::{SessionFeedbackData, SubmitOutcome};
let outcome = self
@@ -3,7 +3,7 @@
//! the MCP auto-restart wiring (`SessionRestartActions`).
#![allow(clippy::items_after_test_module)]
use super::*;
use crate::remote::DEFAULT_CONTEXT_WINDOW;
use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
/// Partition CLI `--allow` rules under the pin: blanket catch-all allows
/// (`Allow(Any)` `*` / `**`, plus bare/match-all Bash/MCP/WebFetch grants — see
/// `resolution::is_catchall_allow`) substitute for the blocked `--yolo`, so drop them when
@@ -123,10 +123,7 @@ pub(crate) async fn spawn_session_actor(
codebase_indexes: std::sync::Arc<parking_lot::Mutex<CodebaseIndexManager>>,
code_nav_enabled: bool,
fs_watch_caps: fs_watch::FsWatchCapabilities,
feedback_proxy_url: Option<String>,
feedback_user_token: Option<String>,
feedback_alpha_test_key: Option<String>,
deployment_key: Option<String>,
feedback_base_url: Option<String>,
client_terminal_capable: bool,
client_fs_capable: bool,
gateway_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
@@ -141,7 +138,6 @@ pub(crate) async fn spawn_session_actor(
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
memory_config: Option<crate::config::MemoryConfig>,
loc_tracking_enabled: bool,
feedback_flags: crate::session::feedback_manager::FeedbackFlags,
managed_mcp_handle: crate::session::managed_mcp::ManagedMcpStateHandle,
managed_mcp_expires_at: Option<chrono::DateTime<chrono::Utc>>,
@@ -879,36 +875,30 @@ pub(crate) async fn spawn_session_actor(
}
persist_chat_history_jsonl_sync(&session_info, &conversation);
chat_state_handle.replace_conversation(conversation);
let feedback_client = feedback_proxy_url.map(|base_url| {
let mut client =
crate::agent::feedback_client::FeedbackClient::new(base_url, feedback_user_token)
.with_alpha_test_key(feedback_alpha_test_key)
.with_deployment_key(deployment_key);
if let Some(am) = auth_manager.as_ref() {
client = client.with_auth_manager(am.clone());
}
client
});
let feedback_client = match (feedback_base_url, auth_manager.as_ref()) {
(Some(base_url), Some(am)) => Some(
crate::agent::feedback_client::FeedbackClient::new(base_url, am.clone())
.with_session_id(session_info.id.0.to_string()),
),
_ => None,
};
let has_feedback_client = feedback_client.is_some();
tracing::info!(
session_id = % session_info.id.0, has_feedback_client = has_feedback_client,
"Creating feedback manager"
);
let feedback_client_type = match client_type {
ClientType::GrokTUI => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Tui,
ClientType::GrokWeb => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Web,
ClientType::Nebula => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Nebula,
ClientType::Extension => {
prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Extension
}
ClientType::Generic => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Agent,
ClientType::Desktop => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Desktop,
ClientType::GrokPager => prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Tui,
ClientType::GrokTUI => crate::session::feedback_types::ClientType::Tui,
ClientType::GrokWeb => crate::session::feedback_types::ClientType::Web,
ClientType::Nebula => crate::session::feedback_types::ClientType::Nebula,
ClientType::Extension => crate::session::feedback_types::ClientType::Extension,
ClientType::Generic => crate::session::feedback_types::ClientType::Agent,
ClientType::Desktop => crate::session::feedback_types::ClientType::Desktop,
ClientType::GrokPager => crate::session::feedback_types::ClientType::Tui,
};
let feedback_config = FeedbackManagerConfig {
feedback_enabled: feedback_flags.enabled,
client_type: feedback_client_type,
loc_tracking_enabled,
..Default::default()
};
let feedback_manager = Arc::new(FeedbackManager::new(
@@ -930,11 +920,6 @@ pub(crate) async fn spawn_session_actor(
}
signals_handle.set_primary_model(&primary_model_id);
signals_handle.set_tracing_config(inference_idle_timeout_secs);
let sync_loop_cancel = if has_feedback_client {
Some(tokio_util::sync::CancellationToken::new())
} else {
None
};
let force_compact = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let resolved_workspace_root = kigi_workspace::session::git::find_git_root_from_path(
std::path::Path::new(&session_info.cwd),
@@ -1141,7 +1126,6 @@ pub(crate) async fn spawn_session_actor(
client_identifier: session_client_identifier.clone(),
origin_client: origin_client.clone(),
feedback_manager: feedback_manager.clone(),
sync_loop_cancel: sync_loop_cancel.clone(),
agent: std::cell::RefCell::new(agent),
last_reported_branch: Arc::new(Mutex::new(None)),
git_head_enabled: fs_watch_caps.git_head,
@@ -1375,18 +1359,6 @@ pub(crate) async fn spawn_session_actor(
}
});
}
if let Some(cancel) = sync_loop_cancel {
tracing::info!(session_id = % session_info.id.0, "Spawning feedback sync loop");
let fm = feedback_manager.clone();
tokio::spawn(async move {
fm.run_sync_loop(cancel).await;
});
} else {
tracing::debug!(
session_id = % session_info.id.0,
"No feedback client available, skipping sync loop"
);
}
{
use agent_client_protocol::Client as _;
use kigi_tools::implementations::grok_build::ask_user_question::{
@@ -1600,10 +1572,7 @@ pub(crate) async fn spawn_session_on_thread(
codebase_indexes: std::sync::Arc<parking_lot::Mutex<CodebaseIndexManager>>,
code_nav_enabled: bool,
fs_watch_caps: fs_watch::FsWatchCapabilities,
feedback_proxy_url: Option<String>,
feedback_user_token: Option<String>,
feedback_alpha_test_key: Option<String>,
deployment_key: Option<String>,
feedback_base_url: Option<String>,
client_terminal_capable: bool,
client_fs_capable: bool,
gateway_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
@@ -1618,7 +1587,6 @@ pub(crate) async fn spawn_session_on_thread(
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
memory_config: Option<crate::config::MemoryConfig>,
loc_tracking_enabled: bool,
feedback_flags: crate::session::feedback_manager::FeedbackFlags,
managed_mcp_handle: crate::session::managed_mcp::ManagedMcpStateHandle,
managed_mcp_expires_at: Option<chrono::DateTime<chrono::Utc>>,
@@ -1751,10 +1719,7 @@ pub(crate) async fn spawn_session_on_thread(
codebase_indexes,
code_nav_enabled,
fs_watch_caps,
feedback_proxy_url,
feedback_user_token,
feedback_alpha_test_key,
deployment_key,
feedback_base_url,
client_terminal_capable,
client_fs_capable,
gateway_enabled,
@@ -1769,7 +1734,6 @@ pub(crate) async fn spawn_session_on_thread(
persisted_goal_mode,
persisted_announcement_state,
memory_config,
loc_tracking_enabled,
feedback_flags,
managed_mcp_handle,
managed_mcp_expires_at,
@@ -850,15 +850,11 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
auth_scheme: Default::default(),
extra_headers: Default::default(),
context_window: 256_000,
client_version: None,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
client_identifier: None,
reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None,
attribution_callback: None,
bearer_resolver: None,
@@ -47,15 +47,11 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
auth_scheme: Default::default(),
extra_headers: Default::default(),
context_window: 100_000,
client_version: None,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
client_identifier: None,
reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None,
attribution_callback: None,
bearer_resolver: None,
@@ -193,7 +189,6 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -338,15 +333,11 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
api_backend: Default::default(),
auth_scheme: Default::default(),
context_window: 100_000,
client_version: None,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
client_identifier: None,
reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None,
attribution_callback: None,
bearer_resolver: None,
@@ -470,15 +461,11 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
api_backend: Default::default(),
auth_scheme: Default::default(),
context_window: 100_000,
client_version: None,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
client_identifier: None,
reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None,
attribution_callback: None,
bearer_resolver: None,
@@ -641,7 +628,6 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -890,7 +876,6 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(agent),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -1729,15 +1714,11 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
auth_scheme: Default::default(),
extra_headers: Default::default(),
context_window: 100_000,
client_version: None,
force_http1: false,
max_retries: Some(0),
stream_tool_calls: false,
idle_timeout_secs: Some(60),
client_identifier: None,
reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None,
attribution_callback: None,
bearer_resolver: None,
@@ -1876,7 +1857,6 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(agent),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -43,7 +43,7 @@ async fn test_last_api_request_at_idle_detection() {
/// End-to-end test for `maybe_refresh_model_metadata_on_resume`.
///
/// Simulates a session idle for >10 minutes, then verifies the function
/// fetches `/models-v2`, parses the response, and updates `context_window`
/// fetches `/models`, parses the response, and updates `context_window`
/// and `max_completion_tokens` in the sampling config.
#[tokio::test(flavor = "current_thread")]
async fn test_e2e_idle_resume_refreshes_model_metadata() {
@@ -52,7 +52,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
local
.run_until(async {
let app = axum::Router::new().route(
"/v1/models-v2",
"/v1/models",
get(|| async {
axum::Json(serde_json::json!(
{ "data" : [{ "model" : "test-model", "name" : "Test Model",
@@ -117,7 +117,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
api_key: Some("test-key".to_string()),
auth_type: Default::default(),
alpha_test_key: None,
client_version: None,
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let actor = SessionActor {
@@ -219,7 +218,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -321,12 +319,12 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
assert_eq!(
cfg_after.context_window,
std::num::NonZeroU64::new(300_000).unwrap(),
"context_window should be updated to 300K from /models-v2"
"context_window should be updated to 300K from /models"
);
assert_eq!(
cfg_after.max_completion_tokens,
Some(16384),
"max_completion_tokens should be updated to 16384 from /models-v2"
"max_completion_tokens should be updated to 16384 from /models"
);
})
.await;
@@ -152,7 +152,6 @@ async fn create_test_actor(
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -591,7 +590,6 @@ async fn create_test_actor_with_memory(
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-memory")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -1168,7 +1166,7 @@ async fn test_compact_on_error_no_trigger_when_tokens_within_new_window() {
/// End-to-end test for `maybe_refresh_model_metadata_on_resume`.
///
/// Simulates a session idle for >10 minutes, then verifies the function
/// fetches `/models-v2`, parses the response, and updates `context_window`
/// fetches `/models`, parses the response, and updates `context_window`
/// and `max_completion_tokens` in the sampling config.
#[tokio::test(flavor = "current_thread")]
async fn test_e2e_idle_resume_refreshes_model_metadata() {
@@ -1177,7 +1175,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
local
.run_until(async {
let app = axum::Router::new().route(
"/v1/models-v2",
"/v1/models",
get(|| async {
axum::Json(serde_json::json!(
{ "data" : [{ "model" : "test-model", "name" : "Test Model",
@@ -1241,7 +1239,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
api_key: Some("test-key".to_string()),
auth_type: Default::default(),
alpha_test_key: None,
client_version: None,
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let actor = SessionActor {
@@ -1346,7 +1343,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -1448,12 +1444,12 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
assert_eq!(
cfg_after.context_window,
std::num::NonZeroU64::new(300_000).unwrap(),
"context_window should be updated to 300K from /models-v2"
"context_window should be updated to 300K from /models"
);
assert_eq!(
cfg_after.max_completion_tokens,
Some(16384),
"max_completion_tokens should be updated to 16384 from /models-v2"
"max_completion_tokens should be updated to 16384 from /models"
);
})
.await;
@@ -211,7 +211,6 @@ async fn create_test_actor_with_memory(
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-memory")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -166,7 +166,7 @@ async fn actor_with_proxy(
let cfg = crate::agent::config::Config {
endpoints: crate::agent::config::EndpointsConfig {
cli_chat_proxy_base_url: Some(proxy_base.to_string()),
coding_api_base_url: Some(proxy_base.to_string()),
..Default::default()
},
..Default::default()
@@ -157,7 +157,6 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -271,7 +271,6 @@ pub(crate) async fn create_test_actor_ex(
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -64,9 +64,6 @@ async fn web_search_uses_model_override_from_config_end_to_end() {
entry,
crate::agent::config::resolve_credentials(entry, None),
None,
None,
None,
None,
);
let web_search_sampling = crate::tools::config::web_search_sampling_config(resolved);
@@ -88,11 +88,11 @@ pub struct ClientFeedbackInput {
pub session_id: String,
/// Type of client submitting feedback
pub client_type: prod_mc_cli_chat_proxy_types::feedback_types::ClientType,
pub client_type: crate::session::feedback_types::ClientType,
/// Rating type (thumbs, stars, nps)
#[serde(default)]
pub rating_type: Option<prod_mc_cli_chat_proxy_types::feedback_types::RatingType>,
pub rating_type: Option<crate::session::feedback_types::RatingType>,
/// Rating value (interpretation depends on rating_type):
/// - thumbs: -1 (down), 0 (neutral), 1 (up)
@@ -113,7 +113,7 @@ pub struct ClientFeedbackInput {
/// Context type for the feedback
#[serde(default)]
pub context_type: Option<prod_mc_cli_chat_proxy_types::feedback_types::ContextType>,
pub context_type: Option<crate::session::feedback_types::ContextType>,
/// 0-based turn number this feedback is about.
#[serde(default, alias = "turnNumber")]
@@ -134,7 +134,7 @@ pub struct ClientFeedbackInput {
/// Terminal environment snapshot from the client.
#[serde(default)]
pub terminal_info: Option<prod_mc_cli_chat_proxy_types::feedback_types::FeedbackTerminalInfo>,
pub terminal_info: Option<crate::session::feedback_types::FeedbackTerminalInfo>,
}
impl ClientFeedbackInput {
@@ -144,10 +144,10 @@ impl ClientFeedbackInput {
/// - stars: 1 to 5
/// - nps: 0 to 10
fn clamp_rating_value(
rating_type: Option<prod_mc_cli_chat_proxy_types::feedback_types::RatingType>,
rating_type: Option<crate::session::feedback_types::RatingType>,
rating_value: Option<i32>,
) -> Option<i32> {
use prod_mc_cli_chat_proxy_types::feedback_types::RatingType;
use crate::session::feedback_types::RatingType;
match (rating_type, rating_value) {
(Some(RatingType::Thumbs), Some(v)) => Some(v.clamp(-1, 1)),
@@ -175,8 +175,8 @@ impl ClientFeedbackInput {
resolved_model_id: Option<String>,
model_fingerprint: Option<String>,
turn_number: Option<i64>,
) -> prod_mc_cli_chat_proxy_types::feedback_types::FeedbackSubmission {
use prod_mc_cli_chat_proxy_types::feedback_types::FeedbackContent;
) -> crate::session::feedback_types::FeedbackSubmission {
use crate::session::feedback_types::FeedbackContent;
let clamped_rating_value = Self::clamp_rating_value(self.rating_type, self.rating_value);
let content = match (
@@ -577,7 +577,7 @@ pub struct SessionInfoResponse {
pub struct FeedbackContext {
pub last_user_message: Option<String>,
pub last_assistant_message: Option<String>,
pub tool_outcomes: Vec<prod_mc_cli_chat_proxy_types::feedback_types::FeedbackToolOutcome>,
pub tool_outcomes: Vec<crate::session::feedback_types::FeedbackToolOutcome>,
pub compaction_count: i64,
pub context_window_usage: u8,
pub context_tokens_used: u64,
@@ -655,14 +655,14 @@ mod tests {
let input: ClientFeedbackInput = serde_json::from_str(json).unwrap();
assert_eq!(
input.client_type,
prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Desktop
crate::session::feedback_types::ClientType::Desktop
);
assert_eq!(input.session_id, "sess-1");
let submission = input.to_submission(Some("grok-3".into()), None, None, Some(5));
assert_eq!(
submission.client_type,
prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Desktop
crate::session::feedback_types::ClientType::Desktop
);
assert_eq!(submission.client_type.to_string(), "desktop");
}
@@ -7,7 +7,7 @@
//! lives alongside the primary one in `acp_session.rs`.
use super::SessionActor;
use super::is_project_instructions;
use crate::remote::DEFAULT_CONTEXT_WINDOW;
use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
use crate::session::compaction_config::{
AsyncCompactionCache, SUPPRESS_NONE, SUPPRESS_STICKY, SUPPRESS_TURN, SUPPRESS_UNTIL_SUCCESS,
};
@@ -2242,7 +2242,6 @@ mod inline_auto_compact_flow_tests {
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
sync_loop_cancel: None,
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
@@ -10,9 +10,7 @@ use super::signals::SessionSignals;
use crate::util::probabilistic_sample;
// Re-export shared feedback API wire types to avoid duplication
pub use prod_mc_cli_chat_proxy_types::feedback_types::{
FeedbackHeuristicsConfig, FeedbackMode, TierConfig,
};
pub use crate::session::feedback_types::{FeedbackHeuristicsConfig, FeedbackMode, TierConfig};
/// Feedback request tier with associated probability and criteria.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -288,7 +286,7 @@ impl FeedbackHeuristics {
/// Create a heuristics evaluator from a remote feedback-heuristics config.
pub fn from_config(config: &FeedbackHeuristicsConfig) -> Self {
use prod_mc_cli_chat_proxy_types::feedback_types::parse_feedback_mode_str;
use crate::session::feedback_types::parse_feedback_mode_str;
Self {
enabled: config.enabled,
@@ -343,7 +341,7 @@ impl FeedbackHeuristics {
/// Update the heuristics configuration from a loaded config.
/// Preserves the triggered_tiers state and request tracking.
pub fn update_config(&mut self, config: &FeedbackHeuristicsConfig) {
use prod_mc_cli_chat_proxy_types::feedback_types::parse_feedback_mode_str;
use crate::session::feedback_types::parse_feedback_mode_str;
self.enabled = config.enabled;
@@ -3,57 +3,48 @@
//! This manager coordinates:
//! - Signal tracking via SessionSignalsHandle
//! - Heuristics evaluation to determine when to request feedback
//! - Periodic sync of signals to the feedback/analytics backend
//! - Background loading of feedback configuration from the backend
//! - Creating feedback request records when triggered
//! - Sending feedback request notifications to clients
//! - Local persistence of every feedback record
//! - Forwarding text feedback to the Kimi Code feedback endpoint for
//! subscription (OAuth) sessions
//!
//! ## Usage
//! ```ignore
//! // Create the manager when a session starts
//! let manager = FeedbackManager::new(session_id, feedback_api_url, user_token);
//! let manager = FeedbackManager::new(session_id, feedback_client, config);
//!
//! // Get the signals handle to pass around for event tracking
//! let signals = manager.signals_handle();
//!
//! // Spawn the background sync task (also loads config)
//! tokio::spawn(manager.run_sync_loop());
//!
//! // Track events
//! signals.increment_turn();
//! signals.record_tool_call("read_file");
//!
//! // Check for feedback after each turn
//! // This also records the request with the feedback API if triggered
//! if let Some(request) = manager.maybe_request_feedback(None).await {
//! // Send FeedbackRequest notification to client
//! }
//! ```
use std::ops::ControlFlow;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::RwLock;
use crate::agent::feedback_client::{
FeedbackApiError, FeedbackClient, signals_to_update, snapshot_to_turn_delta,
};
use crate::agent::feedback_client::FeedbackClient;
use crate::session::feedback::{
FeedbackEvaluation, FeedbackHeuristics, FeedbackRequest, FeedbackTier, TriggerCondition,
};
use crate::session::signals::{SessionSignalsActor, SessionSignalsHandle, TurnDeltaSnapshot};
use crate::session::signals::{SessionSignalsActor, SessionSignalsHandle};
use prod_mc_cli_chat_proxy_types::feedback_types::{
ClientType, ContextType, CreateFeedbackRequestInput, FeedbackContent, FeedbackMode,
FeedbackSubmission, FeedbackToolOutcome,
use crate::session::feedback_types::{
ClientType, FeedbackContent, FeedbackMode, FeedbackSubmission, FeedbackToolOutcome,
};
use crate::session::persistence::{LocalFeedbackEntry, PersistenceMsg, UserFeedbackEntry};
pub(crate) enum SubmitOutcome {
Submitted,
/// No server configured for this session.
/// Persisted locally only: no subscription session, or a rating-only
/// record with no text content for the Kimi feedback endpoint.
LocalOnly,
/// Server request failed.
Failed(anyhow::Error),
@@ -70,8 +61,9 @@ pub(crate) fn new_submission(
s
}
/// Pipeline: persist → strip → submit. Callers merge `KIGI_USER_METADATA` and
/// set `submission.request_id`.
/// Pipeline: persist locally → forward text content to the Kimi feedback
/// endpoint (subscription sessions only). Callers merge `KIGI_USER_METADATA`
/// and set `submission.request_id`.
pub(crate) async fn submit_feedback_workflow(
submission: &mut FeedbackSubmission,
feedback_client: Option<&FeedbackClient>,
@@ -96,42 +88,33 @@ pub(crate) async fn submit_feedback_workflow(
}
}
let telemetry_model_id = submission.model_id.clone();
let telemetry_rating_value = submission.rating_value;
let telemetry_session_id = submission.session_id.clone();
let has_feedback_text = submission
.feedback_text
.as_ref()
.is_some_and(|t| !t.is_empty());
let request_id = submission.request_id.clone();
let appearance_id = request_id.clone();
let appearance_id = submission.request_id.clone();
// Keep client-enriched triage fields; do not strip_metadata (Slack shows Option fields when set).
let outcome = if let Some(client) = feedback_client {
let result = if let Some(req_id) = request_id {
with_one_shot_auth_retry(client, || async {
client
.complete_request(&req_id, submission)
.await
.map(|_| ())
})
.await
} else {
with_one_shot_auth_retry(client, || async {
client.submit_feedback(submission).await.map(|_| ())
})
.await
};
match result {
Ok(()) => SubmitOutcome::Submitted,
Err(e) => {
tracing::warn!(error = %e, "feedback submission failed");
SubmitOutcome::Failed(e)
// Only text-bearing feedback goes over the wire: the Kimi endpoint takes
// a `content` string (kimi-cli slash.py parity); ratings stay local.
let outcome = match (feedback_client, &submission.feedback_text) {
(Some(client), Some(text)) if !text.is_empty() => {
let model = submission
.model_id
.as_deref()
.or(submission.resolved_model_id.as_deref());
match client
.submit_feedback(&submission.session_id, text, model)
.await
{
Ok(()) => SubmitOutcome::Submitted,
Err(e) => {
tracing::warn!(error = %e, "feedback submission failed");
SubmitOutcome::Failed(e)
}
}
}
} else {
SubmitOutcome::LocalOnly
_ => SubmitOutcome::LocalOnly,
};
{
@@ -172,18 +155,13 @@ pub struct FeedbackFlags {
/// Configuration for the feedback manager.
#[derive(Debug, Clone)]
pub struct FeedbackManagerConfig {
/// Interval for syncing signals to the analytics backend (default: 30s)
/// Interval for the signals actor's periodic bookkeeping tick.
pub sync_interval: Duration,
/// Whether user-facing feedback features are enabled (popups, `/feedback`,
/// ratings). Gated by `KIGI_FEEDBACK_ENABLED`.
pub feedback_enabled: bool,
/// Client type (Agent, Tui, Web, Extension)
pub client_type: ClientType,
/// Whether LOC attribution tracking is enabled for this session.
/// Propagated into every `SessionTurnDelta` so the server can
/// distinguish "tracking off" (zeros are noise) from "tracking on,
/// no code changed" (zeros are real data).
pub loc_tracking_enabled: bool,
}
impl Default for FeedbackManagerConfig {
@@ -192,7 +170,6 @@ impl Default for FeedbackManagerConfig {
sync_interval: Duration::from_secs(60),
feedback_enabled: false,
client_type: ClientType::Agent,
loc_tracking_enabled: false,
}
}
}
@@ -205,19 +182,17 @@ pub struct FeedbackManager {
signals_handle: SessionSignalsHandle,
/// Feedback heuristics evaluator
heuristics: Arc<RwLock<FeedbackHeuristics>>,
/// REST client for the feedback/analytics backend
/// Client for the Kimi Code feedback endpoint (subscription sessions).
feedback_client: Option<FeedbackClient>,
/// Configuration
config: FeedbackManagerConfig,
/// Whether config has been loaded from server
config_loaded: Arc<AtomicBool>,
}
impl FeedbackManager {
/// Create a new feedback manager for a session.
///
/// If `feedback_client` is None, signal syncing is disabled but local
/// tracking and heuristics evaluation still work.
/// If `feedback_client` is None, submissions stay local but tracking and
/// heuristics evaluation still work.
pub fn new(
session_id: impl Into<String>,
feedback_client: Option<FeedbackClient>,
@@ -243,7 +218,6 @@ impl FeedbackManager {
heuristics: Arc::new(RwLock::new(FeedbackHeuristics::new())),
feedback_client,
config,
config_loaded: Arc::new(AtomicBool::new(false)),
}
}
@@ -267,13 +241,14 @@ impl FeedbackManager {
self.config.feedback_enabled
}
/// REST client for the feedback/analytics backend, if configured.
/// Client for the Kimi Code feedback endpoint, if this is a subscription
/// session.
pub fn feedback_client(&self) -> Option<&FeedbackClient> {
self.feedback_client.as_ref()
}
/// Client type for this session (Agent, Tui, Web, etc.).
pub fn client_type(&self) -> prod_mc_cli_chat_proxy_types::feedback_types::ClientType {
pub fn client_type(&self) -> ClientType {
self.config.client_type
}
@@ -330,47 +305,6 @@ impl FeedbackManager {
.await
}
/// Check if config has been loaded from the server.
pub fn is_config_loaded(&self) -> bool {
self.config_loaded.load(Ordering::Relaxed)
}
/// Load feedback heuristics config from the backend.
/// This is called automatically in run_sync_loop but can be called manually.
/// Does not block - errors are logged and defaults are used.
#[tracing::instrument(name = "feedback.load_config", skip_all, fields(
session_id = %self.session_id,
))]
pub async fn load_config(&self) {
let Some(client) = &self.feedback_client else {
return; // No client, use defaults
};
if self.config.feedback_enabled {
match client.get_feedback_config().await {
Ok(config) => {
let mut heuristics = self.heuristics.write().await;
heuristics.update_config(&config);
self.config_loaded.store(true, Ordering::Relaxed);
tracing::info!(
session_id = %self.session_id,
config_id = %config.config_id,
config_version = config.config_version,
enabled = config.enabled,
"Loaded feedback heuristics config from server"
);
}
Err(e) => {
tracing::warn!(
session_id = %self.session_id,
error = %e,
"Failed to load feedback heuristics config, using defaults"
);
}
}
}
}
/// Evaluate heuristics and return a FeedbackRequest if one should be sent.
///
/// Call this after each turn to check if feedback should be requested.
@@ -378,9 +312,6 @@ impl FeedbackManager {
/// - No tier criteria are met
/// - The tier was already triggered this session
/// - Probabilistic sampling says no
///
/// When a request is triggered, this method also creates a record via the
/// feedback API for tracking and analytics.
#[tracing::instrument(name = "feedback.maybe_request_feedback", skip_all, fields(
session_id = %self.session_id,
))]
@@ -395,7 +326,6 @@ impl FeedbackManager {
let signals = self.signals_handle.snapshot().await?;
let mut heuristics = self.heuristics.write().await;
// Check if heuristics are globally enabled (from server config)
if !heuristics.is_enabled() {
return None;
}
@@ -422,12 +352,10 @@ impl FeedbackManager {
tier = ?request.tier,
trigger_type = %request.trigger_type,
feedback_mode = ?request.feedback_mode,
prompt_id = ?prompt_id,
"Feedback request triggered"
);
self.record_feedback_request(&request, trigger_condition, feedback_mode, prompt_id)
.await;
return Some(request);
}
@@ -449,11 +377,6 @@ impl FeedbackManager {
/// `x.ai/debug/trigger_feedback` ACP extension method to exercise
/// the full feedback notification ↔ response flow without needing a
/// real session that meets tier criteria.
///
/// When a `feedback_client` is configured, the request is also recorded
/// via the feedback API — exactly like a real trigger — so that the
/// subsequent `complete_request` / `dismiss_request` round-trip from the
/// client works end-to-end.
#[tracing::instrument(name = "feedback.force_feedback_request", skip_all, fields(
session_id = %self.session_id,
))]
@@ -481,96 +404,7 @@ impl FeedbackManager {
// Manual/debug triggers are always dismissible regardless of tier config,
// since they exist for developer testing, not real user feedback collection.
let request = FeedbackRequest::with_mode(
self.session_id.clone(),
condition.clone(),
mode,
true,
None,
);
self.record_feedback_request(&request, &condition, mode, None)
.await;
request
}
/// Record a feedback request via the feedback API.
///
/// This is a best-effort operation — errors are logged but do not
/// prevent the request from being sent to the client.
#[tracing::instrument(name = "feedback.record_feedback_request", skip_all, fields(
session_id = %self.session_id,
))]
async fn record_feedback_request(
&self,
request: &FeedbackRequest,
trigger_condition: &TriggerCondition,
feedback_mode: FeedbackMode,
prompt_id: Option<String>,
) {
let Some(client) = &self.feedback_client else {
return;
};
let input = CreateFeedbackRequestInput {
request_id: request.request_id.clone(),
session_id: self.session_id.clone(),
client_type: self.config.client_type,
feedback_mode,
feedback_prompt: Some(request.prompt.clone()),
priority: tier_to_priority(trigger_condition.tier),
trigger_type: request.trigger_type.clone(),
trigger_reason: Some(trigger_condition.trigger_reason()),
context_type: Some(ContextType::Session),
context_message_ids: vec![],
expires_at: None,
experiment_id: None,
trigger_condition: serde_json::to_value(trigger_condition).ok(),
prompt_id,
};
match with_one_shot_auth_retry(client, || client.create_feedback_request(&input)).await {
Ok(response) => {
tracing::debug!(
request_id = %response.request_id,
"Feedback request recorded with feedback API"
);
}
Err(e) => {
tracing::warn!(
request_id = %request.request_id,
error = %e,
"Failed to record feedback request (continuing anyway)"
);
}
}
}
/// Capture a turn-end snapshot and send the delta to the analytics backend.
///
/// Call this once per user turn, after the agent has finished all tool-call
/// rounds and produced a final response (i.e. alongside `record_turn_complete`).
/// Intermediate tool-call steps within the same turn do NOT need their own
/// call — the signals actor accumulates tool calls, errors, and latency
/// continuously, so the single snapshot at turn end captures the full diff.
///
/// The caller provides a pre-captured `TurnDeltaSnapshot` (taken exactly
/// once inside the session actor). This avoids double-advancing the delta
/// baseline. If the snapshot is `None` (e.g. the signals actor was shut
/// down), this is a no-op.
///
/// The delta is converted and sent asynchronously to the backend. Errors
/// are logged but never block the turn flow.
///
/// Load feedback heuristics config on startup.
/// This should be spawned as a background task.
#[tracing::instrument(skip_all, fields(session_id = %self.session_id))]
pub async fn run_sync_loop(self: Arc<Self>, cancel: tokio_util::sync::CancellationToken) {
// Load config in background (non-blocking, errors logged)
self.load_config().await;
cancel.cancelled().await;
tracing::debug!("Feedback sync loop cancelled");
FeedbackRequest::with_mode(self.session_id.clone(), condition, mode, true, None)
}
/// Shutdown the manager: shuts down the signals actor.
@@ -579,173 +413,6 @@ impl FeedbackManager {
}
}
// Auth outcome handler used by run_sync_loop on 401.
/// Max consecutive failed sync ticks tolerated before stopping the loop.
/// ~10 minutes at the default 60s interval.
const MAX_CONSECUTIVE_AUTH_FAILURES: u8 = 10;
/// telemetry `reason` discriminators on the `signals sync loop stopped permanently`
/// event. Pinned because alerts filter on these strings.
const REASON_AUTH_PERMANENT_FAILURE: &str = "auth_permanent_failure";
const REASON_NO_CLIENT_OR_REFRESHER: &str = "no_client_or_refresher";
const LOG_TITLE_TRANSIENT: &str = "signals sync transient auth failure";
const LOG_TITLE_STOPPED_PERMANENTLY: &str = "signals sync loop stopped permanently";
/// Classification of one 401-recovery attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SyncAuthOutcome {
/// Refresh + retry succeeded.
Recovered,
/// Refresh or retry failed transiently (lock timeout, network, sibling
/// race, post-refresh 5xx). Increment the counter and retry next tick.
Transient,
/// IdP confirmed a terminal failure (`invalid_grant` / `invalid_client`).
/// Only re-login will recover.
Permanent,
/// No client or no refresher configured — nothing to retry.
Unrecoverable,
}
fn handle_auth_outcome(
outcome: SyncAuthOutcome,
consecutive_auth_failures: &mut u8,
session_id: &str,
) -> ControlFlow<()> {
match outcome {
SyncAuthOutcome::Recovered => {
*consecutive_auth_failures = 0;
tracing::info!(
session_id = %session_id,
"Signal sync recovered after token refresh"
);
ControlFlow::Continue(())
}
SyncAuthOutcome::Transient => {
*consecutive_auth_failures = consecutive_auth_failures.saturating_add(1);
tracing::warn!(
session_id = %session_id,
consecutive_failures = *consecutive_auth_failures,
max = MAX_CONSECUTIVE_AUTH_FAILURES,
"Signals sync transient auth failure"
);
kigi_log::unified_log::warn(
LOG_TITLE_TRANSIENT,
Some(session_id),
Some(serde_json::json!({
"consecutive_failures": *consecutive_auth_failures,
"max": MAX_CONSECUTIVE_AUTH_FAILURES,
})),
);
if *consecutive_auth_failures >= MAX_CONSECUTIVE_AUTH_FAILURES {
tracing::warn!(
session_id = %session_id,
consecutive_failures = *consecutive_auth_failures,
"Signals sync loop stopped: consecutive transient auth failures"
);
kigi_log::unified_log::warn(
"signals sync loop stopped: consecutive transient auth failures",
Some(session_id),
Some(serde_json::json!({
"consecutive_failures": *consecutive_auth_failures,
"max": MAX_CONSECUTIVE_AUTH_FAILURES,
})),
);
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
}
SyncAuthOutcome::Permanent => {
tracing::warn!(
session_id = %session_id,
reason = REASON_AUTH_PERMANENT_FAILURE,
"Signals sync loop stopped: IdP confirmed permanent auth failure"
);
kigi_log::unified_log::warn(
LOG_TITLE_STOPPED_PERMANENTLY,
Some(session_id),
Some(serde_json::json!({ "reason": REASON_AUTH_PERMANENT_FAILURE })),
);
ControlFlow::Break(())
}
SyncAuthOutcome::Unrecoverable => {
tracing::warn!(
session_id = %session_id,
reason = REASON_NO_CLIENT_OR_REFRESHER,
"Signals sync loop stopped: no client or no refresher configured"
);
kigi_log::unified_log::warn(
LOG_TITLE_STOPPED_PERMANENTLY,
Some(session_id),
Some(serde_json::json!({ "reason": REASON_NO_CLIENT_OR_REFRESHER })),
);
ControlFlow::Break(())
}
}
}
/// Check if an error is an HTTP 401 Unauthorized response.
///
/// Uses typed downcast on [`FeedbackApiError`] instead of string matching,
/// so it stays correct even if error messages change.
fn is_auth_error(error: &anyhow::Error) -> bool {
error
.downcast_ref::<FeedbackApiError>()
.is_some_and(|e| e.is_unauthorized())
}
/// Check if an error is an HTTP 403 Forbidden response.
///
/// 403 from the signals endpoint means the session does not belong to the
/// current user — a permanent condition that will never self-resolve.
fn is_forbidden_error(error: &anyhow::Error) -> bool {
error
.downcast_ref::<FeedbackApiError>()
.is_some_and(|e| e.is_forbidden())
}
/// Run `op` once; on 401, wait for an in-flight refresh to land, then
/// retry once. Prefers waiting for the proactive-refresh task or
/// main-request-path recovery over driving a `ServerRejected` refresh
/// itself, avoiding the 401-amplification pattern during token-expiry
/// windows.
async fn with_one_shot_auth_retry<T, F, Fut>(
client: &FeedbackClient,
mut op: F,
) -> anyhow::Result<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = anyhow::Result<T>>,
{
match op().await {
Ok(v) => Ok(v),
Err(e) if is_auth_error(&e) => {
// 1. Wait briefly for the proactive refresh or main-path
// recovery to land a fresh token.
let refreshed = client.wait_for_token_refresh(Duration::from_secs(3)).await;
// 2. If nobody refreshed, drive our own recovery as fallback.
if refreshed || client.try_refresh_credentials().await {
op().await
} else {
Err(e)
}
}
Err(e) => Err(e),
}
}
/// Convert a FeedbackTier to a priority value (1-10, higher = more important).
fn tier_to_priority(tier: crate::session::feedback::FeedbackTier) -> i32 {
use crate::session::feedback::FeedbackTier;
match tier {
FeedbackTier::Tier1 => 5, // Standard engagement
FeedbackTier::Tier2 => 6, // Complex session with recovery
FeedbackTier::Tier3 => 7, // Recovery from friction
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -786,83 +453,6 @@ mod tests {
manager.shutdown().await;
}
#[test]
fn test_is_auth_error_detects_401() {
use crate::agent::feedback_client::FeedbackApiError;
let err: anyhow::Error = FeedbackApiError {
status: reqwest::StatusCode::UNAUTHORIZED,
context: "Signals update",
body: "Invalid or expired credentials".to_string(),
}
.into();
assert!(is_auth_error(&err));
}
#[test]
fn test_is_auth_error_ignores_other_statuses() {
use crate::agent::feedback_client::FeedbackApiError;
let err_500: anyhow::Error = FeedbackApiError {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
context: "Signals update",
body: "oops".to_string(),
}
.into();
assert!(!is_auth_error(&err_500));
let err_403: anyhow::Error = FeedbackApiError {
status: reqwest::StatusCode::FORBIDDEN,
context: "Signals update",
body: "ZDR team".to_string(),
}
.into();
assert!(!is_auth_error(&err_403));
}
#[test]
fn test_is_auth_error_ignores_non_api_errors() {
assert!(!is_auth_error(&anyhow::anyhow!("network timeout")));
assert!(!is_auth_error(&anyhow::anyhow!("connection refused")));
}
#[test]
fn test_is_forbidden_error_detects_403() {
use crate::agent::feedback_client::FeedbackApiError;
let err: anyhow::Error = FeedbackApiError {
status: reqwest::StatusCode::FORBIDDEN,
context: "Signals update",
body: "Access denied: session does not belong to this user".to_string(),
}
.into();
assert!(is_forbidden_error(&err));
}
#[test]
fn test_is_forbidden_error_ignores_other_statuses() {
use crate::agent::feedback_client::FeedbackApiError;
let err_401: anyhow::Error = FeedbackApiError {
status: reqwest::StatusCode::UNAUTHORIZED,
context: "Signals update",
body: "Invalid credentials".to_string(),
}
.into();
assert!(!is_forbidden_error(&err_401));
assert!(!is_forbidden_error(&anyhow::anyhow!("network error")));
}
#[test]
fn test_is_auth_error_works_through_anyhow_conversion() {
use crate::agent::feedback_client::FeedbackApiError;
// Verify the FeedbackApiError survives anyhow::Error round-trip
// (this is the actual path: send_json returns FeedbackApiError.into())
let api_err = FeedbackApiError {
status: reqwest::StatusCode::UNAUTHORIZED,
context: "Signals update",
body: "token expired".to_string(),
};
let anyhow_err: anyhow::Error = api_err.into();
assert!(is_auth_error(&anyhow_err));
}
#[tokio::test]
async fn test_feedback_manager_disabled() {
let config = FeedbackManagerConfig {
@@ -895,158 +485,21 @@ mod tests {
assert!(snapshot.is_none(), "Signals actor should be shut down");
}
// ── handle_auth_outcome tests ──────────────────────────────────────────
/// 9 transient failures must NOT break, and a subsequent `Recovered`
/// must reset the counter to 0.
#[test]
fn test_sync_loop_continues_through_transient_auth_failures() {
let mut counter: u8 = 0;
for _ in 0..(MAX_CONSECUTIVE_AUTH_FAILURES - 1) {
let flow = handle_auth_outcome(SyncAuthOutcome::Transient, &mut counter, "s");
assert_eq!(flow, ControlFlow::Continue(()));
}
assert_eq!(counter, MAX_CONSECUTIVE_AUTH_FAILURES - 1);
let flow = handle_auth_outcome(SyncAuthOutcome::Recovered, &mut counter, "s");
assert_eq!(flow, ControlFlow::Continue(()));
assert_eq!(counter, 0, "Recovered must reset the counter");
}
/// Exactly `MAX_CONSECUTIVE_AUTH_FAILURES` consecutive `Transient`
/// outcomes break the loop.
#[test]
fn test_sync_loop_breaks_after_max_transient_auth_failures() {
let mut counter: u8 = 0;
for i in 0..(MAX_CONSECUTIVE_AUTH_FAILURES - 1) {
let flow = handle_auth_outcome(SyncAuthOutcome::Transient, &mut counter, "s");
assert_eq!(
flow,
ControlFlow::Continue(()),
"iteration {i} should still continue"
);
}
// The 10th (== MAX_CONSECUTIVE_AUTH_FAILURES) transient breaks.
let flow = handle_auth_outcome(SyncAuthOutcome::Transient, &mut counter, "s");
assert_eq!(flow, ControlFlow::Break(()));
assert_eq!(counter, MAX_CONSECUTIVE_AUTH_FAILURES);
}
/// `Permanent` breaks immediately and does not bump the counter.
#[test]
fn test_sync_loop_breaks_immediately_on_permanent_failure() {
let mut counter: u8 = 0;
let flow = handle_auth_outcome(SyncAuthOutcome::Permanent, &mut counter, "s");
assert_eq!(flow, ControlFlow::Break(()));
assert_eq!(counter, 0);
}
/// 5 transient → 1 recovered → 5 transient must not break.
#[test]
fn test_sync_loop_counter_resets_on_successful_sync() {
let mut counter: u8 = 0;
for _ in 0..5 {
assert_eq!(
handle_auth_outcome(SyncAuthOutcome::Transient, &mut counter, "s"),
ControlFlow::Continue(())
);
}
assert_eq!(counter, 5);
assert_eq!(
handle_auth_outcome(SyncAuthOutcome::Recovered, &mut counter, "s"),
ControlFlow::Continue(())
);
assert_eq!(counter, 0, "Recovered must reset the counter");
for _ in 0..5 {
assert_eq!(
handle_auth_outcome(SyncAuthOutcome::Transient, &mut counter, "s"),
ControlFlow::Continue(())
);
}
assert_eq!(counter, 5, "second burst should be re-counted from zero");
}
/// `Unrecoverable` breaks the loop and does not bump the counter.
#[test]
fn test_sync_loop_breaks_on_unrecoverable() {
let mut counter: u8 = 0;
let flow = handle_auth_outcome(SyncAuthOutcome::Unrecoverable, &mut counter, "s");
assert_eq!(flow, ControlFlow::Break(()));
assert_eq!(counter, 0);
}
/// `FeedbackClient::is_auth_permanently_failed` reflects the attached
/// `AuthManager`'s `permanent_failure()` cache (record → true,
/// age-out → false).
/// A rating-only submission (no text) must not hit the network even when
/// no client is configured — the workflow reports LocalOnly.
#[tokio::test]
async fn test_is_auth_permanently_failed_reads_auth_manager() {
use crate::agent::feedback_client::FeedbackClient;
use crate::auth::error::RefreshTokenFailedReason;
use crate::auth::{AuthManager, KimiAuth, KimiCodeConfig};
use std::sync::Arc;
async fn test_rating_only_submission_stays_local() {
use crate::session::feedback_types::RatingType;
let dir = tempfile::tempdir().unwrap();
let am = Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
let client = FeedbackClient::new("http://example/v1", None).with_auth_manager(am.clone());
assert!(!client.is_auth_permanently_failed());
// The tombstone is scoped to the live credential's refresh token.
am.hot_swap(KimiAuth {
key: "tok".into(),
refresh_token: Some("rt".into()),
expires_at: Some(chrono::Utc::now() - chrono::Duration::hours(1)),
..KimiAuth::test_default()
});
am.record_permanent_failure("rt".to_string(), RefreshTokenFailedReason::Other.into());
assert!(client.is_auth_permanently_failed());
am.force_permanent_failure_aged_out();
assert!(!client.is_auth_permanently_failed());
}
/// With no `AuthManager` attached, `is_auth_permanently_failed` is false.
#[test]
fn test_is_auth_permanently_failed_without_auth_manager() {
use crate::agent::feedback_client::FeedbackClient;
let client = FeedbackClient::new("http://example/v1", None);
assert!(!client.is_auth_permanently_failed());
}
/// `has_token_refresher` requires BOTH an `AuthManager` AND a refresher
/// wired in. Without this, a static-deployment-key session would be
/// mis-classified as recoverable.
#[tokio::test]
async fn test_has_token_refresher_requires_refresher_attached() {
use crate::agent::feedback_client::FeedbackClient;
use crate::auth::{AuthManager, KimiCodeConfig};
use std::sync::Arc;
struct NoOpRefresher;
#[async_trait::async_trait]
impl crate::auth::refresh::TokenRefresher for NoOpRefresher {
async fn refresh(
&self,
_reason: crate::auth::refresh::RefreshReason,
) -> crate::auth::refresh::RefreshOutcome {
crate::auth::refresh::RefreshOutcome::TransientFailure {
message: "noop".into(),
}
}
}
let dir = tempfile::tempdir().unwrap();
let am = Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
let bare = FeedbackClient::new("http://example/v1", None);
assert!(!bare.has_token_refresher());
let with_am = FeedbackClient::new("http://example/v1", None).with_auth_manager(am.clone());
assert!(
!with_am.has_token_refresher(),
"AuthManager without a refresher must NOT be reported as recoverable"
let mut submission = new_submission(
"sess-local".into(),
ClientType::Tui,
FeedbackContent::Rating {
rating_type: RatingType::Thumbs,
rating_value: 1,
},
);
am.set_refresher(std::sync::Arc::new(NoOpRefresher));
assert!(with_am.has_token_refresher());
let outcome = submit_feedback_workflow(&mut submission, None, None, false).await;
assert!(matches!(outcome, SubmitOutcome::LocalOnly));
}
}
@@ -0,0 +1,763 @@
//! Local feedback data types.
//!
//! Formerly the wire contract with the deleted xAI cli-chat-proxy feedback
//! backend; now these types only back the LOCAL feedback records persisted in
//! the session store and the heuristics that decide when to solicit feedback.
//! The only remaining network surface is the Kimi Code `POST {base}/feedback`
//! call in [`crate::agent::feedback_client`], which sends a small flat JSON
//! body — none of these types go over the wire anymore, so the proxy-only
//! null-column fields (experiment/comparison/preference plumbing) are gone.
use serde::{Deserialize, Deserializer, Serialize};
pub use kigi_shared::session::FeedbackTerminalInfo;
/// Type of client submitting feedback.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClientType {
/// Terminal/CLI agent
#[default]
Agent,
/// Terminal UI
Tui,
/// Web interface
Web,
/// IDE extension (VS Code, JetBrains, etc.)
Extension,
/// Remote workspace / hosted agent client (wire value `nebula`).
Nebula,
/// Desktop (Electron app)
Desktop,
}
impl std::fmt::Display for ClientType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientType::Agent => write!(f, "agent"),
ClientType::Tui => write!(f, "tui"),
ClientType::Web => write!(f, "web"),
ClientType::Extension => write!(f, "extension"),
ClientType::Nebula => write!(f, "nebula"),
ClientType::Desktop => write!(f, "desktop"),
}
}
}
/// Type of feedback being submitted.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FeedbackType {
/// Numeric rating only
#[default]
Rating,
/// Free-form text only
Text,
/// Both rating and text
RatingWithText,
/// Model preference comparison
ModelPreference,
/// Bug report
BugReport,
/// Feature request
FeatureRequest,
}
impl std::fmt::Display for FeedbackType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FeedbackType::Rating => write!(f, "rating"),
FeedbackType::Text => write!(f, "text"),
FeedbackType::RatingWithText => write!(f, "rating_with_text"),
FeedbackType::ModelPreference => write!(f, "model_preference"),
FeedbackType::BugReport => write!(f, "bug_report"),
FeedbackType::FeatureRequest => write!(f, "feature_request"),
}
}
}
/// Type of rating scale used.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RatingType {
/// Thumbs up/down (-1, 0, 1)
Thumbs,
/// Star rating (1-5)
Stars,
/// Net Promoter Score (0-10)
Nps,
}
impl std::fmt::Display for RatingType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RatingType::Thumbs => write!(f, "thumbs"),
RatingType::Stars => write!(f, "stars"),
RatingType::Nps => write!(f, "nps"),
}
}
}
/// Context type for what the feedback is about.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextType {
/// Feedback about a specific message
Message,
/// Feedback about the overall session/conversation
Session,
/// Feedback about a specific feature
Feature,
/// Feedback about tool usage
ToolUse,
/// General feedback
General,
}
impl std::fmt::Display for ContextType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContextType::Message => write!(f, "message"),
ContextType::Session => write!(f, "session"),
ContextType::Feature => write!(f, "feature"),
ContextType::ToolUse => write!(f, "tool_use"),
ContextType::General => write!(f, "general"),
}
}
}
/// Type of feedback mode requested.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FeedbackMode {
/// Thumbs up/down
Thumbs,
/// Star rating (1-5)
Stars,
/// Free-form text
Text,
/// Thumbs up/down with optional text comment
ThumbsText,
/// Star rating with optional text comment
StarsText,
/// Model comparison
Comparison,
/// Multi-question survey
Survey,
/// Net Promoter Score (0-10)
Nps,
/// NPS with optional text comment
NpsText,
}
impl std::fmt::Display for FeedbackMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FeedbackMode::Thumbs => write!(f, "thumbs"),
FeedbackMode::Stars => write!(f, "stars"),
FeedbackMode::Text => write!(f, "text"),
FeedbackMode::ThumbsText => write!(f, "thumbs_text"),
FeedbackMode::StarsText => write!(f, "stars_text"),
FeedbackMode::Comparison => write!(f, "comparison"),
FeedbackMode::Survey => write!(f, "survey"),
FeedbackMode::Nps => write!(f, "nps"),
FeedbackMode::NpsText => write!(f, "nps_text"),
}
}
}
/// Parse a feedback mode string to FeedbackMode enum.
pub fn parse_feedback_mode_str(s: &str) -> FeedbackMode {
match s {
"thumbs" => FeedbackMode::Thumbs,
"stars" => FeedbackMode::Stars,
"text" => FeedbackMode::Text,
"thumbs_text" => FeedbackMode::ThumbsText,
"stars_text" => FeedbackMode::StarsText,
"comparison" => FeedbackMode::Comparison,
"survey" => FeedbackMode::Survey,
"nps" => FeedbackMode::Nps,
"nps_text" => FeedbackMode::NpsText,
_ => FeedbackMode::Thumbs,
}
}
/// Allowed `feedback_type` + value-field combinations. Construct submissions
/// via [`FeedbackSubmission::with_content`].
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum FeedbackContent {
Rating {
rating_type: RatingType,
rating_value: i32,
},
Text(String),
RatingWithText {
rating_type: RatingType,
rating_value: i32,
text: String,
},
}
impl FeedbackContent {
fn apply_to(self, s: &mut FeedbackSubmission) {
s.rating_type = None;
s.rating_value = None;
s.feedback_text = None;
match self {
Self::Rating {
rating_type,
rating_value,
} => {
s.feedback_type = FeedbackType::Rating;
s.rating_type = Some(rating_type);
s.rating_value = Some(rating_value);
}
Self::Text(text) => {
s.feedback_type = FeedbackType::Text;
s.feedback_text = Some(text);
}
Self::RatingWithText {
rating_type,
rating_value,
text,
} => {
s.feedback_type = FeedbackType::RatingWithText;
s.rating_type = Some(rating_type);
s.rating_value = Some(rating_value);
s.feedback_text = Some(text);
}
}
}
}
fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<String>::deserialize(deserializer)?;
Ok(opt.filter(|s| !s.is_empty()))
}
/// A user feedback record. Persisted locally in the session store; the text
/// content is forwarded to the Kimi Code feedback endpoint for subscription
/// sessions. Construct via [`FeedbackSubmission::with_content`]; the `Default`
/// impl exists for builder-style construction and test fixtures and does not
/// produce a valid submission on its own (empty `session_id`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FeedbackSubmission {
/// Session ID this feedback is for
pub session_id: String,
/// Type of client submitting feedback
pub client_type: ClientType,
/// Type of feedback being submitted
pub feedback_type: FeedbackType,
/// Turn number within the session (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub turn_number: Option<i64>,
/// Rating type (if applicable)
#[serde(skip_serializing_if = "Option::is_none")]
pub rating_type: Option<RatingType>,
/// Rating value (interpretation depends on rating_type)
/// - thumbs: -1 (down), 0 (neutral), 1 (up)
/// - stars: 1-5
/// - nps: 0-10
#[serde(skip_serializing_if = "Option::is_none")]
pub rating_value: Option<i32>,
/// Free-form feedback text
#[serde(skip_serializing_if = "Option::is_none")]
pub feedback_text: Option<String>,
/// Feedback categories (e.g., ["accuracy", "speed", "helpfulness"])
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub feedback_categories: Vec<String>,
/// Model ID used for the response being rated
#[serde(skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
/// Server-resolved model ID from the actual chat completion response.
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_model_id: Option<String>,
/// Checkpoint fingerprint from the inference provider (`system_fingerprint`).
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "empty_string_as_none"
)]
pub model_fingerprint: Option<String>,
/// Context type for the feedback
#[serde(skip_serializing_if = "Option::is_none")]
pub context_type: Option<ContextType>,
/// Feedback request ID (set when responding to a solicited request)
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
/// Client version
#[serde(skip_serializing_if = "Option::is_none")]
pub client_version: Option<String>,
/// Shell (kigi-shell) version
#[serde(skip_serializing_if = "Option::is_none")]
pub shell_version: Option<String>,
/// Additional metadata as JSON
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
/// Last user message at feedback time.
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "last_user_turn"
)]
pub last_user_message: Option<String>,
/// Last assistant response at feedback time.
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "last_assistant_turn"
)]
pub last_assistant_message: Option<String>,
/// Per-tool call counts for the rated turn.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_outcomes: Vec<FeedbackToolOutcome>,
/// Session working directory.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_cwd: Option<String>,
/// Number of compactions in the session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_count: Option<i64>,
/// Context window usage percentage (0100).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window_usage: Option<u8>,
/// Raw context tokens used at feedback time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_tokens_used: Option<u64>,
/// Raw model context window token limit at feedback time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window_tokens: Option<u64>,
/// Terminal environment snapshot at feedback time (brand, multiplexer, SSH, etc.).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal_info: Option<FeedbackTerminalInfo>,
}
impl FeedbackSubmission {
/// Construct from typed content; set optional fields after.
pub fn with_content(
session_id: String,
client_type: ClientType,
content: FeedbackContent,
) -> Self {
let mut s = Self {
session_id,
client_type,
..Default::default()
};
content.apply_to(&mut s);
s
}
/// Merge a JSON object into `metadata`, inserting if absent.
pub fn merge_metadata(&mut self, extra: serde_json::Value) {
match &mut self.metadata {
Some(existing) if existing.is_object() => {
if let (Some(dst), Some(src)) = (existing.as_object_mut(), extra.as_object()) {
for (k, v) in src {
dst.insert(k.clone(), v.clone());
}
}
}
_ => {
self.metadata = Some(extra);
}
}
}
}
/// Per-tool call/failure counts for a single tool in a turn.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FeedbackToolOutcome {
pub tool_name: String,
pub calls: u32,
pub failures: u32,
}
/// Configuration for a single feedback tier.
///
/// Each tier has specific thresholds and conditions that must be met
/// for feedback to be requested at that tier.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct TierConfig {
/// Whether this tier is enabled
pub enabled: bool,
/// Sample rate (0.0 to 1.0, e.g., 0.0005 = 0.05%)
pub sample_rate: f64,
/// Minimum turns required to trigger
pub min_turns: i64,
/// Minimum tool calls required (Tier 1 & 2)
#[serde(default)]
pub min_tool_calls: i64,
/// Minimum compactions required (Tier 1 & 2)
#[serde(default)]
pub min_compactions: i64,
/// Minimum errors required (Tier 2 only)
#[serde(default)]
pub min_errors: i64,
/// Whether cancellations disqualify this tier (Tier 1)
#[serde(default)]
pub no_cancellations: bool,
/// Whether cancellation is required (Tier 3)
#[serde(default)]
pub requires_cancellation: bool,
/// Whether revert is required (Tier 3)
#[serde(default)]
pub requires_revert: bool,
/// Whether at least one of cancellation/revert is required (Tier 3)
#[serde(default)]
pub requires_recovery: bool,
/// Feedback mode to use when this tier triggers
pub feedback_mode: FeedbackMode,
/// Whether feedback requests from this tier are dismissible (non-intrusive)
#[serde(default = "default_true")]
pub dismissible: bool,
/// Prompt text shown to users when this tier's feedback is requested
#[serde(default)]
pub prompt: String,
/// Max times this tier can trigger per session (0 = unlimited)
#[serde(default = "default_one")]
pub max_triggers: i32,
}
impl Default for TierConfig {
fn default() -> Self {
Self {
enabled: true,
sample_rate: 0.0005,
min_turns: 10,
min_tool_calls: 5,
min_compactions: 2,
min_errors: 0,
no_cancellations: false,
requires_cancellation: false,
requires_revert: false,
requires_recovery: false,
feedback_mode: FeedbackMode::Thumbs,
dismissible: true,
prompt: String::new(),
max_triggers: 1,
}
}
}
/// Configuration for feedback heuristics.
///
/// Formerly fetched from the proxy backend; now purely local — the built-in
/// [`Default`] is the only production source, kept as a struct so tests and
/// future config surfaces can tune the tiers.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct FeedbackHeuristicsConfig {
/// Unique configuration identifier
pub config_id: String,
/// Configuration version (monotonically increasing)
pub config_version: i64,
// === Global Settings ===
/// Master enable/disable switch for all feedback collection
pub enabled: bool,
/// Minimum seconds between feedback requests (cooldown period)
#[serde(default = "default_cooldown_seconds")]
pub cooldown_seconds: i64,
/// Maximum feedback requests per session
#[serde(default = "default_max_requests")]
pub max_requests_per_session: i64,
// === Tier 1: Standard Engagement ===
/// Whether Tier 1 is enabled
#[serde(default = "default_true")]
pub tier1_enabled: bool,
/// Sample rate for Tier 1 (0.0-1.0)
#[serde(default = "default_tier1_sample_rate")]
pub tier1_sample_rate: f64,
/// Minimum turns for Tier 1
#[serde(default = "default_tier1_min_turns")]
pub tier1_min_turns: i64,
/// Minimum tool calls for Tier 1
#[serde(default = "default_tier1_min_tool_calls")]
pub tier1_min_tool_calls: i64,
/// Minimum compactions for Tier 1
#[serde(default = "default_tier1_min_compactions")]
pub tier1_min_compactions: i64,
/// Whether Tier 1 requires no cancellations
#[serde(default = "default_true")]
pub tier1_no_cancellations: bool,
/// Feedback mode for Tier 1
#[serde(default = "default_feedback_mode_thumbs")]
pub tier1_feedback_mode: String,
/// Whether Tier 1 feedback requests are dismissible
#[serde(default = "default_true")]
pub tier1_dismissible: bool,
/// Prompt text shown to users when Tier 1 feedback is requested
#[serde(default = "default_tier1_prompt")]
pub tier1_prompt: String,
/// Max times Tier 1 can trigger per session (0 = unlimited)
#[serde(default = "default_one")]
pub tier1_max_triggers: i32,
// === Tier 2: Complex Session with Recovery ===
/// Whether Tier 2 is enabled
#[serde(default = "default_true")]
pub tier2_enabled: bool,
/// Sample rate for Tier 2 (0.0-1.0)
#[serde(default = "default_tier2_sample_rate")]
pub tier2_sample_rate: f64,
/// Minimum turns for Tier 2
#[serde(default = "default_tier2_min_turns")]
pub tier2_min_turns: i64,
/// Minimum tool calls for Tier 2
#[serde(default = "default_tier2_min_tool_calls")]
pub tier2_min_tool_calls: i64,
/// Minimum compactions for Tier 2
#[serde(default = "default_tier2_min_compactions")]
pub tier2_min_compactions: i64,
/// Minimum errors for Tier 2
#[serde(default = "default_tier2_min_errors")]
pub tier2_min_errors: i64,
/// Feedback mode for Tier 2
#[serde(default = "default_feedback_mode_thumbs_text")]
pub tier2_feedback_mode: String,
/// Whether Tier 2 feedback requests are dismissible
#[serde(default = "default_true")]
pub tier2_dismissible: bool,
/// Prompt text shown to users when Tier 2 feedback is requested
#[serde(default = "default_tier2_prompt")]
pub tier2_prompt: String,
/// Max times Tier 2 can trigger per session (0 = unlimited)
#[serde(default = "default_one")]
pub tier2_max_triggers: i32,
// === Tier 3: Recovery from Friction ===
/// Whether Tier 3 is enabled
#[serde(default = "default_true")]
pub tier3_enabled: bool,
/// Sample rate for Tier 3 (0.0-1.0)
#[serde(default = "default_tier3_sample_rate")]
pub tier3_sample_rate: f64,
/// Minimum turns for Tier 3
#[serde(default = "default_tier3_min_turns")]
pub tier3_min_turns: i64,
/// Whether Tier 3 requires at least one cancellation
#[serde(default)]
pub tier3_requires_cancellation: bool,
/// Whether Tier 3 requires at least one revert
#[serde(default)]
pub tier3_requires_revert: bool,
/// Whether Tier 3 requires recovery (cancellation OR revert)
#[serde(default = "default_true")]
pub tier3_requires_recovery: bool,
/// Feedback mode for Tier 3
#[serde(default = "default_feedback_mode_stars_text")]
pub tier3_feedback_mode: String,
/// Whether Tier 3 feedback requests are dismissible
#[serde(default = "default_true")]
pub tier3_dismissible: bool,
/// Prompt text shown to users when Tier 3 feedback is requested
#[serde(default = "default_tier3_prompt")]
pub tier3_prompt: String,
/// Max times Tier 3 can trigger per session (0 = unlimited)
#[serde(default = "default_one")]
pub tier3_max_triggers: i32,
}
impl Default for FeedbackHeuristicsConfig {
fn default() -> Self {
Self {
config_id: "default".to_string(),
config_version: 1,
enabled: true,
cooldown_seconds: 300,
max_requests_per_session: 3,
// Tier 1
tier1_enabled: true,
tier1_sample_rate: 0.0005,
tier1_min_turns: 10,
tier1_min_tool_calls: 5,
tier1_min_compactions: 2,
tier1_no_cancellations: true,
tier1_feedback_mode: "thumbs".to_string(),
tier1_dismissible: true,
tier1_prompt: default_tier1_prompt(),
tier1_max_triggers: 1,
// Tier 2
tier2_enabled: true,
tier2_sample_rate: 0.0002,
tier2_min_turns: 15,
tier2_min_tool_calls: 10,
tier2_min_compactions: 3,
tier2_min_errors: 1,
tier2_feedback_mode: "thumbs_text".to_string(),
tier2_dismissible: true,
tier2_prompt: default_tier2_prompt(),
tier2_max_triggers: 1,
// Tier 3
tier3_enabled: true,
tier3_sample_rate: 0.0001,
tier3_min_turns: 20,
tier3_requires_cancellation: false,
tier3_requires_revert: false,
tier3_requires_recovery: true,
tier3_feedback_mode: "stars_text".to_string(),
tier3_dismissible: true,
tier3_prompt: default_tier3_prompt(),
tier3_max_triggers: 1,
}
}
}
impl FeedbackHeuristicsConfig {
/// Get the Tier 1 configuration as a TierConfig.
pub fn tier1_config(&self) -> TierConfig {
TierConfig {
enabled: self.tier1_enabled,
sample_rate: self.tier1_sample_rate,
min_turns: self.tier1_min_turns,
min_tool_calls: self.tier1_min_tool_calls,
min_compactions: self.tier1_min_compactions,
min_errors: 0,
no_cancellations: self.tier1_no_cancellations,
requires_cancellation: false,
requires_revert: false,
requires_recovery: false,
feedback_mode: parse_feedback_mode_str(&self.tier1_feedback_mode),
dismissible: self.tier1_dismissible,
prompt: self.tier1_prompt.clone(),
max_triggers: self.tier1_max_triggers,
}
}
/// Get the Tier 2 configuration as a TierConfig.
pub fn tier2_config(&self) -> TierConfig {
TierConfig {
enabled: self.tier2_enabled,
sample_rate: self.tier2_sample_rate,
min_turns: self.tier2_min_turns,
min_tool_calls: self.tier2_min_tool_calls,
min_compactions: self.tier2_min_compactions,
min_errors: self.tier2_min_errors,
no_cancellations: false,
requires_cancellation: false,
requires_revert: false,
requires_recovery: false,
feedback_mode: parse_feedback_mode_str(&self.tier2_feedback_mode),
dismissible: self.tier2_dismissible,
prompt: self.tier2_prompt.clone(),
max_triggers: self.tier2_max_triggers,
}
}
/// Get the Tier 3 configuration as a TierConfig.
pub fn tier3_config(&self) -> TierConfig {
TierConfig {
enabled: self.tier3_enabled,
sample_rate: self.tier3_sample_rate,
min_turns: self.tier3_min_turns,
min_tool_calls: 0,
min_compactions: 0,
min_errors: 0,
no_cancellations: false,
requires_cancellation: self.tier3_requires_cancellation,
requires_revert: self.tier3_requires_revert,
requires_recovery: self.tier3_requires_recovery,
feedback_mode: parse_feedback_mode_str(&self.tier3_feedback_mode),
dismissible: self.tier3_dismissible,
prompt: self.tier3_prompt.clone(),
max_triggers: self.tier3_max_triggers,
}
}
}
// Default value functions for serde
fn default_true() -> bool {
true
}
fn default_cooldown_seconds() -> i64 {
300
}
fn default_max_requests() -> i64 {
3
}
fn default_tier1_sample_rate() -> f64 {
0.0005
}
fn default_tier1_min_turns() -> i64 {
10
}
fn default_tier1_min_tool_calls() -> i64 {
5
}
fn default_tier1_min_compactions() -> i64 {
2
}
fn default_tier2_sample_rate() -> f64 {
0.0002
}
fn default_tier2_min_turns() -> i64 {
15
}
fn default_tier2_min_tool_calls() -> i64 {
10
}
fn default_tier2_min_compactions() -> i64 {
3
}
fn default_tier2_min_errors() -> i64 {
1
}
fn default_tier3_sample_rate() -> f64 {
0.0001
}
fn default_tier3_min_turns() -> i64 {
20
}
fn default_feedback_mode_thumbs() -> String {
"thumbs".to_string()
}
fn default_feedback_mode_thumbs_text() -> String {
"thumbs_text".to_string()
}
fn default_feedback_mode_stars_text() -> String {
"stars_text".to_string()
}
fn default_tier1_prompt() -> String {
"You've been having a productive session! Would you mind sharing quick feedback?".to_string()
}
fn default_tier2_prompt() -> String {
"You've worked through a complex session. Your feedback would help us improve.".to_string()
}
fn default_tier3_prompt() -> String {
"Thanks for sticking with us through that session. Got a moment to share feedback?".to_string()
}
fn default_one() -> i32 {
1
}
+3 -72
View File
@@ -3,9 +3,7 @@
//! Forks a saved session to a new working directory with a new session ID.
//! This creates new session files but does not start the session.
use crate::remote::BackendClient;
const FORK_LOG: &str = "xai_fork";
use crate::session::export::ExportedMetadata;
const FORK_LOG: &str = "kigi_fork";
use crate::session::info::Info;
use crate::session::storage::{CopySessionOptions, JsonlStorageAdapter};
use crate::util::kigi_home::kigi_home;
@@ -62,11 +60,7 @@ fn generate_fork_session_id(_source_id: &str) -> String {
}
/// Fork a saved session to a new working directory.
pub async fn fork_session(
request: ForkSessionRequest,
agent_id: &str,
auth_manager: Option<std::sync::Arc<crate::auth::AuthManager>>,
) -> io::Result<ForkSessionResponse> {
pub async fn fork_session(request: ForkSessionRequest) -> io::Result<ForkSessionResponse> {
let t0 = std::time::Instant::now();
let root_dir = kigi_home();
@@ -114,32 +108,6 @@ pub async fn fork_session(
let copy_ms = t0.elapsed().as_millis() as u64;
// Writeback session to backend (fire-and-forget).
// This is telemetry-grade: the local fork works without it. All fork
// state lives locally (session files on disk), and the caller does not
// depend on synchronous backend registration. The backend eventually
// learns about the session when the background task completes.
// Spawning removes the network round-trip (~200-400ms) from the
// critical path.
if let Some(am) = auth_manager {
let sid = new_session_id.clone();
let cwd = request.new_cwd.clone();
let parent = request.source_session_id.clone();
let model = request.new_model_id.clone();
let aid = agent_id.to_string();
tokio::spawn(async move {
if let Err(e) =
sync_forked_session_to_backend(&sid, &cwd, parent, model, &aid, am).await
{
tracing::warn!(
session_id = %sid,
error = %e,
"Failed to register forked session with backend (background)"
);
}
});
}
let total_ms = t0.elapsed().as_millis() as u64;
tracing::info!(
target: FORK_LOG,
@@ -149,7 +117,7 @@ pub async fn fork_session(
total_ms,
chat_copied = result.chat_messages_copied,
updates_copied = result.updates_copied,
"FORK_COPY: session data copied (backend sync spawned in background)"
"FORK_COPY: session data copied"
);
Ok(ForkSessionResponse {
@@ -163,43 +131,6 @@ pub async fn fork_session(
})
}
/// Sync a forked session to the backend (for writeback mode).
async fn sync_forked_session_to_backend(
session_id: &str,
cwd: &str,
parent_session_id: String,
model_id: Option<String>,
agent_id: &str,
auth_manager: std::sync::Arc<crate::auth::AuthManager>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = BackendClient::new().with_auth_manager(auth_manager);
let metadata = ExportedMetadata {
title: None, // Will be generated later when session runs
cwd: cwd.to_string(),
model_id,
created_at: Some(chrono::Utc::now().to_rfc3339()),
updated_at: Some(chrono::Utc::now().to_rfc3339()),
total_messages: Some(0),
parent_session_id: Some(parent_session_id),
session_kind: None,
subagent_type: None,
subagent_persona: None,
subagent_role: None,
fork_context_source: None,
subagent_depth: None,
};
client
.upsert_session(session_id, &metadata, agent_id)
.await?;
tracing::info!(
session_id = %session_id,
"Forked session registered with backend"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -743,7 +743,6 @@ mod classify_tests {
message: "test".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
}))
};
assert!(det(StatusCode::BAD_REQUEST));
@@ -836,7 +835,6 @@ mod classify_tests {
.into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
})));
}
#[test]
@@ -866,7 +864,6 @@ mod classify_tests {
message: "bad payload".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
}) else {
panic!("expected Deterministic for 400");
};
@@ -878,7 +875,6 @@ mod classify_tests {
message: "upstream blip".into(),
model_metadata: None,
retry_after_secs: None,
should_retry: None,
}) else {
panic!("expected Transient for 500");
};
@@ -1595,15 +1591,11 @@ mod reasoning_compaction_regression_tests {
auth_scheme: Default::default(),
extra_headers: Default::default(),
context_window: 256_000,
client_version: None,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
client_identifier: None,
reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None,
attribution_callback: None,
bearer_resolver: None,
+2 -16
View File
@@ -12,6 +12,7 @@ pub mod two_pass;
pub use self::acp_session::*;
pub use self::acp_types::*;
pub use self::commands::*;
pub use self::feedback_types::{ClientType, FeedbackTerminalInfo, RatingType};
pub use self::fork::{ForkSessionRequest, ForkSessionResponse, fork_session};
pub use self::handle::*;
pub use self::persistence::{
@@ -19,13 +20,9 @@ pub use self::persistence::{
resolve_local_session_any_cwd, session_exists_by_id, session_exists_for_cwd,
};
pub use self::result::{Empty, ExtMethodResult};
pub use self::share::{ShareSessionRequest, ShareSessionResponse};
pub use kigi_fsnotify::{
FsConfig, FsEvent, FsEventKind, FsEventSource, FsNotifyError, GitMetaKind,
};
pub use prod_mc_cli_chat_proxy_types::feedback_types::{
ClientType, FeedbackTerminalInfo, RatingType,
};
/// `false` twin: this template is not compiled into this build, so no
/// template matches. Keeps ungated call sites compiling in both
/// configurations.
@@ -273,18 +270,6 @@ pub struct ClientFsConfig {
pub mode: ClientFsMode,
}
/// Share session request/response types
pub mod share {
/// Request to share a session via URL
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ShareSessionRequest {
pub session_id: String,
}
/// Response containing the shareable URL
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ShareSessionResponse {
pub share_url: String,
}
}
/// Proxy config for the session registry client.
/// Shared between `acp_session` (slash commands) and `persistence` (title generation).
#[derive(Clone)]
@@ -303,6 +288,7 @@ pub(crate) mod events;
pub mod export;
pub mod feedback;
pub mod feedback_manager;
pub mod feedback_types;
pub mod file_system;
pub mod fork;
pub(crate) mod fs_watch;
@@ -5,8 +5,6 @@ use std::sync::Arc;
use crate::config::StorageMode;
use crate::remote::RemoteSync;
use crate::sampling::Client as OaiCompatClient;
use crate::sampling::ConversationItem;
use crate::session::export::ExportedMetadata;
@@ -105,7 +103,7 @@ pub struct UserFeedbackEntry {
pub dismissed: bool,
/// The full submission payload (omitted when dismissed)
#[serde(skip_serializing_if = "Option::is_none")]
pub submission: Option<prod_mc_cli_chat_proxy_types::feedback_types::FeedbackSubmission>,
pub submission: Option<crate::session::feedback_types::FeedbackSubmission>,
}
/// Helper for `#[serde(skip_serializing_if)]` on bool fields.
@@ -116,14 +114,13 @@ pub(crate) fn is_false(v: &bool) -> bool {
#[cfg(test)]
mod feedback_tests {
use super::*;
use prod_mc_cli_chat_proxy_types::feedback_types::{
use crate::session::feedback_types::{
ClientType, FeedbackSubmission, FeedbackType, RatingType,
};
fn make_submission(thumbs_up: bool) -> FeedbackSubmission {
FeedbackSubmission {
session_id: "session-abc".into(),
user_id: None,
client_type: ClientType::Tui,
feedback_type: if thumbs_up {
FeedbackType::Rating
@@ -139,22 +136,13 @@ mod feedback_tests {
Some("could be better".into())
},
feedback_categories: vec![],
message_id: None,
model_id: Some("grok-3-fast".into()),
resolved_model_id: Some("grok-4.5".into()),
model_fingerprint: None,
context_type: None,
feature_name: None,
tool_name: None,
experiment_id: None,
comparison_id: None,
preferred_model_id: None,
preference_strength: None,
preference_reasons: vec![],
request_id: None,
client_version: None,
shell_version: None,
extension_host: None,
metadata: None,
last_user_message: None,
last_assistant_message: None,
@@ -165,7 +153,6 @@ mod feedback_tests {
context_tokens_used: None,
context_window_tokens: None,
terminal_info: None,
unified_log_url: None,
}
}
@@ -1392,7 +1379,6 @@ struct SessionPersistence {
/// Pending ACP notification for merging consecutive text chunks
pending_notification: Option<acp::SessionNotification>,
rx: mpsc::UnboundedReceiver<PersistenceMsg>,
remote_sync: Option<RemoteSync>,
/// Session title generation lifecycle.
summary: crate::session::summary::SummaryGenerator,
registry_title_sync: Option<RegistryGeneratedTitleSync>,
@@ -1497,20 +1483,12 @@ impl SessionPersistence {
}
}
/// Flush any pending merged ACP notification to disk and remote sync.
/// Flush any pending merged ACP notification to disk.
async fn flush_pending(&mut self) {
// Write any pending merged ACP notification
if let Some(notification) = self.pending_notification.take() {
self.write_update(&SessionUpdate::Acp(Box::new(notification.clone())))
self.write_update(&SessionUpdate::Acp(Box::new(notification)))
.await;
// HTTP-based remote sync (Writeback mode)
if let Some(sync) = &self.remote_sync {
sync.queue(notification);
}
}
// Flush HTTP sync
if let Some(sync) = &self.remote_sync {
sync.flush();
}
}
@@ -1549,12 +1527,8 @@ impl SessionPersistence {
SessionUpdate::Acp(notification) => {
// ACP notifications use merging to coalesce consecutive text chunks
if let Some(to_write) = self.maybe_merge_notification(&notification) {
self.write_update(&SessionUpdate::Acp(Box::new(to_write.clone())))
self.write_update(&SessionUpdate::Acp(Box::new(to_write)))
.await;
// HTTP-based remote sync (Writeback mode)
if let Some(sync) = &self.remote_sync {
sync.queue(to_write);
}
}
}
SessionUpdate::Xai(_) => {
@@ -1602,9 +1576,6 @@ impl SessionPersistence {
{
tracing::warn!(?e, "failed to update current model");
}
if let Some(sync) = &self.remote_sync {
sync.set_model_id(model_id.0.to_string());
}
}
PersistenceMsg::PlanState(state) => {
if let Err(e) = self.storage.write_plan_state(&self.info, &state).await {
@@ -1660,9 +1631,6 @@ impl SessionPersistence {
&self.info,
&title,
);
if let Some(sync) = &self.remote_sync {
sync.set_title(title.clone());
}
if let Some(reg) = self.registry_title_sync.as_ref()
&& !reg.suppress_for_zdr
{
@@ -1901,69 +1869,6 @@ fn collect_session_files_recursive(base: &Path, dir: &Path, files: &mut Vec<Copi
}
}
fn init_remote_sync(
summary: &Summary,
storage_mode: StorageMode,
auth_manager: Option<Arc<crate::auth::AuthManager>>,
) -> io::Result<Option<RemoteSync>> {
match storage_mode {
StorageMode::Local => Ok(None),
StorageMode::Writeback => {
let auth_manager = auth_manager.ok_or_else(|| {
io::Error::new(
io::ErrorKind::PermissionDenied,
"Writeback storage mode requires authentication. Run 'grok login' first.",
)
})?;
if auth_manager.current_or_expired().is_some() {
// ZDR was an xAI team concept; nothing gates remote sync here.
} else {
tracing::warn!(
"writeback: no auth loaded yet, ZDR check skipped (backend enforces server-side)"
);
}
tracing::info!("Writeback mode enabled, syncing to backend");
let client =
crate::remote::BackendClient::new().with_auth_manager(auth_manager.clone());
let metadata = ExportedMetadata::from_summary(summary);
Ok(Some(RemoteSync::new(
summary.info.id.to_string(),
metadata,
client,
)))
}
}
}
/// Pull a session from the backend if not found locally. Returns the pulled
/// session's [`Info`] (cwd may differ from caller's on different machines),
/// or `None` if not found or on error.
async fn try_pull_from_remote(info: &Info, client: &crate::remote::BackendClient) -> Option<Info> {
// BackendClient resolves auth internally via its auth_manager.
client.auth_manager.as_ref()?;
tracing::info!(session_id = %info.id, "Session not found locally, trying backend");
match crate::remote::pull_session_to_local(&info.id.0, client).await {
Ok(crate::remote::PullResult::Hydrated(pulled_info)) => {
tracing::info!(
session_id = %info.id,
pulled_cwd = %pulled_info.cwd,
"Pulled session from backend"
);
Some(pulled_info)
}
Ok(crate::remote::PullResult::NotFound) => {
tracing::debug!(session_id = %info.id, "Session not found on backend either");
None
}
Err(e) => {
tracing::warn!(session_id = %info.id, error = %e, "Backend pull failed");
None
}
}
}
/// Map a persistence `io::Error` into an `acp::Error` with a human-friendly
/// `message` and a stable `data.code` for log aggregation.
pub(crate) fn io_error_to_acp(e: &io::Error) -> acp::Error {
@@ -2066,7 +1971,6 @@ pub(crate) async fn new(
let info_clone = info.clone();
let storage: Arc<dyn StorageAdapter> = Arc::from(storage);
let remote_sync = init_remote_sync(&summary, storage_mode, auth_manager)?;
let handle = PersistenceHandle {
tx: tx.clone(),
noop: false,
@@ -2078,7 +1982,6 @@ pub(crate) async fn new(
storage: storage.clone(),
pending_notification: None,
rx,
remote_sync: remote_sync.clone(),
summary: crate::session::summary::SummaryGenerator::new(
crate::session::summary::SummaryConfig {
sampling_client,
@@ -2147,7 +2050,6 @@ pub async fn new_with_explicit_dir(
storage: storage.clone(),
pending_notification: None,
rx,
remote_sync: None,
summary: crate::session::summary::SummaryGenerator::new(
crate::session::summary::SummaryConfig {
sampling_client,
@@ -2195,101 +2097,12 @@ pub struct PersistedInfoLight {
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
}
/// On NotFound, try pulling from backend. Returns pulled info or the original error.
async fn pull_on_miss(
info: &Info,
client: &crate::remote::BackendClient,
err: io::Error,
) -> io::Result<Info> {
if err.kind() != io::ErrorKind::NotFound {
return Err(err);
}
try_pull_from_remote(info, client).await.ok_or(err)
}
#[expect(dead_code, reason = "wired when session restore flow calls load")]
pub(crate) async fn load(
info: &Info,
sampling_client: OaiCompatClient,
storage_mode: StorageMode,
auth_manager: Option<Arc<crate::auth::AuthManager>>,
backend: Option<&crate::remote::BackendClient>,
gateway: Option<GatewaySender>,
session_summary_model: String,
registry_title_sync: Option<RegistryGeneratedTitleSync>,
) -> io::Result<(PersistedInfo, PersistenceHandle)> {
let root_dir = kigi_home();
let storage: Box<dyn StorageAdapter> = Box::new(JsonlStorageAdapter::with_root(root_dir));
let (persisted, loaded_info) = match storage.load_session(info).await {
Ok(p) => (p, info.clone()),
Err(e) => match backend {
Some(client) => {
let pulled = pull_on_miss(info, client, e).await?;
let p = storage.load_session(&pulled).await?;
(p, pulled)
}
None => return Err(e),
},
};
// Touch on load too: resuming must reset the worktree's gc expiry clock.
touch_worktree_for_session(&loaded_info).await;
let persisted_info = PersistedInfo {
summary: persisted.summary,
chat_history: persisted.chat_history,
updates: persisted.updates,
plan_state: persisted.plan_state,
rewind_points: persisted.rewind_points,
signals: persisted.signals,
};
let (tx, rx) = mpsc::unbounded_channel::<PersistenceMsg>();
let storage: Arc<dyn StorageAdapter> = Arc::from(storage);
let remote_sync = init_remote_sync(&persisted_info.summary, storage_mode, auth_manager)?;
let has_title = !persisted_info.summary.display_title().is_empty();
let handle = PersistenceHandle {
tx: tx.clone(),
noop: false,
};
tokio::task::spawn(async move {
let mut summary_gen = crate::session::summary::SummaryGenerator::new(
crate::session::summary::SummaryConfig {
sampling_client,
model: session_summary_model,
persistence_tx: tx,
},
);
if has_title {
summary_gen.mark_done();
}
let persistence = SessionPersistence {
info: loaded_info,
storage: storage.clone(),
pending_notification: None,
rx,
remote_sync: remote_sync.clone(),
summary: summary_gen,
registry_title_sync,
gateway,
};
persistence.run().await;
});
Ok((persisted_info, handle))
}
/// Like `load`, but doesn't load updates into memory.
/// Loads a session for streaming updates without reading them into memory.
/// Instead, provides the path to the updates file for streaming reads.
/// Use this for memory-efficient session loading when replaying updates.
pub(crate) async fn load_light(
info: &Info,
sampling_client: OaiCompatClient,
storage_mode: StorageMode,
auth_manager: Option<Arc<crate::auth::AuthManager>>,
backend: Option<&crate::remote::BackendClient>,
gateway: Option<GatewaySender>,
session_summary_model: String,
registry_title_sync: Option<RegistryGeneratedTitleSync>,
@@ -2298,16 +2111,9 @@ pub(crate) async fn load_light(
let storage: Box<dyn StorageAdapter> =
Box::new(JsonlStorageAdapter::with_root(root_dir.clone()));
let (persisted, loaded_info) = match storage.load_session_without_updates(info).await {
Ok(p) => (p, info.clone()),
Err(e) => match backend {
Some(client) => {
let pulled = pull_on_miss(info, client, e).await?;
let p = storage.load_session_without_updates(&pulled).await?;
(p, pulled)
}
None => return Err(e),
},
let (persisted, loaded_info) = {
let p = storage.load_session_without_updates(info).await?;
(p, info.clone())
};
// Touch on load too: resuming must reset the worktree's gc expiry clock.
touch_worktree_for_session(&loaded_info).await;
@@ -2330,7 +2136,6 @@ pub(crate) async fn load_light(
let (tx, rx) = mpsc::unbounded_channel::<PersistenceMsg>();
let storage: Arc<dyn StorageAdapter> = Arc::from(storage);
let remote_sync = init_remote_sync(&persisted_info.summary, storage_mode, auth_manager)?;
let has_title = !persisted_info.summary.display_title().is_empty();
let handle = PersistenceHandle {
@@ -2353,7 +2158,6 @@ pub(crate) async fn load_light(
storage: storage.clone(),
pending_notification: None,
rx,
remote_sync: remote_sync.clone(),
summary: summary_gen,
registry_title_sync,
gateway,
@@ -2373,100 +2177,58 @@ pub async fn list_summaries(cwd: Option<&str>) -> io::Result<Vec<Summary>> {
}
/// Failure modes of [`delete_session_history`].
///
/// Kept distinct so callers can surface a precise message: a remote
/// failure is reported separately from a local-disk failure because the
/// remote delete runs first and aborts the whole operation (see the doc
/// on [`delete_session_history`]).
#[derive(Debug, thiserror::Error)]
pub enum DeleteSessionError {
/// Listing local summaries (to resolve the on-disk session dir) failed.
#[error("failed to list sessions: {0}")]
List(#[source] io::Error),
/// The remote (writeback) copy could not be deleted; local bits were
/// left untouched so the operation can be retried.
#[error("failed to delete remote session data: {0}")]
Remote(#[source] crate::remote::client::BackendError),
/// The local on-disk session directory could not be removed.
#[error("failed to delete session: {0}")]
Local(#[source] io::Error),
}
/// Where a session copy was actually removed by [`delete_session_history`].
/// Whether a session copy was removed by [`delete_session_history`].
///
/// Both fields are `false` when nothing existed to delete (still a
/// `local_removed` is `false` when nothing existed to delete (still a
/// success). Callers use [`Self::any_removed`] to decide between a
/// "deleted" and a "not found" message without conflating a remote-only
/// delete with a no-op.
/// "deleted" and a "not found" message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SessionDeletion {
/// A local on-disk session directory was found and removed.
pub local_removed: bool,
/// A remote (writeback) copy was found and removed. `false` when
/// `needs_remote` was not set, or the remote copy was already absent
/// (the backend returned `404`).
pub remote_removed: bool,
}
impl SessionDeletion {
/// `true` when a copy was removed from at least one location.
/// `true` when the local session directory was removed.
pub fn any_removed(self) -> bool {
self.local_removed || self.remote_removed
self.local_removed
}
}
/// Permanently delete a session's history: the remote (writeback) copy
/// when `needs_remote`, the local on-disk session directory, and the
/// FTS search-index entry.
/// Permanently delete a session's history: the local on-disk session
/// directory and the FTS search-index entry.
///
/// Idempotent: a session that is missing locally (e.g. remote-only)
/// still succeeds, and a remote `404` (copy already gone) is treated as
/// success rather than an error. When `needs_remote` is set the remote
/// delete runs *first* and is authoritative — only on its success (or a
/// `404`) are the local bits removed. This ordering prevents a partial
/// delete where the local copy is nuked but the remote copy lingers and
/// re-appears on the next session list.
/// Idempotent: a session that is missing locally still succeeds.
///
/// Returns a [`SessionDeletion`] recording which copies (local / remote)
/// were actually removed; both fields `false` means nothing existed
/// (still `Ok`).
/// Returns a [`SessionDeletion`] recording whether a local copy was
/// removed; `false` means nothing existed (still `Ok`).
pub async fn delete_session_history(
session_id: &str,
cwd: Option<&str>,
needs_remote: bool,
auth_manager: Arc<crate::auth::AuthManager>,
) -> Result<SessionDeletion, DeleteSessionError> {
let sid = acp::SessionId::new(Arc::from(session_id));
// Resolve the local session info, scoping to cwd if provided. A
// remote-only session won't be found here — that's fine, the remote
// delete (if applicable) still runs.
// Resolve the local session info, scoping to cwd if provided.
let summaries = list_summaries(cwd)
.await
.map_err(DeleteSessionError::List)?;
let local_info = summaries
let Some(info) = summaries
.iter()
.find(|s| s.info.id == sid)
.map(|s| s.info.clone());
// Remote delete first (authoritative for cloud history). A genuine
// failure aborts before any local mutation so the row does not
// reappear; a `404` means the copy is already gone, so deletion stays
// idempotent and falls through to local cleanup.
let remote_removed = if needs_remote {
let result = crate::remote::client::BackendClient::new()
.with_auth_manager(auth_manager)
.delete_session_data(session_id)
.await;
classify_remote_delete(result)?
} else {
false
};
let Some(info) = local_info else {
.map(|s| s.info.clone())
else {
return Ok(SessionDeletion {
local_removed: false,
remote_removed,
});
};
@@ -2481,85 +2243,22 @@ pub async fn delete_session_history(
Ok(SessionDeletion {
local_removed: true,
remote_removed,
})
}
/// Classify a remote `delete_session_data` result, reporting whether a
/// remote copy was actually removed: a `2xx` means a copy was deleted
/// (`Ok(true)`), a `404` means it was already gone so deletion stays
/// idempotent (`Ok(false)`), and any other backend error aborts the
/// delete (`Err`) so local bits are left untouched and it can be retried.
fn classify_remote_delete(
result: Result<(), crate::remote::client::BackendError>,
) -> Result<bool, DeleteSessionError> {
use crate::remote::client::BackendError;
match result {
Ok(()) => Ok(true),
Err(BackendError::RequestFailed { status: 404, .. }) => Ok(false),
Err(e) => Err(DeleteSessionError::Remote(e)),
}
}
#[cfg(test)]
mod delete_session_history_tests {
use super::{DeleteSessionError, SessionDeletion, classify_remote_delete};
use crate::remote::client::BackendError;
use super::SessionDeletion;
#[test]
fn remote_ok_reports_removed() {
assert!(
classify_remote_delete(Ok(())).unwrap(),
"a 2xx delete must report that a remote copy was removed"
);
}
#[test]
fn remote_404_is_treated_as_already_deleted() {
let removed = classify_remote_delete(Err(BackendError::RequestFailed {
status: 404,
body: "not found".into(),
}))
.expect("a 404 means the remote copy is gone — deletion must stay idempotent");
assert!(
!removed,
"a 404 must report that nothing was removed remotely"
);
}
#[test]
fn remote_non_404_request_failure_aborts() {
let res = classify_remote_delete(Err(BackendError::RequestFailed {
status: 500,
body: "boom".into(),
}));
assert!(matches!(res, Err(DeleteSessionError::Remote(_))));
}
#[test]
fn remote_auth_failure_aborts() {
let res = classify_remote_delete(Err(BackendError::Auth("denied".into())));
assert!(matches!(res, Err(DeleteSessionError::Remote(_))));
}
#[test]
fn any_removed_reflects_either_location() {
fn any_removed_reflects_local_removal() {
assert!(!SessionDeletion::default().any_removed());
assert!(
SessionDeletion {
local_removed: true,
remote_removed: false,
}
.any_removed()
);
assert!(
SessionDeletion {
local_removed: false,
remote_removed: true,
}
.any_removed(),
"a remote-only delete must count as removed"
);
}
}
@@ -1085,7 +1085,7 @@ async fn test_load_prompts_only_large_session() {
#[tokio::test]
async fn test_append_feedback_creates_file_and_persists() {
use crate::session::persistence::{LocalFeedbackEntry, UserFeedbackEntry};
use prod_mc_cli_chat_proxy_types::feedback_types::{
use crate::session::feedback_types::{
ClientType, FeedbackSubmission, FeedbackType, RatingType,
};
let temp_dir = TempDir::new().unwrap();
@@ -1101,7 +1101,6 @@ async fn test_append_feedback_creates_file_and_persists() {
dismissed: false,
submission: Some(FeedbackSubmission {
session_id: "test-session-123".into(),
user_id: None,
client_type: ClientType::Tui,
feedback_type: FeedbackType::Rating,
turn_number: Some(3),
@@ -1109,22 +1108,13 @@ async fn test_append_feedback_creates_file_and_persists() {
rating_value: Some(1),
feedback_text: None,
feedback_categories: vec![],
message_id: None,
model_id: Some("grok-3-fast".into()),
resolved_model_id: Some("grok-4.5".into()),
model_fingerprint: None,
context_type: None,
feature_name: None,
tool_name: None,
experiment_id: None,
comparison_id: None,
preferred_model_id: None,
preference_strength: None,
preference_reasons: vec![],
request_id: None,
client_version: None,
shell_version: None,
extension_host: None,
metadata: None,
last_user_message: None,
last_assistant_message: None,
@@ -1135,7 +1125,6 @@ async fn test_append_feedback_creates_file_and_persists() {
context_tokens_used: None,
context_window_tokens: None,
terminal_info: None,
unified_log_url: None,
}),
});
adapter.append_feedback(&info, &user_entry).await.unwrap();
@@ -3,7 +3,6 @@ use std::cmp::{Ordering, Reverse};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use super::PartialReason;
use super::envelope::SessionKind;
use super::row::UnifiedRow;
@@ -11,10 +10,6 @@ use super::row::UnifiedRow;
pub(super) struct CompositeCursor {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub boundary: Option<BoundaryKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conv_page_token: Option<String>,
#[serde(default)]
pub conv_page_drained: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -42,52 +37,21 @@ impl CompositeCursor {
}
}
pub(super) enum ConvLane {
Skipped,
Degraded(PartialReason),
Page {
rows: Vec<UnifiedRow>,
next_token: Option<String>,
frontier: Option<BoundaryKey>,
},
}
pub(super) fn conv_frontier(raw_rows: &[UnifiedRow], has_more: bool) -> Option<BoundaryKey> {
if !has_more {
return None;
}
raw_rows
.iter()
.max_by(|a, b| cmp_total_order(a, b))
.map(boundary_of)
}
pub(super) struct Paginated {
pub candidates: Vec<UnifiedRow>,
pub emit_count: usize,
pub next_cursor: Option<CompositeCursor>,
pub partial: Option<PartialReason>,
}
pub(super) fn merge_and_paginate(
/// Sort local rows newest-first, resume after the cursor boundary, and cut
/// one page. `next_cursor` is set only when rows remain past the page.
pub(super) fn paginate(
local: Vec<UnifiedRow>,
conv: ConvLane,
cursor: &CompositeCursor,
limit: usize,
) -> Paginated {
let (conv_rows, conv_next_token, conv_fetched, conv_frontier, partial) = match conv {
ConvLane::Skipped => (Vec::new(), None, false, None, None),
ConvLane::Degraded(reason) => (Vec::new(), None, false, None, Some(reason)),
ConvLane::Page {
rows,
next_token,
frontier,
} => (rows, next_token, true, frontier, None),
};
let mut keyed: Vec<(SortKey, UnifiedRow)> = local
.into_iter()
.chain(conv_rows)
.map(|row| (row_sort_key(&row), row))
.collect();
@@ -98,46 +62,12 @@ pub(super) fn merge_and_paginate(
keyed.sort_by(|(a, _), (b, _)| a.cmp(b));
let mut emit_count = keyed.len().min(limit);
if let Some(frontier) = &conv_frontier {
let fkey = boundary_sort_key(frontier);
let frontier_count = keyed
.iter()
.take_while(|(k, _)| k.cmp(&fkey) != Ordering::Greater)
.count();
emit_count = emit_count.min(frontier_count);
}
let emit_count = keyed.len().min(limit);
let new_boundary = (emit_count > 0).then(|| boundary_of(&keyed[emit_count - 1].1));
let has_more = keyed.len() > emit_count;
let tail = &keyed[emit_count..];
let local_has_more = tail.iter().any(|(_, r)| r.kind == SessionKind::Build);
let conv_in_tail = tail.iter().any(|(_, r)| r.kind == SessionKind::Chat);
let (next_conv_token, next_conv_drained, conv_has_more) = if conv_fetched {
if conv_in_tail {
(cursor.conv_page_token.clone(), false, true)
} else {
let has_more = conv_next_token.is_some();
(conv_next_token, true, has_more)
}
} else if partial.is_some() && cursor.conv_page_token.is_some() && new_boundary.is_some() {
(
cursor.conv_page_token.clone(),
cursor.conv_page_drained,
true,
)
} else {
(
cursor.conv_page_token.clone(),
cursor.conv_page_drained,
false,
)
};
let next_cursor = (local_has_more || conv_has_more).then(|| CompositeCursor {
let next_cursor = has_more.then(|| CompositeCursor {
boundary: new_boundary.or_else(|| cursor.boundary.clone()),
conv_page_token: next_conv_token,
conv_page_drained: next_conv_drained,
});
let candidates: Vec<UnifiedRow> = keyed.into_iter().map(|(_, row)| row).collect();
@@ -146,7 +76,6 @@ pub(super) fn merge_and_paginate(
candidates,
emit_count,
next_cursor,
partial,
}
}
@@ -205,12 +134,8 @@ pub(super) fn cmp_total_order(a: &UnifiedRow, b: &UnifiedRow) -> Ordering {
#[cfg(test)]
mod tests {
use super::*;
use crate::remote::Conversation;
use crate::session::merge::MergedSession;
use crate::session::unified_list::{
conversation_to_row, facet_registry, merged_session_to_row,
};
use std::collections::BTreeSet;
use crate::session::unified_list::{facet_registry, merged_session_to_row};
fn local(id: &str, ts: &str) -> UnifiedRow {
let m = MergedSession {
@@ -236,336 +161,85 @@ mod tests {
merged_session_to_row(m, facet_registry())
}
fn conv(id: &str, ts: &str) -> UnifiedRow {
let c = Conversation {
conversation_id: id.into(),
title: "t".into(),
modify_time: Some(ts.into()),
..Conversation::default()
};
conversation_to_row(c, facet_registry())
}
struct ConvSource {
rows: Vec<UnifiedRow>,
page_size: usize,
}
impl ConvSource {
fn new(mut rows: Vec<UnifiedRow>, page_size: usize) -> Self {
rows.sort_by(cmp_total_order);
Self { rows, page_size }
}
fn page(&self, token: Option<&str>) -> ConvLane {
if self.rows.is_empty() {
return ConvLane::Page {
rows: Vec::new(),
next_token: None,
frontier: None,
};
}
let idx = token
.and_then(|t| t.strip_prefix('p'))
.and_then(|n| n.parse::<usize>().ok())
.unwrap_or(0);
let start = idx * self.page_size;
let end = (start + self.page_size).min(self.rows.len());
let rows = self.rows.get(start..end).unwrap_or(&[]).to_vec();
let next_token = (end < self.rows.len()).then(|| format!("p{}", idx + 1));
let frontier = conv_frontier(&rows, next_token.is_some());
ConvLane::Page {
rows,
next_token,
frontier,
}
}
}
fn walk_all(local_window: &[UnifiedRow], conv: &ConvSource, limit: usize) -> Vec<String> {
let mut cursor = CompositeCursor::default();
let mut emitted: Vec<String> = Vec::new();
for _ in 0..1000 {
let lane = conv.page(cursor.conv_page_token.as_deref());
let result = merge_and_paginate(local_window.to_vec(), lane, &cursor, limit);
emitted.extend(
result.candidates[..result.emit_count]
.iter()
.map(|r| r.legacy.session_id.clone()),
);
match result.next_cursor {
Some(c) => cursor = c,
None => return emitted,
}
}
panic!("pagination did not terminate");
}
fn ids(rows: &[UnifiedRow]) -> Vec<String> {
rows.iter().map(|r| r.legacy.session_id.clone()).collect()
fn ids(p: &Paginated) -> Vec<String> {
p.candidates[..p.emit_count]
.iter()
.map(|r| r.legacy.session_id.clone())
.collect()
}
#[test]
fn cursor_round_trips() {
let cur = CompositeCursor {
fn cursor_roundtrip_boundary_only() {
let c = CompositeCursor {
boundary: Some(BoundaryKey {
updated_at: "2026-06-01T00:00:00Z".into(),
kind: SessionKind::Chat,
session_id: "conv_1".into(),
}),
conv_page_token: Some("p3".into()),
conv_page_drained: true,
};
let decoded = CompositeCursor::decode(Some(&cur.encode()));
assert_eq!(decoded.conv_page_token.as_deref(), Some("p3"));
assert!(decoded.conv_page_drained);
let b = decoded.boundary.unwrap();
assert_eq!(b.session_id, "conv_1");
assert_eq!(b.kind, SessionKind::Chat);
}
#[test]
fn malformed_cursor_decodes_to_fresh_first_page() {
for bad in [Some("not base64 !!!"), Some(""), None] {
let c = CompositeCursor::decode(bad);
assert!(c.boundary.is_none());
assert!(c.conv_page_token.is_none());
assert!(!c.conv_page_drained);
}
}
#[test]
fn multi_page_walk_equals_single_fetch_window() {
let local_window = vec![
local("l1", "2026-06-10T00:00:00Z"),
local("l2", "2026-06-08T00:00:00Z"),
local("l3", "2026-06-04T00:00:00Z"),
local("l4", "2026-05-30T00:00:00Z"),
];
let conv_rows = vec![
conv("c1", "2026-06-09T00:00:00Z"),
conv("c2", "2026-06-07T00:00:00Z"),
conv("c3", "2026-06-06T00:00:00Z"),
conv("c4", "2026-06-03T00:00:00Z"),
conv("c5", "2026-05-29T00:00:00Z"),
];
let mut expected_all = local_window.clone();
expected_all.extend(conv_rows.clone());
expected_all.sort_by(cmp_total_order);
let expected_ids = ids(&expected_all);
for &limit in &[1usize, 2, 3, 5, 7, 100] {
for &page_size in &[1usize, 2, 3] {
let source = ConvSource::new(conv_rows.clone(), page_size);
let got = walk_all(&local_window, &source, limit);
let unique: BTreeSet<&String> = got.iter().collect();
assert_eq!(
unique.len(),
got.len(),
"duplicate emitted (limit={limit}, page_size={page_size}): {got:?}"
);
assert_eq!(
got, expected_ids,
"walk != single fetch (limit={limit}, page_size={page_size})"
);
}
}
}
#[test]
fn equal_updated_at_tie_break_no_drop_or_dup() {
let ts = "2026-06-01T00:00:00Z";
let local_window = vec![local("l_same", ts), local("l_old", "2026-05-01T00:00:00Z")];
let conv_rows = vec![conv("c_same", ts), conv("c_old", "2026-05-15T00:00:00Z")];
let mut expected_all = local_window.clone();
expected_all.extend(conv_rows.clone());
expected_all.sort_by(cmp_total_order);
let expected_ids = ids(&expected_all);
assert_eq!(expected_ids[0], "l_same");
assert_eq!(expected_ids[1], "c_same");
for &limit in &[1usize, 2, 3] {
let source = ConvSource::new(conv_rows.clone(), 1);
let got = walk_all(&local_window, &source, limit);
let unique: BTreeSet<&String> = got.iter().collect();
assert_eq!(unique.len(), got.len(), "dup at limit={limit}: {got:?}");
assert_eq!(
got, expected_ids,
"tie-break walk mismatch at limit={limit}"
);
}
}
#[test]
fn partial_conv_page_is_not_advanced_until_drained() {
let local_window = vec![
local("l1", "2026-06-10T00:00:00Z"),
local("l2", "2026-06-08T00:00:00Z"),
];
let conv_rows = vec![
conv("c1", "2026-06-09T00:00:00Z"),
conv("c2", "2026-06-07T00:00:00Z"),
];
let source = ConvSource::new(conv_rows.clone(), 2);
let got = walk_all(&local_window, &source, 1);
let mut expected_all = local_window.clone();
expected_all.extend(conv_rows.clone());
expected_all.sort_by(cmp_total_order);
assert_eq!(got, ids(&expected_all));
}
#[test]
fn whole_page_filtered_out_does_not_drop_later_match() {
let local_window = vec![
local("l1", "2026-06-10T00:00:00Z"),
local("l2", "2026-06-01T00:00:00Z"),
];
let raw = vec![
conv("c1_drop", "2026-06-09T00:00:00Z"),
conv("c2_drop", "2026-06-05T00:00:00Z"),
conv("c3_ok", "2026-06-03T00:00:00Z"),
];
let source = ConvSource::new(raw, 1);
let mut cursor = CompositeCursor::default();
let mut emitted: Vec<String> = Vec::new();
for _ in 0..1000 {
let lane = match source.page(cursor.conv_page_token.as_deref()) {
ConvLane::Page {
rows,
next_token,
frontier,
} => ConvLane::Page {
rows: rows
.into_iter()
.filter(|r| r.legacy.session_id.contains("ok"))
.collect(),
next_token,
frontier,
},
other => other,
};
let result = merge_and_paginate(local_window.clone(), lane, &cursor, 2);
emitted.extend(
result.candidates[..result.emit_count]
.iter()
.map(|r| r.legacy.session_id.clone()),
);
match result.next_cursor {
Some(c) => cursor = c,
None => break,
}
}
let mut expected = local_window.clone();
expected.push(conv("c3_ok", "2026-06-03T00:00:00Z"));
expected.sort_by(cmp_total_order);
assert_eq!(emitted, ids(&expected));
assert!(
emitted.iter().any(|id| id == "c3_ok"),
"the later matching conversation must not be dropped"
);
}
#[test]
fn local_only_when_conversations_skipped() {
let local_window = vec![
local("l1", "2026-06-10T00:00:00Z"),
local("l2", "2026-06-08T00:00:00Z"),
];
let result = merge_and_paginate(
local_window.clone(),
ConvLane::Skipped,
&CompositeCursor::default(),
10,
);
assert_eq!(result.emit_count, 2);
assert!(result.partial.is_none());
assert!(result.next_cursor.is_none());
}
#[test]
fn degraded_lane_sets_partial_and_returns_local() {
let local_window = vec![local("l1", "2026-06-10T00:00:00Z")];
let result = merge_and_paginate(
local_window,
ConvLane::Degraded(PartialReason::Timeout),
&CompositeCursor::default(),
10,
);
assert_eq!(result.partial, Some(PartialReason::Timeout));
assert_eq!(result.emit_count, 1);
}
#[test]
fn degraded_mid_walk_with_progress_keeps_live_conv_token() {
let cursor = CompositeCursor {
boundary: Some(BoundaryKey {
updated_at: "2026-06-15T00:00:00Z".into(),
updated_at: "2026-02-01T00:00:00Z".into(),
kind: SessionKind::Build,
session_id: "z_newer".into(),
session_id: "a".into(),
}),
conv_page_token: Some("p2".into()),
conv_page_drained: true,
};
let result = merge_and_paginate(
vec![local("l1", "2026-06-10T00:00:00Z")],
ConvLane::Degraded(PartialReason::Timeout),
&cursor,
10,
);
assert_eq!(result.emit_count, 1, "the local row is emitted (progress)");
assert_eq!(result.partial, Some(PartialReason::Timeout));
let next = result
.next_cursor
.expect("progress + live conv token must keep the continuation");
assert_eq!(next.conv_page_token.as_deref(), Some("p2"));
assert_eq!(
next.boundary.as_ref().map(|b| b.session_id.as_str()),
Some("l1")
);
let decoded = CompositeCursor::decode(Some(&c.encode()));
let b = decoded.boundary.expect("boundary survives roundtrip");
assert_eq!(b.session_id, "a");
assert_eq!(b.updated_at, "2026-02-01T00:00:00Z");
}
#[test]
fn degraded_mid_walk_with_no_progress_terminates() {
let cursor = CompositeCursor {
boundary: Some(BoundaryKey {
updated_at: "2026-06-10T00:00:00Z".into(),
kind: SessionKind::Build,
session_id: "l1".into(),
}),
conv_page_token: Some("p2".into()),
conv_page_drained: true,
};
let result = merge_and_paginate(
vec![local("l1", "2026-06-10T00:00:00Z")],
ConvLane::Degraded(PartialReason::Timeout),
&cursor,
10,
);
assert_eq!(
result.emit_count, 0,
"local lane is exhausted (no progress)"
);
assert_eq!(result.partial, Some(PartialReason::Timeout));
fn decode_garbage_yields_default() {
assert!(
result.next_cursor.is_none(),
"a zero-progress degraded page must terminate, not re-emit an identical cursor"
CompositeCursor::decode(Some("!!!not-base64!!!"))
.boundary
.is_none()
);
assert!(CompositeCursor::decode(None).boundary.is_none());
assert!(CompositeCursor::decode(Some("")).boundary.is_none());
}
#[test]
fn degraded_first_page_with_no_token_does_not_fabricate_a_cursor() {
let result = merge_and_paginate(
Vec::new(),
ConvLane::Degraded(PartialReason::Error),
&CompositeCursor::default(),
10,
);
assert_eq!(result.partial, Some(PartialReason::Error));
assert!(result.next_cursor.is_none());
fn paginate_sorts_newest_first_and_cuts_page() {
let rows = vec![
local("old", "2026-01-01T00:00:00Z"),
local("new", "2026-03-01T00:00:00Z"),
local("mid", "2026-02-01T00:00:00Z"),
];
let page = paginate(rows, &CompositeCursor::default(), 2);
assert_eq!(ids(&page), vec!["new", "mid"]);
assert!(page.next_cursor.is_some(), "a third row remains");
}
#[test]
fn paginate_resumes_after_boundary_without_duplicates() {
let rows: Vec<UnifiedRow> = vec![
local("a", "2026-03-01T00:00:00Z"),
local("b", "2026-02-01T00:00:00Z"),
local("c", "2026-01-01T00:00:00Z"),
];
let first = paginate(rows.clone(), &CompositeCursor::default(), 2);
assert_eq!(ids(&first), vec!["a", "b"]);
let cursor = first.next_cursor.expect("more rows remain");
let second = paginate(rows, &cursor, 2);
assert_eq!(ids(&second), vec!["c"]);
assert!(second.next_cursor.is_none(), "list is exhausted");
}
#[test]
fn paginate_exact_page_has_no_next_cursor() {
let rows = vec![
local("a", "2026-03-01T00:00:00Z"),
local("b", "2026-02-01T00:00:00Z"),
];
let page = paginate(rows, &CompositeCursor::default(), 2);
assert_eq!(ids(&page).len(), 2);
assert!(page.next_cursor.is_none());
}
#[test]
fn paginate_ties_break_stably_by_session_id() {
let ts = "2026-02-01T00:00:00Z";
let rows = vec![local("b", ts), local("a", ts), local("c", ts)];
let first = paginate(rows.clone(), &CompositeCursor::default(), 2);
assert_eq!(ids(&first), vec!["a", "b"]);
let cursor = first.next_cursor.expect("one row remains");
let second = paginate(rows, &cursor, 2);
assert_eq!(ids(&second), vec!["c"]);
}
}
@@ -4,7 +4,6 @@ use serde::Serialize;
use super::envelope::{FacetMap, FacetValue, SessionKind};
use super::row::UnifiedRow;
use crate::remote::Conversation;
use crate::session::merge::MergedSession;
pub const KIND_FACET_KEY: &str = "kind";
@@ -44,25 +43,6 @@ impl NormalizedItem {
starred: false,
}
}
pub fn from_conversation(c: &Conversation) -> Self {
Self {
kind: SessionKind::Chat,
cwd: String::new(),
repo_name: None,
branch: None,
worktree_label: None,
git_root_dir: None,
source_workspace_dir: None,
workspace_ids: c
.workspaces
.iter()
.map(|w| w.workspace_id.clone())
.filter(|id| !id.is_empty())
.collect(),
starred: c.starred,
}
}
}
#[derive(Debug, Default)]
@@ -393,7 +373,7 @@ pub struct FacetSummaryValue {
#[cfg(test)]
mod tests {
use super::*;
use crate::session::unified_list::{conversation_to_row, merged_session_to_row};
use crate::session::unified_list::merged_session_to_row;
fn local_row(session_id: &str, repo: Option<&str>, branch: Option<&str>) -> UnifiedRow {
let m = MergedSession {
@@ -419,74 +399,6 @@ mod tests {
merged_session_to_row(m, &build_facet_registry())
}
fn conv_row(conversation_id: &str, workspaces: &[&str]) -> UnifiedRow {
let c = Conversation {
conversation_id: conversation_id.into(),
title: "t".into(),
modify_time: Some("2026-06-01T00:00:00Z".into()),
workspaces: workspaces
.iter()
.map(|w| crate::remote::conversations_client::Workspace {
workspace_id: (*w).into(),
})
.collect(),
..Conversation::default()
};
conversation_to_row(c, &build_facet_registry())
}
fn conv_row_starred(conversation_id: &str, starred: bool) -> UnifiedRow {
let c = Conversation {
conversation_id: conversation_id.into(),
title: "t".into(),
modify_time: Some("2026-06-01T00:00:00Z".into()),
starred,
..Conversation::default()
};
conversation_to_row(c, &build_facet_registry())
}
#[test]
fn project_facet_only_on_conversations() {
let reg = build_facet_registry();
let conv = NormalizedItem::from_conversation(&Conversation {
conversation_id: "c1".into(),
workspaces: vec![crate::remote::conversations_client::Workspace {
workspace_id: "ws_9f3a".into(),
}],
..Conversation::default()
});
let facets = reg.extract_all(&conv);
assert!(matches!(
facets.get(WORKSPACE_FACET_KEY),
Some(FacetValue::Many(v)) if v == &[serde_json::json!("ws_9f3a")]
));
let local = NormalizedItem::from_merged(&MergedSession {
session_id: "s".into(),
summary: String::new(),
first_prompt: None,
updated_at: String::new(),
created_at: String::new(),
cwd: "/x".into(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 0,
last_active_at: None,
branch: Some("main".into()),
repo_name: Some("xai".into()),
worktree_label: None,
git_root_dir: None,
git_remotes: Vec::new(),
source_workspace_dir: None,
session_kind: None,
});
let lf = reg.extract_all(&local);
assert!(!lf.contains_key(WORKSPACE_FACET_KEY));
assert!(matches!(lf.get(REPO_FACET_KEY), Some(FacetValue::One(_))));
assert!(matches!(lf.get(BRANCH_FACET_KEY), Some(FacetValue::One(_))));
}
#[test]
fn project_pushdown_single_value_sets_workspace_id() {
let reg = build_facet_registry();
@@ -513,121 +425,6 @@ mod tests {
assert!(q.workspace_id.is_none());
}
#[test]
fn project_filter_is_partition_aware_keeps_local_rows() {
let reg = build_facet_registry();
let rows = vec![
local_row("local-1", Some("xai"), Some("main")),
conv_row("conv-match", &["ws_9f3a"]),
conv_row("conv-other", &["ws_zzz"]),
];
let mut filters = BTreeMap::new();
filters.insert(
WORKSPACE_FACET_KEY.to_owned(),
vec![serde_json::json!("ws_9f3a")],
);
let kept = reg.apply_in_memory_filters(&filters, rows);
let ids: Vec<&str> = kept.iter().map(|r| r.legacy.session_id.as_str()).collect();
assert!(ids.contains(&"local-1"));
assert!(ids.contains(&"conv-match"));
assert!(!ids.contains(&"conv-other"));
}
#[test]
fn repo_filter_is_partition_aware_keeps_conversation_rows() {
let reg = build_facet_registry();
let rows = vec![
local_row("local-xai", Some("xai"), Some("main")),
local_row("local-other", Some("other"), Some("main")),
conv_row("conv-1", &["ws_9f3a"]),
];
let mut filters = BTreeMap::new();
filters.insert(REPO_FACET_KEY.to_owned(), vec![serde_json::json!("xai")]);
let kept = reg.apply_in_memory_filters(&filters, rows);
let ids: Vec<&str> = kept.iter().map(|r| r.legacy.session_id.as_str()).collect();
assert!(ids.contains(&"local-xai"));
assert!(!ids.contains(&"local-other"));
assert!(ids.contains(&"conv-1"));
}
#[test]
fn pushdown_and_in_memory_project_filter_agree() {
let reg = build_facet_registry();
let convs = vec![conv_row("a", &["ws_1"]), conv_row("b", &["ws_2"])];
let mut filters = BTreeMap::new();
filters.insert(
WORKSPACE_FACET_KEY.to_owned(),
vec![serde_json::json!("ws_1")],
);
let in_memory = reg.apply_in_memory_filters(&filters, convs);
let ids: Vec<&str> = in_memory
.iter()
.map(|r| r.legacy.session_id.as_str())
.collect();
assert_eq!(ids, ["a"]);
let mut q = SourceQuery::default();
reg.apply_pushdown(&filters, &mut q);
assert_eq!(q.workspace_id.as_deref(), Some("ws_1"));
}
#[test]
fn starred_facet_present_only_for_starred_conversations() {
let reg = build_facet_registry();
let starred = NormalizedItem::from_conversation(&Conversation {
conversation_id: "c1".into(),
starred: true,
..Conversation::default()
});
assert!(matches!(
reg.extract_all(&starred).get(STARRED_FACET_KEY),
Some(FacetValue::One(serde_json::Value::Bool(true)))
));
let plain = NormalizedItem::from_conversation(&Conversation {
conversation_id: "c2".into(),
starred: false,
..Conversation::default()
});
assert!(!reg.extract_all(&plain).contains_key(STARRED_FACET_KEY));
let local = NormalizedItem::from_merged(&MergedSession {
session_id: "s".into(),
summary: String::new(),
first_prompt: None,
updated_at: String::new(),
created_at: String::new(),
cwd: "/x".into(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 0,
last_active_at: None,
branch: None,
repo_name: None,
worktree_label: None,
git_root_dir: None,
git_remotes: Vec::new(),
source_workspace_dir: None,
session_kind: None,
});
assert!(!reg.extract_all(&local).contains_key(STARRED_FACET_KEY));
}
#[test]
fn starred_filter_is_partition_aware_keeps_local_rows() {
let reg = build_facet_registry();
let rows = vec![
local_row("local-1", Some("xai"), Some("main")),
conv_row_starred("conv-starred", true),
conv_row_starred("conv-plain", false),
];
let mut filters = BTreeMap::new();
filters.insert(STARRED_FACET_KEY.to_owned(), vec![serde_json::json!(true)]);
let kept = reg.apply_in_memory_filters(&filters, rows);
let ids: Vec<&str> = kept.iter().map(|r| r.legacy.session_id.as_str()).collect();
assert!(ids.contains(&"local-1"));
assert!(ids.contains(&"conv-starred"));
assert!(!ids.contains(&"conv-plain"));
}
fn local_row_with_git(
session_id: &str,
git_root: Option<&str>,
@@ -656,49 +453,6 @@ mod tests {
merged_session_to_row(m, &build_facet_registry())
}
#[test]
fn git_path_facets_present_only_for_local_rows() {
let reg = build_facet_registry();
let local = NormalizedItem::from_merged(&MergedSession {
session_id: "s".into(),
summary: String::new(),
first_prompt: None,
updated_at: String::new(),
created_at: String::new(),
cwd: "/x".into(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 0,
last_active_at: None,
branch: None,
repo_name: None,
worktree_label: None,
git_root_dir: Some("/Users/me/xai".into()),
git_remotes: Vec::new(),
source_workspace_dir: Some("/Users/me/xai-main".into()),
session_kind: Some("worktree".into()),
});
let f = reg.extract_all(&local);
assert!(matches!(
f.get(GIT_ROOT_FACET_KEY),
Some(FacetValue::One(serde_json::Value::String(s))) if s == "/Users/me/xai"
));
assert!(matches!(
f.get(SOURCE_WORKSPACE_FACET_KEY),
Some(FacetValue::One(serde_json::Value::String(s))) if s == "/Users/me/xai-main"
));
// Conversations carry no local git enrichment.
let conv = NormalizedItem::from_conversation(&Conversation {
conversation_id: "c1".into(),
..Conversation::default()
});
let cf = reg.extract_all(&conv);
assert!(!cf.contains_key(GIT_ROOT_FACET_KEY));
assert!(!cf.contains_key(SOURCE_WORKSPACE_FACET_KEY));
}
#[test]
fn git_root_filter_keeps_matching_local_rows() {
let reg = build_facet_registry();
@@ -3,8 +3,7 @@ mod envelope;
mod facets;
mod row;
use crate::agent::session_registry_client::SessionRegistryClient;
use crate::remote::{ConvError, ConvQuery, ConversationsClient};
use cursor::{CompositeCursor, ConvLane, Paginated, merge_and_paginate};
use cursor::{CompositeCursor, Paginated, paginate};
pub use envelope::{FacetMap, FacetValue, SessionKind, SessionMetaEnvelope};
pub use facets::{
BRANCH_FACET_KEY, BranchFacet, CWD_FACET_KEY, CwdFacet, FacetProvider, FacetRegistry,
@@ -13,62 +12,18 @@ pub use facets::{
SOURCE_WORKSPACE_FACET_KEY, STARRED_FACET_KEY, SourceQuery, SourceWorkspaceFacet, StarredFacet,
WORKSPACE_FACET_KEY, WORKTREE_FACET_KEY, WorkspaceFacet, WorktreeFacet, build_facet_registry,
};
pub use row::{
ExtSupersetRow, RowMeta, SessionInfo, UnifiedRow, conversation_to_row, merged_session_to_row,
};
pub use row::{ExtSupersetRow, RowMeta, SessionInfo, UnifiedRow, merged_session_to_row};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::LazyLock;
pub const DEFAULT_LIMIT: usize = 30;
const CONV_PAGE_HEADROOM: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PartialReason {
Timeout,
Error,
NoOauth,
}
impl PartialReason {
fn as_str(self) -> &'static str {
match self {
PartialReason::Timeout => "timeout",
PartialReason::Error => "error",
PartialReason::NoOauth => "no_oauth",
}
}
}
static FACET_REGISTRY: LazyLock<FacetRegistry> = LazyLock::new(build_facet_registry);
pub fn facet_registry() -> &'static FacetRegistry {
&FACET_REGISTRY
}
/// Hard-off in release builds so they can't enable the
/// conversations lane via env.
pub fn conversations_lane_enabled() -> bool {
if true {
return false;
}
std::env::var("KIGI_SESSION_LIST_CONVERSATIONS")
.ok()
.is_some_and(|v| {
!matches!(
v.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "off" | "no"
)
})
}
/// Env lane (desktop `KIGI_SESSION_LIST_CONVERSATIONS`) OR process-wide
/// `--chat` (`KIGI_CHAT_MODE`); hard-off in release builds.
/// The single predicate `MvpAgent::conversations_client()` keys on.
pub fn conversations_lane_active() -> bool {
conversations_lane_enabled() || crate::agent::chat_modes::process_chat_mode_enabled()
}
/// Parse `x.ai/session/list` params and, under process-wide chat mode, force
/// the conversations-only `kind` facet (see [`force_kind_chat`]).
pub fn parse_list_req(raw: &str) -> Result<ListReq, serde_json::Error> {
let mut req: ListReq = serde_json::from_str(raw)?;
if crate::agent::chat_modes::process_chat_mode_enabled() {
force_kind_chat(&mut req);
}
Ok(req)
serde_json::from_str(raw)
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -88,7 +43,6 @@ pub struct UnifiedListResult {
pub rows: Vec<UnifiedRow>,
pub next_cursor: Option<String>,
pub facets: FacetSummary,
pub conversations_partial: Option<PartialReason>,
}
#[derive(Debug, Default)]
struct ParsedMeta {
@@ -131,33 +85,8 @@ fn value_list(v: &serde_json::Value) -> Vec<serde_json::Value> {
other => vec![other.clone()],
}
}
/// Rewrite `req` so the `kind` facet filter is exactly `["chat"]`.
///
/// REPLACES any client-sent `kind` allow-list (a union with `"build"` would
/// re-enable the local lane); every other facet filter and `_meta` key is
/// left untouched.
pub fn force_kind_chat(req: &mut ListReq) {
let mut meta = match req.meta.take() {
Some(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
let mut filters = match meta.remove("x.ai/facetFilters") {
Some(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
filters.insert(
KIND_FACET_KEY.to_owned(),
serde_json::json!([SessionKind::Chat.as_str()]),
);
meta.insert(
"x.ai/facetFilters".to_owned(),
serde_json::Value::Object(filters),
);
req.meta = Some(serde_json::Value::Object(meta));
}
pub async fn build_unified_list(
registry_client: Option<&SessionRegistryClient>,
conversations_client: Option<&ConversationsClient>,
req: ListReq,
) -> UnifiedListResult {
let reg = facet_registry();
@@ -171,13 +100,11 @@ pub async fn build_unified_list(
let cursor = CompositeCursor::decode(req.cursor.as_deref());
let mut source_query = SourceQuery::default();
reg.apply_pushdown(&facet_filters, &mut source_query);
let exclude_conversations = excludes_conversations(&facet_filters);
let exclude_build = excludes_build(&facet_filters);
let over = (limit * 3).max(100);
let local_fut = async {
if exclude_build {
return Vec::new();
}
let local_rows = if exclude_build {
Vec::new()
} else {
crate::session::merge::fetch_merged(
registry_client,
req.cwd.as_deref(),
@@ -189,84 +116,17 @@ pub async fn build_unified_list(
.map(|m| merged_session_to_row(m, reg))
.collect::<Vec<UnifiedRow>>()
};
let conv_fut = async {
if exclude_conversations {
return ConvLane::Skipped;
}
let Some(client) = conversations_client else {
return ConvLane::Skipped;
};
let q = ConvQuery {
page_size: (limit + CONV_PAGE_HEADROOM) as i64,
page_token: cursor.conv_page_token.clone(),
search_query: query.clone(),
workspace_id: source_query.workspace_id.clone(),
};
match tokio::time::timeout(
crate::session::merge::REMOTE_TIMEOUT,
client.list_conversations(&q),
)
.await
{
Ok(Ok(page)) => {
let next_token = page.next_page_token;
let rows: Vec<UnifiedRow> = page
.conversations
.into_iter()
.map(|c| conversation_to_row(c, reg))
.collect();
let frontier = cursor::conv_frontier(&rows, next_token.is_some());
ConvLane::Page {
rows,
next_token,
frontier,
}
}
Ok(Err(ConvError::NoOauth)) => ConvLane::Degraded(PartialReason::NoOauth),
Ok(Err(e)) => {
tracing::warn!("conversation list failed: {e}");
ConvLane::Degraded(PartialReason::Error)
}
Err(_) => {
tracing::warn!("conversation list timed out");
ConvLane::Degraded(PartialReason::Timeout)
}
}
};
let (local_rows, conv_lane) = tokio::join!(local_fut, conv_fut);
{
let (conv_lane_status, conv_rows) = match &conv_lane {
ConvLane::Skipped => ("skipped", 0),
ConvLane::Degraded(reason) => (reason.as_str(), 0),
ConvLane::Page { rows, .. } => ("ok", rows.len()),
};
tracing::debug!(
local_lane_skipped = exclude_build,
local_rows = local_rows.len(),
conv_lane = conv_lane_status,
conv_rows,
"session list lanes"
);
}
tracing::debug!(
local_lane_skipped = exclude_build,
local_rows = local_rows.len(),
"session list"
);
let local_rows = reg.apply_in_memory_filters(&facet_filters, local_rows);
let conv_lane = match conv_lane {
ConvLane::Page {
rows,
next_token,
frontier,
} => ConvLane::Page {
rows: reg.apply_in_memory_filters(&facet_filters, rows),
next_token,
frontier,
},
other => other,
};
let Paginated {
candidates,
emit_count,
next_cursor,
partial,
} = merge_and_paginate(local_rows, conv_lane, &cursor, limit);
} = paginate(local_rows, &cursor, limit);
let mut rows = candidates;
rows.truncate(emit_count);
let facets = reg.summarize_window(&rows);
@@ -274,15 +134,6 @@ pub async fn build_unified_list(
rows,
next_cursor: next_cursor.map(|c| c.encode()),
facets,
conversations_partial: partial,
}
}
fn excludes_conversations(filters: &BTreeMap<String, Vec<serde_json::Value>>) -> bool {
match filters.get(KIND_FACET_KEY) {
Some(allowed) if !allowed.is_empty() => !allowed
.iter()
.any(|v| v.as_str() == Some(SessionKind::Chat.as_str())),
_ => false,
}
}
/// Mirror of [`excludes_conversations`]: `true` when a non-empty `kind`
@@ -307,21 +158,12 @@ pub struct ExtListResponse {
pub struct ExtListResponseMeta {
#[serde(rename = "x.ai/facets")]
pub facets: FacetSummary,
#[serde(rename = "x.ai/partial")]
pub partial: PartialInfo,
}
#[derive(Debug, Clone, Serialize)]
pub struct PartialInfo {
pub conversations: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<&'static str>,
}
pub fn ext_list_response(result: UnifiedListResult) -> ExtListResponse {
let UnifiedListResult {
rows,
next_cursor,
facets,
conversations_partial,
} = result;
ExtListResponse {
sessions: rows
@@ -329,13 +171,7 @@ pub fn ext_list_response(result: UnifiedListResult) -> ExtListResponse {
.map(UnifiedRow::into_ext_superset)
.collect(),
next_cursor,
meta: ExtListResponseMeta {
facets,
partial: PartialInfo {
conversations: conversations_partial.is_some(),
reason: conversations_partial.map(PartialReason::as_str),
},
},
meta: ExtListResponseMeta { facets },
}
}
#[cfg(test)]
@@ -494,74 +330,8 @@ mod tests {
);
filters
}
#[test]
fn excludes_build_mirrors_excludes_conversations() {
assert!(excludes_build(&kind_filter(&["chat"])));
assert!(!excludes_conversations(&kind_filter(&["chat"])));
assert!(!excludes_build(&kind_filter(&["build"])));
assert!(excludes_conversations(&kind_filter(&["build"])));
assert!(!excludes_build(&kind_filter(&["build", "chat"])));
assert!(!excludes_conversations(&kind_filter(&["build", "chat"])));
assert!(!excludes_build(&kind_filter(&[])));
assert!(!excludes_conversations(&kind_filter(&[])));
assert!(!excludes_build(&BTreeMap::new()));
assert!(!excludes_conversations(&BTreeMap::new()));
}
/// The forced `kind` REPLACES a client-sent `kind: ["build"]` (never
/// unions), so the local lane stays excluded.
#[test]
fn forced_kind_replaces_client_build_filter() {
let mut req = ListReq {
meta: Some(serde_json::json!({ "x.ai/facetFilters" : { "kind" : ["build"] }, })),
..ListReq::default()
};
force_kind_chat(&mut req);
let parsed = ParsedMeta::parse(req.meta.as_ref());
assert_eq!(
parsed.facet_filters.get(KIND_FACET_KEY),
Some(&vec![serde_json::json!("chat")]),
"forced kind must replace the client filter, not union with it"
);
assert!(excludes_build(&parsed.facet_filters));
assert!(!excludes_conversations(&parsed.facet_filters));
}
#[test]
fn forced_kind_preserves_other_facets() {
let mut req = ListReq {
meta: Some(serde_json::json!(
{ "x.ai/facetFilters" : { "kind" : ["build"], "starred" : [true],
"workspace" : ["w1"] }, "x.ai/query" : "antelope", "x.ai/limit" : 5,
}
)),
..ListReq::default()
};
force_kind_chat(&mut req);
let parsed = ParsedMeta::parse(req.meta.as_ref());
assert_eq!(
parsed.facet_filters.get(KIND_FACET_KEY),
Some(&vec![serde_json::json!("chat")])
);
assert_eq!(
parsed.facet_filters.get("starred"),
Some(&vec![serde_json::json!(true)])
);
assert_eq!(
parsed.facet_filters.get("workspace"),
Some(&vec![serde_json::json!("w1")])
);
assert_eq!(parsed.query.as_deref(), Some("antelope"));
assert_eq!(parsed.limit, Some(5));
}
#[test]
fn forced_kind_creates_facet_filters_when_meta_absent() {
let mut req = ListReq::default();
force_kind_chat(&mut req);
let parsed = ParsedMeta::parse(req.meta.as_ref());
assert_eq!(
parsed.facet_filters.get(KIND_FACET_KEY),
Some(&vec![serde_json::json!("chat")])
);
}
fn xai_auth_manager(dir: &std::path::Path) -> std::sync::Arc<crate::auth::AuthManager> {
let am = std::sync::Arc::new(crate::auth::AuthManager::new(
dir,
@@ -598,191 +368,4 @@ mod tests {
});
addr
}
/// A client-sent `kind: ["build"]` rewritten by [`force_kind_chat`]
/// yields conversations only.
#[tokio::test]
#[serial_test::serial]
async fn forced_kind_serves_conversations_only() {
let addr = spawn_conversations_stub(
serde_json::json!(
{ "conversations" : [{ "conversationId" : "c1", "title" : "Hello",
"modifyTime" : "2026-07-01T00:00:00Z" }, { "conversationId" : "c2",
"title" : "", "modifyTime" : "2026-07-02T00:00:00Z" },], }
)
.to_string(),
)
.await;
let _env = kigi_test_support::EnvGuard::set(
"KIGI_CONVERSATIONS_BASE_URL",
format!("http://{addr}"),
);
let home = tempfile::tempdir().expect("tempdir");
let client = ConversationsClient::new(xai_auth_manager(home.path()));
let mut req = ListReq {
meta: Some(serde_json::json!({ "x.ai/facetFilters" : { "kind" : ["build"] }, })),
..ListReq::default()
};
force_kind_chat(&mut req);
let result = build_unified_list(None, Some(&client), req).await;
let ids: Vec<&str> = result
.rows
.iter()
.map(|r| r.legacy.session_id.as_str())
.collect();
assert_eq!(ids, ["c2", "c1"], "conversations only, newest first");
assert!(
result
.rows
.iter()
.all(|r| r.legacy.source == "conversation"),
"no build row may survive the forced kind filter"
);
assert_eq!(result.conversations_partial, None);
}
/// A degraded conversations lane (no OAuth) surfaces through
/// `conversations_partial` instead of failing the list.
#[tokio::test]
#[serial_test::serial]
async fn degraded_conversations_lane_reports_no_oauth() {
let home = tempfile::tempdir().expect("tempdir");
let auth = std::sync::Arc::new(crate::auth::AuthManager::new(
home.path(),
crate::auth::KimiCodeConfig::default(),
));
let client = ConversationsClient::new(auth);
let mut req = ListReq::default();
force_kind_chat(&mut req);
let result = build_unified_list(None, Some(&client), req).await;
assert!(result.rows.is_empty());
assert_eq!(result.conversations_partial, Some(PartialReason::NoOauth));
}
/// Build-mode canary: with no conversations client the lane is skipped —
/// not degraded.
#[tokio::test]
async fn non_chat_list_without_client_skips_conversations_lane() {
let req = ListReq {
cwd: Some("/nonexistent/unified-list-canary".into()),
..ListReq::default()
};
let result = build_unified_list(None, None, req).await;
assert_eq!(
result.conversations_partial, None,
"no client ⇒ lane skipped, never reported as degraded"
);
assert!(result.rows.is_empty());
}
/// Desktop env lane stays env-gated; process chat mode is feature-gated.
#[test]
#[serial_test::serial]
fn conversations_lane_env_gating_matrix() {
{
let _off = kigi_test_support::EnvGuard::unset("KIGI_SESSION_LIST_CONVERSATIONS");
assert!(!conversations_lane_enabled());
}
{
let _on = kigi_test_support::EnvGuard::set("KIGI_SESSION_LIST_CONVERSATIONS", "1");
assert!(!conversations_lane_enabled());
}
{
let _off = kigi_test_support::EnvGuard::set("KIGI_SESSION_LIST_CONVERSATIONS", "0");
assert!(!conversations_lane_enabled());
}
}
/// Truth table for `conversations_lane_active`: desktop env lane OR
/// process chat mode, hard-off in release builds.
#[test]
#[serial_test::serial]
fn conversations_lane_active_truth_table() {
use crate::agent::chat_modes::KIGI_CHAT_MODE_ENV;
let _chat_off = kigi_test_support::EnvGuard::unset(KIGI_CHAT_MODE_ENV);
let _desktop_off = kigi_test_support::EnvGuard::unset("KIGI_SESSION_LIST_CONVERSATIONS");
assert!(
!conversations_lane_active(),
"no env ⇒ lane off (Build-mode default)"
);
{
let _desktop = kigi_test_support::EnvGuard::set("KIGI_SESSION_LIST_CONVERSATIONS", "1");
assert!(!conversations_lane_active());
}
{
let _chat = kigi_test_support::EnvGuard::set(KIGI_CHAT_MODE_ENV, "1");
assert!(
!conversations_lane_active(),
"process chat mode must enable the lane (chat feature only)"
);
}
}
/// `parse_list_req` forces the conversations-only `kind` exactly when
/// process chat mode is on; otherwise the client request is untouched.
#[test]
#[serial_test::serial]
fn parse_list_req_forces_kind_under_process_chat_mode_only() {
use crate::agent::chat_modes::KIGI_CHAT_MODE_ENV;
let raw = serde_json::json!(
{ "_meta" : { "x.ai/facetFilters" : { "kind" : ["build"], "starred" : [true]
} }, }
)
.to_string();
{
let _off = kigi_test_support::EnvGuard::unset(KIGI_CHAT_MODE_ENV);
let req = parse_list_req(&raw).expect("parse");
let parsed = ParsedMeta::parse(req.meta.as_ref());
assert_eq!(
parsed.facet_filters.get(KIND_FACET_KEY),
Some(&vec![serde_json::json!("build")]),
"non-chat: client kind filter untouched"
);
}
{
let _on = kigi_test_support::EnvGuard::set(KIGI_CHAT_MODE_ENV, "1");
let req = parse_list_req(&raw).expect("parse");
let parsed = ParsedMeta::parse(req.meta.as_ref());
let expected = if false { "chat" } else { "build" };
assert_eq!(
parsed.facet_filters.get(KIND_FACET_KEY),
Some(&vec![serde_json::json!(expected)])
);
assert_eq!(
parsed.facet_filters.get("starred"),
Some(&vec![serde_json::json!(true)]),
"other facets pass through"
);
}
}
/// Wire pin for the cross-crate `x.ai/partial` envelope the pager parses:
/// the serialized reason strings must not drift (the pager maps unknown
/// reasons to a generic retry notice, masking a rename).
#[test]
fn ext_list_response_serializes_partial_reasons() {
for (reason, wire) in [
(PartialReason::NoOauth, "no_oauth"),
(PartialReason::Timeout, "timeout"),
(PartialReason::Error, "error"),
] {
let value = serde_json::to_value(ext_list_response(UnifiedListResult {
rows: Vec::new(),
next_cursor: None,
facets: facet_registry().summarize_window(&[]),
conversations_partial: Some(reason),
}))
.expect("serialize");
assert_eq!(
value["_meta"]["x.ai/partial"],
serde_json::json!({ "conversations" :
true, "reason" : wire })
);
}
let healthy = serde_json::to_value(ext_list_response(UnifiedListResult {
rows: Vec::new(),
next_cursor: None,
facets: facet_registry().summarize_window(&[]),
conversations_partial: None,
}))
.expect("serialize");
assert_eq!(
healthy["_meta"]["x.ai/partial"],
serde_json::json!({ "conversations" :
false })
);
}
}
@@ -2,7 +2,6 @@ use serde::Serialize;
use super::envelope::{FacetMap, SessionKind, SessionMetaEnvelope};
use super::facets::{FacetRegistry, NormalizedItem};
use crate::remote::Conversation;
use crate::session::merge::MergedSession;
#[derive(Debug, Clone)]
@@ -73,44 +72,6 @@ pub fn merged_session_to_row(m: MergedSession, reg: &FacetRegistry) -> UnifiedRo
}
}
pub fn conversation_to_row(c: Conversation, reg: &FacetRegistry) -> UnifiedRow {
let facets = reg.extract_all(&NormalizedItem::from_conversation(&c));
let Conversation {
conversation_id,
title,
modify_time,
create_time,
..
} = c;
let legacy = MergedSession {
session_id: conversation_id,
summary: title.clone(),
first_prompt: None,
updated_at: modify_time.as_deref().unwrap_or_default().to_owned(),
created_at: create_time.unwrap_or_default(),
cwd: String::new(),
hostname: None,
source: "conversation".to_string(),
model_id: None,
num_messages: 0,
last_active_at: modify_time.clone(),
branch: None,
repo_name: None,
worktree_label: None,
git_root_dir: None,
git_remotes: Vec::new(),
source_workspace_dir: None,
session_kind: None,
};
UnifiedRow {
kind: SessionKind::Chat,
legacy,
title,
updated_at: modify_time,
facets,
}
}
fn effective_local_ts(m: &MergedSession) -> Option<String> {
m.last_active_at
.as_deref()
@@ -151,47 +112,4 @@ pub struct SessionInfo {
mod tests {
use super::*;
use crate::session::unified_list::facet_registry;
#[test]
fn conversation_row_uses_conversation_id_as_session_id() {
let c = Conversation {
conversation_id: "conv_abc123".into(),
title: "Compare GPU vendors".into(),
modify_time: Some("2026-06-18T18:02:00Z".into()),
create_time: Some("2026-06-18T17:30:00Z".into()),
..Conversation::default()
};
let row = conversation_to_row(c, facet_registry());
assert_eq!(row.legacy.session_id, "conv_abc123");
assert_eq!(row.kind, SessionKind::Chat);
assert_eq!(row.legacy.source, "conversation");
assert_eq!(row.legacy.cwd, "");
let ext = serde_json::to_value(row.clone().into_ext_superset()).unwrap();
assert_eq!(ext["sessionId"], "conv_abc123");
assert_eq!(ext["cwd"], "");
assert_eq!(ext["source"], "conversation");
assert_eq!(ext["_meta"]["x.ai/session"]["kind"], "chat");
// Chat rows have no local git enrichment (fields omitted).
assert!(ext.get("gitRootDir").is_none());
assert!(ext.get("gitRemotes").is_none());
assert!(ext.get("sourceWorkspaceDir").is_none());
assert!(ext.get("sessionKind").is_none());
let bare = serde_json::to_value(row.into_session_info()).unwrap();
assert_eq!(bare["sessionId"], "conv_abc123");
}
#[test]
fn conversation_missing_modify_time_still_resumable() {
let c = Conversation {
conversation_id: "conv_no_time".into(),
title: "Untitled".into(),
..Conversation::default()
};
let row = conversation_to_row(c, facet_registry());
assert_eq!(row.legacy.session_id, "conv_no_time");
assert!(row.updated_at.is_none());
assert_eq!(row.legacy.updated_at, "");
}
}
@@ -387,7 +387,7 @@ async fn resume_local_session_in_worktree(
source_workspace_dir: Some(resolved_source_cwd.to_owned()),
..Default::default()
};
let fork_resp = match fork_session(fork_req, agent_id, auth_manager).await {
let fork_resp = match fork_session(fork_req).await {
Ok(r) => r,
Err(e) => {
cleanup_worktree_on_failure(resolved_source_cwd, &wt_resp.worktree_path).await;