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:
@@ -147,7 +147,6 @@ kigi-auth = { workspace = true, features = ["middleware"] }
|
||||
kigi-log = { workspace = true }
|
||||
kigi-http = { workspace = true }
|
||||
kigi-models = { workspace = true }
|
||||
prod-mc-cli-chat-proxy-types = { path = "../../../prod/mc/cli-chat-proxy-types" }
|
||||
flate2 = { workspace = true }
|
||||
fs2 = { workspace = true }
|
||||
zstd = { workspace = true }
|
||||
|
||||
@@ -536,7 +536,6 @@ fn summary_ids(summaries: &[Summary]) -> Vec<String> {
|
||||
|
||||
async fn build_local_list_with_delayed_peer(cwd: String) -> UnifiedListResult {
|
||||
let local = build_unified_list(
|
||||
None,
|
||||
None,
|
||||
ListReq {
|
||||
cwd: Some(cwd),
|
||||
@@ -642,7 +641,6 @@ fn bench_session_list(c: &mut Criterion) {
|
||||
|b| {
|
||||
b.iter_with_large_drop(|| {
|
||||
black_box(runtime.block_on(build_unified_list(
|
||||
None,
|
||||
None,
|
||||
ListReq {
|
||||
cwd: Some(black_box(fixture.picker_cwd.clone())),
|
||||
|
||||
@@ -621,10 +621,8 @@ pub async fn run_leader(
|
||||
let fetch_auth_for_prefetch = ModelFetchAuth::resolve(&endpoints_for_prefetch);
|
||||
let platform_keys_for_prefetch =
|
||||
crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms);
|
||||
// The shared pair helper owns the remote_fetch gate for both halves, so a
|
||||
// disabled knob cannot block leader readiness on settings retries.
|
||||
let (prefetched_models, remote_settings) = tokio::task::spawn_blocking(move || {
|
||||
crate::agent::models::prefetch_models_and_settings_blocking(
|
||||
let prefetched_models = tokio::task::spawn_blocking(move || {
|
||||
crate::agent::models::prefetch_models_blocking(
|
||||
&endpoints_for_prefetch,
|
||||
auth_for_prefetch.as_ref(),
|
||||
fetch_auth_for_prefetch,
|
||||
@@ -632,20 +630,7 @@ pub async fn run_leader(
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or((None, None));
|
||||
|
||||
// Process-wide image normalize cache: off by default, toggled here from
|
||||
// `RemoteSettings.image_normalize_cache_enabled` once at startup.
|
||||
let image_normalize_cache_enabled = remote_settings
|
||||
.as_ref()
|
||||
.and_then(|r| r.image_normalize_cache_enabled)
|
||||
.unwrap_or(false);
|
||||
crate::session::normalize_cache::NormalizeCache::global()
|
||||
.set_enabled(image_normalize_cache_enabled);
|
||||
tracing::debug!(
|
||||
enabled = image_normalize_cache_enabled,
|
||||
"image normalize cache toggle resolved from remote settings"
|
||||
);
|
||||
.unwrap_or(None);
|
||||
|
||||
// ── Phase 7: Signal readiness ─────────────────────────────────────────────
|
||||
//
|
||||
@@ -657,9 +642,7 @@ pub async fn run_leader(
|
||||
// ── Phase 8: LocalSet — agent, bridges, config watcher ───────────────────
|
||||
|
||||
let local_set = tokio::task::LocalSet::new();
|
||||
let remote_settings_for_reloader = remote_settings.clone();
|
||||
let mut agent_config_for_spawn = agent_config.clone();
|
||||
agent_config_for_spawn.remote_settings = remote_settings;
|
||||
crate::util::config::sync_campaign_fields(&mut agent_config_for_spawn);
|
||||
let agent_to_ipc_tx_clone = agent_to_ipc_tx.clone();
|
||||
let cancel_clone = cancel.clone();
|
||||
@@ -891,7 +874,7 @@ pub async fn run_leader(
|
||||
initial_auth_key_hash,
|
||||
initial_config,
|
||||
auth_scope,
|
||||
remote_settings_for_reloader,
|
||||
None,
|
||||
config_update_tx,
|
||||
agent_config.cli_experimental_memory,
|
||||
agent_config.cli_no_memory,
|
||||
|
||||
@@ -1,334 +1,16 @@
|
||||
//! grok.com chat-product model catalog: caches `/rest/modes` and maps modes to
|
||||
//! the `SessionModelState` returned by `load_chat_session` (the chat analogue of
|
||||
//! [`crate::agent::models::ModelsManager`]). NB: these "modes" populate the
|
||||
//! desktop MODEL picker, not the ACP session plan-modes in `LoadSessionResponse.modes`.
|
||||
use crate::auth::AuthManager;
|
||||
use crate::remote::chat_models_client::{
|
||||
ChatModelsClient, ChatModelsError, ListModesResponse, Mode,
|
||||
};
|
||||
use agent_client_protocol as acp;
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
/// ~54 min, matching grok-web's refetch cadence.
|
||||
const CACHE_TTL: Duration = Duration::from_secs(54 * 60);
|
||||
/// Cold-miss budget on the `session/load` critical path (warm/stale served instantly).
|
||||
const COLD_FETCH_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const DEFAULT_LOCALE: &str = "en";
|
||||
/// Process-wide flag set by the pager when started with `--chat` so initialize
|
||||
/// and early UI seed the chat `/rest/modes` catalog instead of build models.
|
||||
//! Legacy `--chat` gateway gate.
|
||||
//!
|
||||
//! The grok.com chat-product model picker (`/rest/modes`, `ChatModesManager`)
|
||||
//! was removed with the xAI proxy: those "modes" came from a grok backend with
|
||||
//! no Kimi counterpart. Only the process-mode gate survives so the `--chat`
|
||||
//! frontend path stays a compile-time-off no-op across crates without a
|
||||
//! cross-crate churn to delete every reference.
|
||||
|
||||
/// Process-wide flag set by the pager when started with `--chat`.
|
||||
pub const KIGI_CHAT_MODE_ENV: &str = "KIGI_CHAT_MODE";
|
||||
|
||||
/// True when the process is a gateway light-frontend (`--chat`) agent.
|
||||
/// Hard-off in release builds so it can't be enabled via env.
|
||||
/// Hard-off: the grok chat-modes backend is gone, so this is always `false`.
|
||||
pub fn process_chat_mode_enabled() -> bool {
|
||||
if true {
|
||||
return false;
|
||||
}
|
||||
match std::env::var(KIGI_CHAT_MODE_ENV) {
|
||||
Ok(v) => {
|
||||
let v = v.trim();
|
||||
!v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct CachedModes {
|
||||
/// Keyed by identity; a mismatch is a miss so one user's modes never leak to another.
|
||||
user_id: String,
|
||||
locale: String,
|
||||
fetched_at: Instant,
|
||||
response: ListModesResponse,
|
||||
}
|
||||
/// Thread-safe, cheaply-cloneable manager. Cloning bumps the inner `Arc`.
|
||||
#[derive(Clone)]
|
||||
pub struct ChatModesManager {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
struct Inner {
|
||||
auth: Arc<AuthManager>,
|
||||
cache: RwLock<Option<CachedModes>>,
|
||||
/// Single-flight guard so concurrent fetches coalesce.
|
||||
fetch_lock: tokio::sync::Mutex<()>,
|
||||
}
|
||||
impl ChatModesManager {
|
||||
pub fn new(auth: Arc<AuthManager>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
auth,
|
||||
cache: RwLock::new(None),
|
||||
fetch_lock: tokio::sync::Mutex::new(()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// The active grok.com identity, or `None` when unauthenticated. Modes are
|
||||
/// per-identity (tier/ACL), so every cache key and store is gated on it.
|
||||
fn current_user_id(&self) -> Option<String> {
|
||||
self.inner.auth.current_or_expired().map(|a| a.user_id)
|
||||
}
|
||||
/// Chat model state for a `session/load` response. On missing auth or fetch
|
||||
/// failure, serves last-good cache else empty — never the build catalog.
|
||||
pub async fn model_state(&self) -> acp::SessionModelState {
|
||||
let Some(user_id) = self.current_user_id() else {
|
||||
return empty_state();
|
||||
};
|
||||
let locale = DEFAULT_LOCALE;
|
||||
{
|
||||
let guard = self.inner.cache.read();
|
||||
if let Some(c) = guard.as_ref()
|
||||
&& c.user_id == user_id
|
||||
&& c.locale == locale
|
||||
{
|
||||
if c.fetched_at.elapsed() < CACHE_TTL {
|
||||
return modes_to_model_state(&c.response);
|
||||
}
|
||||
let stale = c.response.clone();
|
||||
drop(guard);
|
||||
self.spawn_refresh(user_id, locale);
|
||||
return modes_to_model_state(&stale);
|
||||
}
|
||||
}
|
||||
let _flight = self.inner.fetch_lock.lock().await;
|
||||
{
|
||||
let guard = self.inner.cache.read();
|
||||
if let Some(c) = guard.as_ref()
|
||||
&& c.user_id == user_id
|
||||
&& c.locale == locale
|
||||
&& c.fetched_at.elapsed() < CACHE_TTL
|
||||
{
|
||||
return modes_to_model_state(&c.response);
|
||||
}
|
||||
}
|
||||
match self.fetch(locale).await {
|
||||
Ok(resp) if !resp.modes.is_empty() => {
|
||||
if self.current_user_id().as_deref() != Some(user_id.as_str()) {
|
||||
return empty_state();
|
||||
}
|
||||
let mapped = modes_to_model_state(&resp);
|
||||
if mapped.available_models.is_empty() {
|
||||
tracing::warn!(
|
||||
raw_modes = resp.modes.len(),
|
||||
"chat modes: fetch returned modes but none selectable after availability filter"
|
||||
);
|
||||
}
|
||||
self.store(user_id, locale.to_owned(), resp);
|
||||
mapped
|
||||
}
|
||||
Ok(_) => empty_state(),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = % err, "chat modes fetch failed; serving cache/empty"
|
||||
);
|
||||
let guard = self.inner.cache.read();
|
||||
match guard.as_ref() {
|
||||
Some(c) if c.user_id == user_id => modes_to_model_state(&c.response),
|
||||
_ => empty_state(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn fetch(&self, locale: &str) -> Result<ListModesResponse, ChatModelsError> {
|
||||
let client = ChatModelsClient::new(self.inner.auth.clone());
|
||||
match tokio::time::timeout(COLD_FETCH_TIMEOUT, client.list_modes(locale)).await {
|
||||
Ok(result) => result,
|
||||
Err(_elapsed) => Err(ChatModelsError::Timeout),
|
||||
}
|
||||
}
|
||||
fn store(&self, user_id: String, locale: String, response: ListModesResponse) {
|
||||
*self.inner.cache.write() = Some(CachedModes {
|
||||
user_id,
|
||||
locale,
|
||||
fetched_at: Instant::now(),
|
||||
response,
|
||||
});
|
||||
}
|
||||
/// Best-effort stale refresh; skips if a fetch is already in flight.
|
||||
fn spawn_refresh(&self, user_id: String, locale: &'static str) {
|
||||
let me = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let Ok(_flight) = me.inner.fetch_lock.try_lock() else {
|
||||
return;
|
||||
};
|
||||
if me.current_user_id().as_deref() != Some(user_id.as_str()) {
|
||||
return;
|
||||
}
|
||||
if let Ok(resp) = me.fetch(locale).await
|
||||
&& !resp.modes.is_empty()
|
||||
&& me.current_user_id().as_deref() == Some(user_id.as_str())
|
||||
{
|
||||
me.store(user_id, locale.to_owned(), resp);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Kick a background `/rest/modes` fill when auth is already present so
|
||||
/// `--chat` initialize / first `session/new` hit a warm cache.
|
||||
pub fn warm_in_background(&self) {
|
||||
let Some(user_id) = self.current_user_id() else {
|
||||
return;
|
||||
};
|
||||
self.spawn_refresh(user_id, DEFAULT_LOCALE);
|
||||
}
|
||||
}
|
||||
fn empty_state() -> acp::SessionModelState {
|
||||
acp::SessionModelState::new(acp::ModelId::from(String::new()), Vec::new())
|
||||
}
|
||||
/// Maps grok.com modes → `SessionModelState`: keeps only `available` modes,
|
||||
/// reconciles `current_model_id` (default → first available → empty, never
|
||||
/// out-of-set), and stashes `badgeText`/`iconHint`/`tags` in `_meta`.
|
||||
pub fn modes_to_model_state(resp: &ListModesResponse) -> acp::SessionModelState {
|
||||
let available_models: Vec<acp::ModelInfo> = resp
|
||||
.modes
|
||||
.iter()
|
||||
.filter(|m| m.is_available())
|
||||
.map(mode_to_model_info)
|
||||
.collect();
|
||||
let current_model_id = reconcile_current(&resp.default_mode_id, &available_models);
|
||||
acp::SessionModelState::new(current_model_id, available_models)
|
||||
}
|
||||
fn mode_to_model_info(m: &Mode) -> acp::ModelInfo {
|
||||
let name = if m.title.trim().is_empty() {
|
||||
m.id.clone()
|
||||
} else {
|
||||
m.title.clone()
|
||||
};
|
||||
acp::ModelInfo::new(acp::ModelId::from(m.id.clone()), name)
|
||||
.description(if m.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(m.description.clone())
|
||||
})
|
||||
.meta(build_meta(m))
|
||||
}
|
||||
fn build_meta(m: &Mode) -> Option<acp::Meta> {
|
||||
let mut map = serde_json::Map::new();
|
||||
if let Some(badge) = m.badge_text.as_deref().filter(|s| !s.is_empty()) {
|
||||
map.insert("badgeText".to_owned(), serde_json::json!(badge));
|
||||
}
|
||||
if !m.icon_hint.is_empty() {
|
||||
map.insert("iconHint".to_owned(), serde_json::json!(m.icon_hint));
|
||||
}
|
||||
if !m.tags.is_empty() {
|
||||
map.insert("tags".to_owned(), serde_json::json!(m.tags));
|
||||
}
|
||||
if map.is_empty() { None } else { Some(map) }
|
||||
}
|
||||
fn reconcile_current(default_mode_id: &str, available: &[acp::ModelInfo]) -> acp::ModelId {
|
||||
let in_set = |id: &str| available.iter().any(|m| m.model_id.0.as_ref() == id);
|
||||
if !default_mode_id.is_empty() && in_set(default_mode_id) {
|
||||
acp::ModelId::from(default_mode_id.to_owned())
|
||||
} else if let Some(first) = available.first() {
|
||||
first.model_id.clone()
|
||||
} else {
|
||||
acp::ModelId::from(String::new())
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::remote::chat_models_client::ModeAvailability;
|
||||
fn available(id: &str, title: &str) -> Mode {
|
||||
Mode {
|
||||
id: id.to_owned(),
|
||||
title: title.to_owned(),
|
||||
availability: ModeAvailability {
|
||||
available: Some(serde_json::json!({})),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn requires_upgrade(id: &str) -> Mode {
|
||||
Mode {
|
||||
id: id.to_owned(),
|
||||
availability: ModeAvailability {
|
||||
requires_upgrade: Some(serde_json::json!({ "message" : "Upgrade" })),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn filters_to_available_modes() {
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![
|
||||
available("auto", "Auto"),
|
||||
requires_upgrade("heavy"),
|
||||
available("fast", "Fast"),
|
||||
],
|
||||
default_mode_id: "auto".to_owned(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
let ids: Vec<String> = state
|
||||
.available_models
|
||||
.iter()
|
||||
.map(|m| m.model_id.0.to_string())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["auto".to_string(), "fast".to_string()]);
|
||||
assert_eq!(state.current_model_id.0.as_ref(), "auto");
|
||||
}
|
||||
#[test]
|
||||
fn default_outside_filtered_set_falls_back_to_first_available() {
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![requires_upgrade("heavy"), available("fast", "Fast")],
|
||||
default_mode_id: "heavy".to_owned(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
assert_eq!(state.current_model_id.0.as_ref(), "fast");
|
||||
assert!(
|
||||
state
|
||||
.available_models
|
||||
.iter()
|
||||
.any(|m| m.model_id == state.current_model_id)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn empty_default_falls_back_to_first() {
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![available("a", "A"), available("b", "B")],
|
||||
default_mode_id: String::new(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
assert_eq!(state.current_model_id.0.as_ref(), "a");
|
||||
}
|
||||
#[test]
|
||||
fn no_available_modes_yields_empty_current() {
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![requires_upgrade("heavy")],
|
||||
default_mode_id: "heavy".to_owned(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
assert!(state.available_models.is_empty());
|
||||
assert_eq!(state.current_model_id.0.as_ref(), "");
|
||||
}
|
||||
#[test]
|
||||
fn maps_fields_and_meta() {
|
||||
let mut m = available("auto", "Auto");
|
||||
m.description = "Picks the best model".to_owned();
|
||||
m.badge_text = Some("New".to_owned());
|
||||
m.icon_hint = "rocket".to_owned();
|
||||
m.tags = vec!["TAG_PRIMARY".to_owned()];
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![m],
|
||||
default_mode_id: "auto".to_owned(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
let info = &state.available_models[0];
|
||||
assert_eq!(info.name, "Auto");
|
||||
assert_eq!(info.description.as_deref(), Some("Picks the best model"));
|
||||
let meta = info.meta.as_ref().unwrap();
|
||||
assert_eq!(meta["badgeText"], serde_json::json!("New"));
|
||||
assert_eq!(meta["iconHint"], serde_json::json!("rocket"));
|
||||
assert_eq!(meta["tags"], serde_json::json!(["TAG_PRIMARY"]));
|
||||
}
|
||||
#[test]
|
||||
fn name_falls_back_to_id_when_title_blank() {
|
||||
let mut m = available("grok-4.5", "");
|
||||
m.title = " ".to_owned();
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![m],
|
||||
default_mode_id: String::new(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
assert_eq!(state.available_models[0].name, "grok-4.5");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::agent::auth_method::ModelByok;
|
||||
use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
|
||||
use crate::auth::{AuthManager, KimiCodeConfig};
|
||||
use crate::remote::DEFAULT_CONTEXT_WINDOW;
|
||||
use crate::{config::StorageMode, sampling::ApiBackend, tools::config::ShellToolsetConfig};
|
||||
use agent_client_protocol as acp;
|
||||
use indexmap::IndexMap;
|
||||
@@ -141,7 +141,7 @@ pub struct EndpointsConfig {
|
||||
/// `Some` = explicitly configured. Tracking explicitness (vs comparing to the
|
||||
/// default value) lets an org pin the proxy to the default on purpose.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cli_chat_proxy_base_url: Option<String>,
|
||||
pub coding_api_base_url: Option<String>,
|
||||
/// Base URL for the public xAI API.
|
||||
pub xai_api_base_url: String,
|
||||
/// Optional extra access-header value (applied only with the optional
|
||||
@@ -271,11 +271,11 @@ impl EndpointsConfig {
|
||||
resolved
|
||||
}
|
||||
/// The subscription proxy base URL through which all auxiliary services (and
|
||||
/// OAuth/session inference) resolve: explicit `cli_chat_proxy_base_url`, else
|
||||
/// OAuth/session inference) resolve: explicit `coding_api_base_url`, else
|
||||
/// [`kigi_env::coding_api_base_url`]. NEVER falls back to `xai_api_base_url` —
|
||||
/// that is the inference endpoint (API-key auth) only.
|
||||
pub fn proxy_url(&self) -> String {
|
||||
blank_as_unset(&self.cli_chat_proxy_base_url).unwrap_or_else(kigi_env::coding_api_base_url)
|
||||
blank_as_unset(&self.coding_api_base_url).unwrap_or_else(kigi_env::coding_api_base_url)
|
||||
}
|
||||
pub fn resolve_inference_base_url(&self) -> String {
|
||||
self.models_base_url
|
||||
@@ -406,7 +406,7 @@ impl EndpointsConfig {
|
||||
impl Default for EndpointsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cli_chat_proxy_base_url: std::env::var("KIGI_CLI_CHAT_PROXY_BASE_URL").ok(),
|
||||
coding_api_base_url: std::env::var("KIGI_CODE_BASE_URL").ok(),
|
||||
xai_api_base_url: std::env::var("KIGI_XAI_API_BASE_URL")
|
||||
.unwrap_or_else(|_| XAI_API_BASE_URL_DEFAULT.to_owned()),
|
||||
alpha_test_key: None,
|
||||
@@ -4169,19 +4169,11 @@ pub fn resolve_aux_model_sampling_config(
|
||||
endpoints: &EndpointsConfig,
|
||||
session_key: Option<&str>,
|
||||
alpha_test_key: Option<String>,
|
||||
client_version: Option<String>,
|
||||
) -> Option<SamplerConfig> {
|
||||
let catalog_entry = find_model_by_id(models, model_id).cloned();
|
||||
if let Some(entry) = &catalog_entry {
|
||||
let credentials = resolve_credentials(entry, session_key);
|
||||
let sampler = sampling_config_for_model(
|
||||
entry,
|
||||
credentials,
|
||||
alpha_test_key.clone(),
|
||||
client_version.clone(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let sampler = sampling_config_for_model(entry, credentials, alpha_test_key.clone());
|
||||
if sampler.api_key.is_some() {
|
||||
return Some(sampler);
|
||||
}
|
||||
@@ -4232,14 +4224,7 @@ pub fn resolve_aux_model_sampling_config(
|
||||
api_base_url: None,
|
||||
};
|
||||
let credentials = resolve_credentials(&entry, session_key);
|
||||
let sampler = sampling_config_for_model(
|
||||
&entry,
|
||||
credentials,
|
||||
alpha_test_key,
|
||||
client_version,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let sampler = sampling_config_for_model(&entry, credentials, alpha_test_key);
|
||||
return Some(sampler);
|
||||
}
|
||||
tracing::warn!(
|
||||
@@ -4252,21 +4237,19 @@ pub fn resolve_aux_model_sampling_config(
|
||||
/// Shared so the aux resolve happy path and the
|
||||
/// `None` fallback cannot diverge between those entry points.
|
||||
///
|
||||
/// On aux resolve `Some`, stamp session-local fields (client id, attribution, bearer,
|
||||
/// On aux resolve `Some`, stamp session-local fields (attribution, bearer,
|
||||
/// retries) onto the helper config. On `None`, fall back to the active session model and
|
||||
/// full config (not forcing `image_description_model` onto the agent endpoint, which 404s
|
||||
/// on BYOK / non-proxy routes for internal slugs like `grok-build`).
|
||||
/// Stamp the session-local fields (client id, attribution, bearer resolver,
|
||||
/// retries) from the active session onto a routed aux `SamplerConfig` so a
|
||||
/// on BYOK / non-proxy routes for internal slugs).
|
||||
/// Stamp the session-local fields (attribution, bearer resolver, retries)
|
||||
/// from the active session onto a routed aux `SamplerConfig` so a
|
||||
/// helper model keeps the session's auth/attribution. Shared by image-describe
|
||||
/// and the auto-mode classifier so the two can't drift.
|
||||
pub fn stamp_session_local_sampler_fields(
|
||||
cfg: &mut SamplerConfig,
|
||||
active_session_config: &SamplerConfig,
|
||||
client_identifier: Option<String>,
|
||||
max_retries: Option<u32>,
|
||||
) {
|
||||
cfg.client_identifier = client_identifier;
|
||||
cfg.attribution_callback = active_session_config.attribution_callback.clone();
|
||||
cfg.bearer_resolver = active_session_config.bearer_resolver.clone();
|
||||
cfg.max_retries = max_retries;
|
||||
@@ -4274,7 +4257,6 @@ pub fn stamp_session_local_sampler_fields(
|
||||
pub fn finalize_image_describe_sampler_config(
|
||||
resolved_aux: Option<SamplerConfig>,
|
||||
active_session_config: &SamplerConfig,
|
||||
client_identifier: Option<String>,
|
||||
max_retries: Option<u32>,
|
||||
) -> (String, SamplerConfig) {
|
||||
match resolved_aux {
|
||||
@@ -4282,7 +4264,6 @@ pub fn finalize_image_describe_sampler_config(
|
||||
stamp_session_local_sampler_fields(
|
||||
&mut describe_cfg,
|
||||
active_session_config,
|
||||
client_identifier,
|
||||
max_retries,
|
||||
);
|
||||
let model = describe_cfg.model.clone();
|
||||
@@ -4310,9 +4291,6 @@ pub fn sampling_config_for_model(
|
||||
model: &ModelEntry,
|
||||
credentials: ResolvedCredentials,
|
||||
alpha_test_key: Option<String>,
|
||||
client_version: Option<String>,
|
||||
deployment_id: Option<String>,
|
||||
user_id: Option<String>,
|
||||
) -> SamplerConfig {
|
||||
let info = model.info();
|
||||
let model_name = info.model.clone();
|
||||
@@ -4337,15 +4315,11 @@ pub fn sampling_config_for_model(
|
||||
auth_scheme: credentials.auth_scheme,
|
||||
extra_headers,
|
||||
context_window: info.context_window.get(),
|
||||
client_version,
|
||||
reasoning_effort: info.reasoning_effort,
|
||||
force_http1: false,
|
||||
max_retries: info.max_retries,
|
||||
stream_tool_calls: info.stream_tool_calls.unwrap_or(false),
|
||||
idle_timeout_secs: None,
|
||||
client_identifier: None,
|
||||
deployment_id,
|
||||
user_id,
|
||||
origin_client: None,
|
||||
attribution_callback: None,
|
||||
bearer_resolver: None,
|
||||
@@ -4359,11 +4333,19 @@ pub fn sampling_config_for_model(
|
||||
/// Fold URL-derived headers into `extra_headers`.
|
||||
///
|
||||
/// The sampler crate is intentionally URL-agnostic: it does not inspect
|
||||
/// `base_url` to decide which auth or staging headers to add. Replicate the
|
||||
/// `base_url` to decide which auth or identity headers to add. Replicate the
|
||||
/// URL-derived header logic at the shell boundary so callers downstream see a
|
||||
/// single homogenous header bag.
|
||||
///
|
||||
/// * First-party bases get the client-mode header.
|
||||
/// * First-party (Kimi subscription) bases get the `X-Msh-Device-*` identity
|
||||
/// headers, mirroring the official client, which sends its OAuth device
|
||||
/// headers on every inference request (kimi-cli src/kimi_cli/llm.py:317-323
|
||||
/// `_kimi_default_headers` merges `oauth.common_headers()`). Third-party /
|
||||
/// Moonshot-open-platform bases get none — only the bearer and User-Agent.
|
||||
///
|
||||
/// A device-id failure only skips the headers (with a warning): inference
|
||||
/// must not hard-fail because `~/.kigi/device_id` is unwritable — unlike
|
||||
/// OAuth login, where the id is mandatory.
|
||||
///
|
||||
/// Existing entries are never overwritten so callers can pre-set a value.
|
||||
pub fn inject_url_derived_headers(
|
||||
@@ -4371,10 +4353,20 @@ pub fn inject_url_derived_headers(
|
||||
alpha_test_key: Option<&str>,
|
||||
base_url: &str,
|
||||
) {
|
||||
if crate::util::is_cli_chat_proxy_url(base_url) {
|
||||
headers
|
||||
.entry(crate::http::CLIENT_MODE_HEADER.to_string())
|
||||
.or_insert_with(|| crate::http::process_client_mode().to_string());
|
||||
if crate::util::is_production_coding_api_url(base_url) {
|
||||
match crate::auth::device_headers() {
|
||||
Ok(device_headers) => {
|
||||
for (name, value) in device_headers {
|
||||
headers.entry(name.to_string()).or_insert(value);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"device identity headers unavailable; sending inference request without them"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = (alpha_test_key, base_url);
|
||||
}
|
||||
@@ -4383,7 +4375,6 @@ pub fn resolve_model_to_sampling_config(
|
||||
models: &IndexMap<String, ModelEntry>,
|
||||
session_key: Option<&str>,
|
||||
alpha_test_key: Option<String>,
|
||||
client_version: Option<String>,
|
||||
fallback_entry: Option<ModelEntry>,
|
||||
) -> Option<SamplerConfig> {
|
||||
let entry = find_model_by_id(models, model_id)
|
||||
@@ -4394,16 +4385,12 @@ pub fn resolve_model_to_sampling_config(
|
||||
&entry,
|
||||
credentials,
|
||||
alpha_test_key,
|
||||
client_version,
|
||||
None,
|
||||
None,
|
||||
))
|
||||
}
|
||||
fn resolve_hidden_default_web_search_sampling_config(
|
||||
model_id: &str,
|
||||
session_key: Option<&str>,
|
||||
alpha_test_key: Option<String>,
|
||||
client_version: Option<String>,
|
||||
endpoints: &EndpointsConfig,
|
||||
) -> SamplerConfig {
|
||||
let entry = ModelEntry {
|
||||
@@ -4445,21 +4432,13 @@ fn resolve_hidden_default_web_search_sampling_config(
|
||||
api_base_url: None,
|
||||
};
|
||||
let credentials = resolve_credentials(&entry, session_key);
|
||||
sampling_config_for_model(
|
||||
&entry,
|
||||
credentials,
|
||||
alpha_test_key,
|
||||
client_version,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
sampling_config_for_model(&entry, credentials, alpha_test_key)
|
||||
}
|
||||
pub fn resolve_web_search_sampling_config(
|
||||
model_id: &str,
|
||||
models: &IndexMap<String, ModelEntry>,
|
||||
session_key: Option<&str>,
|
||||
alpha_test_key: Option<String>,
|
||||
client_version: Option<String>,
|
||||
endpoints: &EndpointsConfig,
|
||||
) -> Option<SamplerConfig> {
|
||||
let resolved = if let Some(entry) = find_model_by_id(models, model_id).cloned() {
|
||||
@@ -4468,16 +4447,12 @@ pub fn resolve_web_search_sampling_config(
|
||||
&entry,
|
||||
credentials,
|
||||
alpha_test_key,
|
||||
client_version,
|
||||
None,
|
||||
None,
|
||||
))
|
||||
} else if model_id == crate::models::default_web_search_model() {
|
||||
Some(resolve_hidden_default_web_search_sampling_config(
|
||||
model_id,
|
||||
session_key,
|
||||
alpha_test_key,
|
||||
client_version,
|
||||
endpoints,
|
||||
))
|
||||
} else {
|
||||
@@ -4757,21 +4732,23 @@ reasoning_effort = "low"
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn inject_url_derived_headers_adds_client_mode_for_first_party_url() {
|
||||
fn inject_url_derived_headers_adds_device_identity_for_first_party_url() {
|
||||
let mut headers = IndexMap::new();
|
||||
inject_url_derived_headers(
|
||||
&mut headers,
|
||||
None,
|
||||
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||||
);
|
||||
assert!(headers.get(crate::http::CLIENT_MODE_HEADER).is_some());
|
||||
assert!(headers.get("X-Msh-Device-Id").is_some());
|
||||
assert!(headers.get("X-Msh-Device-Name").is_some());
|
||||
assert!(headers.get("X-XAI-Token-Auth").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn inject_url_derived_headers_skips_headers_for_external_url() {
|
||||
let mut headers = IndexMap::new();
|
||||
inject_url_derived_headers(&mut headers, None, "https://api.example.com/v1");
|
||||
assert!(headers.get(crate::http::CLIENT_MODE_HEADER).is_none());
|
||||
assert!(headers.get("X-Msh-Device-Id").is_none());
|
||||
assert!(headers.get("X-Msh-Device-Name").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn inject_url_derived_headers_preserves_caller_extra_headers() {
|
||||
@@ -4924,7 +4901,6 @@ reasoning_effort = "low"
|
||||
&IndexMap::new(),
|
||||
Some("session-token"),
|
||||
None,
|
||||
None,
|
||||
&endpoints,
|
||||
)
|
||||
.expect("hidden default web search model should resolve");
|
||||
@@ -4943,7 +4919,7 @@ reasoning_effort = "low"
|
||||
model: "composer-session-model".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let (model, cfg) = finalize_image_describe_sampler_config(None, &active, None, Some(3));
|
||||
let (model, cfg) = finalize_image_describe_sampler_config(None, &active, Some(3));
|
||||
assert_eq!(model, "composer-session-model");
|
||||
assert_eq!(cfg.model, "composer-session-model");
|
||||
assert_ne!(cfg.model, "grok-build");
|
||||
@@ -4958,11 +4934,9 @@ reasoning_effort = "low"
|
||||
model: "grok-build".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let (model, cfg) =
|
||||
finalize_image_describe_sampler_config(Some(aux), &active, Some("cli".into()), Some(7));
|
||||
let (model, cfg) = finalize_image_describe_sampler_config(Some(aux), &active, Some(7));
|
||||
assert_eq!(model, "grok-build");
|
||||
assert_eq!(cfg.model, "grok-build");
|
||||
assert_eq!(cfg.client_identifier.as_deref(), Some("cli"));
|
||||
assert_eq!(cfg.max_retries, Some(7));
|
||||
}
|
||||
#[test]
|
||||
@@ -4980,7 +4954,7 @@ reasoning_effort = "low"
|
||||
),
|
||||
);
|
||||
let resolved =
|
||||
resolve_aux_model_sampling_config("grok-build", &catalog, &endpoints, None, None, None)
|
||||
resolve_aux_model_sampling_config("grok-build", &catalog, &endpoints, None, None)
|
||||
.expect("override entry has an API key, so resolution succeeds");
|
||||
assert_eq!(resolved.model, "v9m-rl-learnability-tp8");
|
||||
assert_eq!(resolved.base_url, "https://vendor.example/v1");
|
||||
@@ -5085,14 +5059,8 @@ reasoning_effort = "low"
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let sampling_config = sampling_config_for_model(
|
||||
&model,
|
||||
resolve_credentials(&model, None),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let sampling_config =
|
||||
sampling_config_for_model(&model, resolve_credentials(&model, None), None);
|
||||
assert_eq!(
|
||||
sampling_config.api_key,
|
||||
Some("model-specific-key".to_string())
|
||||
@@ -5111,9 +5079,6 @@ reasoning_effort = "low"
|
||||
auth_scheme: AuthScheme::Bearer,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(sampling_config.api_key, Some("fallback-key".to_string()));
|
||||
}
|
||||
@@ -5388,14 +5353,8 @@ reasoning_effort = "low"
|
||||
None,
|
||||
);
|
||||
model.info.api_backend = ApiBackend::Messages;
|
||||
let config = sampling_config_for_model(
|
||||
&model,
|
||||
resolve_credentials(&model, Some("tok")),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let config =
|
||||
sampling_config_for_model(&model, resolve_credentials(&model, Some("tok")), None);
|
||||
assert_eq!(config.api_backend, ApiBackend::Messages);
|
||||
assert_eq!(config.auth_scheme, AuthScheme::Bearer);
|
||||
assert_eq!(config.api_key, Some("tok".to_string()));
|
||||
@@ -5440,7 +5399,7 @@ reasoning_effort = "low"
|
||||
assert_eq!(creds.auth_scheme, AuthScheme::XApiKey);
|
||||
assert_eq!(creds.auth_type, kigi_chat_state::AuthType::ApiKey);
|
||||
assert_eq!(creds.api_key, Some("sk-ant-test-key".to_string()));
|
||||
let config = sampling_config_for_model(&model, creds, None, None, None, None);
|
||||
let config = sampling_config_for_model(&model, creds, None);
|
||||
assert_eq!(config.auth_scheme, AuthScheme::XApiKey);
|
||||
assert_eq!(config.api_backend, ApiBackend::Messages);
|
||||
let client = kigi_sampler::SamplingClient::new(config).expect("client should build");
|
||||
@@ -5459,7 +5418,7 @@ reasoning_effort = "low"
|
||||
assert_eq!(model.info.auth_scheme, AuthScheme::Bearer);
|
||||
let creds = resolve_credentials(&model, None);
|
||||
assert_eq!(creds.auth_scheme, AuthScheme::Bearer);
|
||||
let config = sampling_config_for_model(&model, creds, None, None, None, None);
|
||||
let config = sampling_config_for_model(&model, creds, None);
|
||||
assert_eq!(config.auth_scheme, AuthScheme::Bearer);
|
||||
let client = kigi_sampler::SamplingClient::new(config).expect("client should build");
|
||||
let info = client.auth_info();
|
||||
@@ -5771,25 +5730,11 @@ reasoning_effort = "low"
|
||||
#[test]
|
||||
fn sampling_config_context_window_from_entry_or_default() {
|
||||
let model = test_model_entry("any-model", "https://api.x.ai/v1", None, None, None);
|
||||
let config = sampling_config_for_model(
|
||||
&model,
|
||||
resolve_credentials(&model, None),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let config = sampling_config_for_model(&model, resolve_credentials(&model, None), None);
|
||||
assert_eq!(config.context_window, 200_000);
|
||||
let mut model = test_model_entry("any-model", "https://api.x.ai/v1", None, None, None);
|
||||
model.info.context_window = NonZeroU64::new(256_000).unwrap();
|
||||
let config = sampling_config_for_model(
|
||||
&model,
|
||||
resolve_credentials(&model, None),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let config = sampling_config_for_model(&model, resolve_credentials(&model, None), None);
|
||||
assert_eq!(config.context_window, 256_000);
|
||||
}
|
||||
#[test]
|
||||
@@ -5917,14 +5862,8 @@ reasoning_effort = "low"
|
||||
let mut model =
|
||||
test_model_entry("test-model", "https://api.example.com/v1", None, None, None);
|
||||
model.info.api_backend = ApiBackend::Responses;
|
||||
let sampling_config = sampling_config_for_model(
|
||||
&model,
|
||||
resolve_credentials(&model, None),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let sampling_config =
|
||||
sampling_config_for_model(&model, resolve_credentials(&model, None), None);
|
||||
assert_eq!(sampling_config.api_backend, ApiBackend::Responses);
|
||||
}
|
||||
#[test]
|
||||
@@ -6636,7 +6575,7 @@ reasoning_effort = "low"
|
||||
}
|
||||
fn resolve_sampling(model: &ModelEntry, session_key: Option<&str>) -> SamplerConfig {
|
||||
let credentials = resolve_credentials(model, session_key);
|
||||
sampling_config_for_model(model, credentials, None, None, None, None)
|
||||
sampling_config_for_model(model, credentials, None)
|
||||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
@@ -7022,7 +6961,7 @@ reasoning_effort = "low"
|
||||
&format!(
|
||||
r#"
|
||||
[endpoints]
|
||||
cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1"
|
||||
coding_api_base_url = "https://enterprise-proxy.acme.com/v1"
|
||||
|
||||
[model."{BUNDLED_DEFAULT_KEY}"]
|
||||
api_key = "acme-api-key"
|
||||
@@ -7052,14 +6991,14 @@ reasoning_effort = "low"
|
||||
let (_, models) = resolve_models_from_toml(
|
||||
r#"
|
||||
[endpoints]
|
||||
cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1"
|
||||
coding_api_base_url = "https://enterprise-proxy.acme.com/v1"
|
||||
"#,
|
||||
None,
|
||||
);
|
||||
let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist");
|
||||
assert_eq!(
|
||||
model.info.base_url, "https://enterprise-proxy.acme.com/v1",
|
||||
"default model should use enterprise cli_chat_proxy_base_url"
|
||||
"default model should use enterprise coding_api_base_url"
|
||||
);
|
||||
// The open-platform fallback entries keep their fixed moonshot bases;
|
||||
// only the subscription entry follows the proxy override.
|
||||
@@ -7073,7 +7012,7 @@ reasoning_effort = "low"
|
||||
/// the ambient environment. Gated behind `#[serial]`.
|
||||
fn unset_endpoint_env_vars() {
|
||||
for k in [
|
||||
"KIGI_CLI_CHAT_PROXY_BASE_URL",
|
||||
"KIGI_CODE_BASE_URL",
|
||||
kigi_env::CODE_BASE_URL_ENV,
|
||||
"KIGI_XAI_API_BASE_URL",
|
||||
"KIGI_FEEDBACK_BASE_URL",
|
||||
@@ -7101,7 +7040,7 @@ reasoning_effort = "low"
|
||||
let inference = "https://inference.acme-corp.example/xai/v1";
|
||||
let cfg = EndpointsConfig {
|
||||
xai_api_base_url: inference.to_string(),
|
||||
cli_chat_proxy_base_url: None,
|
||||
coding_api_base_url: None,
|
||||
..Default::default()
|
||||
};
|
||||
let proxy = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url;
|
||||
@@ -7119,7 +7058,7 @@ reasoning_effort = "low"
|
||||
);
|
||||
assert_eq!(cfg.xai_api_base_url, inference);
|
||||
let overridden = EndpointsConfig {
|
||||
cli_chat_proxy_base_url: Some("https://proxy.enterprise.example/v1".to_string()),
|
||||
coding_api_base_url: Some("https://proxy.enterprise.example/v1".to_string()),
|
||||
managed_config_url: Some(
|
||||
"https://control.enterprise.example/deployment/config".to_string(),
|
||||
),
|
||||
@@ -7159,7 +7098,7 @@ reasoning_effort = "low"
|
||||
.unwrap(),
|
||||
)
|
||||
.expect("config should parse");
|
||||
assert!(cfg.endpoints.cli_chat_proxy_base_url.is_none());
|
||||
assert!(cfg.endpoints.coding_api_base_url.is_none());
|
||||
assert_eq!(
|
||||
cfg.endpoints.resolve_managed_config_url(),
|
||||
format!(
|
||||
@@ -7181,7 +7120,7 @@ reasoning_effort = "low"
|
||||
&format!(
|
||||
r#"
|
||||
[endpoints]
|
||||
cli_chat_proxy_base_url = "https://enterprise-proxy.acme.com/v1"
|
||||
coding_api_base_url = "https://enterprise-proxy.acme.com/v1"
|
||||
|
||||
[model."{dm}"]
|
||||
base_url = "https://my-special-proxy.example.com/v1"
|
||||
@@ -8656,7 +8595,7 @@ agent_type = "cursor"
|
||||
fn otlp_traces_endpoint_precedence() {
|
||||
let proxy = "https://inference.acme.com/v1".to_string();
|
||||
let derived = EndpointsConfig {
|
||||
cli_chat_proxy_base_url: Some(proxy.clone()),
|
||||
coding_api_base_url: Some(proxy.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -8664,7 +8603,7 @@ agent_type = "cursor"
|
||||
"https://inference.acme.com/v1/traces"
|
||||
);
|
||||
let base = EndpointsConfig {
|
||||
cli_chat_proxy_base_url: Some(proxy.clone()),
|
||||
coding_api_base_url: Some(proxy.clone()),
|
||||
otel_exporter_otlp_endpoint: Some("https://otel.acme.com".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -8673,7 +8612,7 @@ agent_type = "cursor"
|
||||
"https://otel.acme.com/v1/traces"
|
||||
);
|
||||
let full = EndpointsConfig {
|
||||
cli_chat_proxy_base_url: Some(proxy),
|
||||
coding_api_base_url: Some(proxy),
|
||||
otel_exporter_otlp_endpoint: Some("https://ignored.example".to_string()),
|
||||
otel_exporter_otlp_traces_endpoint: Some("https://otel.acme.com/v1/traces".to_string()),
|
||||
..Default::default()
|
||||
@@ -8702,7 +8641,7 @@ agent_type = "cursor"
|
||||
/// explicitly unset so ambient env (via `Default`) can't leak in.
|
||||
fn internal_otlp_test_config() -> EndpointsConfig {
|
||||
EndpointsConfig {
|
||||
cli_chat_proxy_base_url: Some("https://proxy.example/v1".to_string()),
|
||||
coding_api_base_url: Some("https://proxy.example/v1".to_string()),
|
||||
otel_exporter_otlp_endpoint: None,
|
||||
otel_exporter_otlp_traces_endpoint: None,
|
||||
otel_exporter_otlp_headers: None,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,2 @@
|
||||
pub(crate) mod model_switch;
|
||||
pub(crate) mod session;
|
||||
pub(crate) mod workspaces;
|
||||
|
||||
@@ -271,19 +271,10 @@ async fn handle_session_list(
|
||||
// (never union) so every list surface is conversations-only.
|
||||
let req = unified_list::parse_list_req(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
tracing::debug!(
|
||||
chat_mode_forced_kind = crate::agent::chat_modes::process_chat_mode_enabled(),
|
||||
"session/list"
|
||||
);
|
||||
tracing::debug!("session/list");
|
||||
|
||||
let registry_client = agent.session_registry_client();
|
||||
let conversations_client = agent.conversations_client();
|
||||
let result = unified_list::build_unified_list(
|
||||
registry_client.as_ref(),
|
||||
conversations_client.as_ref(),
|
||||
req,
|
||||
)
|
||||
.await;
|
||||
let result = unified_list::build_unified_list(registry_client.as_ref(), req).await;
|
||||
|
||||
ExtMethodResult::success(unified_list::ext_list_response(result))
|
||||
.to_ext_response()
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
use agent_client_protocol::{self as acp};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::mvp_agent::MvpAgent;
|
||||
use crate::remote::{ListWorkspacesPage, WsError, WsQuery};
|
||||
use crate::session::ExtMethodResult;
|
||||
|
||||
const DEFAULT_PAGE_SIZE: i64 = 50;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspacesListRequest {
|
||||
#[serde(default)]
|
||||
page_size: Option<i64>,
|
||||
#[serde(default)]
|
||||
page_token: Option<String>,
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspaceRow {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
create_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspacesListResponse {
|
||||
workspaces: Vec<WorkspaceRow>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next_page_token: Option<String>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
meta: Option<WorkspacesMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WorkspacesMeta {
|
||||
#[serde(rename = "x.ai/partial")]
|
||||
partial: PartialInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PartialInfo {
|
||||
workspaces: bool,
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
let req: WorkspacesListRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
|
||||
let q = WsQuery {
|
||||
// Clamp to a sane positive page size: a missing, zero, or negative
|
||||
// `pageSize` falls back to the default rather than being forwarded
|
||||
// verbatim to `/rest/workspaces`.
|
||||
page_size: match req.page_size {
|
||||
Some(n) if n > 0 => n,
|
||||
_ => DEFAULT_PAGE_SIZE,
|
||||
},
|
||||
page_token: req.page_token,
|
||||
query: req.query,
|
||||
kind: req.kind,
|
||||
};
|
||||
|
||||
let response = match agent.workspaces_client().list_workspaces(&q).await {
|
||||
Ok(page) => success_response(page),
|
||||
Err(WsError::NoOauth) => degraded_response("no_oauth"),
|
||||
Err(e) => {
|
||||
// Degrade to a partial result, but don't silently swallow the
|
||||
// cause — log it so field failures are diagnosable.
|
||||
tracing::warn!("workspaces/list fetch failed: {e}");
|
||||
degraded_response("error")
|
||||
}
|
||||
};
|
||||
|
||||
ExtMethodResult::success(response)
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
fn success_response(page: ListWorkspacesPage) -> WorkspacesListResponse {
|
||||
WorkspacesListResponse {
|
||||
workspaces: page
|
||||
.workspaces
|
||||
.into_iter()
|
||||
.map(|w| WorkspaceRow {
|
||||
id: w.workspace_id,
|
||||
name: w.name,
|
||||
kind: w.kind,
|
||||
create_time: w.create_time,
|
||||
})
|
||||
.collect(),
|
||||
next_page_token: page.next_page_token,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn degraded_response(reason: &'static str) -> WorkspacesListResponse {
|
||||
WorkspacesListResponse {
|
||||
workspaces: Vec::new(),
|
||||
next_page_token: None,
|
||||
meta: Some(WorkspacesMeta {
|
||||
partial: PartialInfo {
|
||||
workspaces: true,
|
||||
reason,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::remote::Workspace;
|
||||
|
||||
#[test]
|
||||
fn request_parses_camelcase_and_defaults_page_size() {
|
||||
let req: WorkspacesListRequest =
|
||||
serde_json::from_value(serde_json::json!({})).expect("empty params parse");
|
||||
assert!(req.page_size.is_none());
|
||||
|
||||
let req: WorkspacesListRequest = serde_json::from_value(serde_json::json!({
|
||||
"pageSize": 10,
|
||||
"pageToken": "tok",
|
||||
"query": "gpu",
|
||||
"kind": "WORKSPACE_KIND_IMAGINE"
|
||||
}))
|
||||
.expect("full params parse");
|
||||
assert_eq!(req.page_size, Some(10));
|
||||
assert_eq!(req.page_token.as_deref(), Some("tok"));
|
||||
assert_eq!(req.query.as_deref(), Some("gpu"));
|
||||
assert_eq!(req.kind.as_deref(), Some("WORKSPACE_KIND_IMAGINE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_response_projects_grok_workspace_fields() {
|
||||
let page = ListWorkspacesPage {
|
||||
workspaces: vec![Workspace {
|
||||
workspace_id: "ws_1".into(),
|
||||
name: "Research".into(),
|
||||
create_time: Some("2026-06-18T17:30:00Z".into()),
|
||||
kind: Some("WORKSPACE_KIND_IMAGINE".into()),
|
||||
}],
|
||||
next_page_token: Some("tok2".into()),
|
||||
};
|
||||
let value = serde_json::to_value(success_response(page)).unwrap();
|
||||
assert_eq!(value["workspaces"][0]["id"], "ws_1");
|
||||
assert_eq!(value["workspaces"][0]["name"], "Research");
|
||||
assert_eq!(value["workspaces"][0]["kind"], "WORKSPACE_KIND_IMAGINE");
|
||||
assert_eq!(value["workspaces"][0]["createTime"], "2026-06-18T17:30:00Z");
|
||||
assert_eq!(value["nextPageToken"], "tok2");
|
||||
assert!(value.get("_meta").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_response_carries_partial_reason() {
|
||||
let value = serde_json::to_value(degraded_response("no_oauth")).unwrap();
|
||||
assert_eq!(value["workspaces"].as_array().unwrap().len(), 0);
|
||||
assert!(value.get("nextPageToken").is_none());
|
||||
assert_eq!(value["_meta"]["x.ai/partial"]["workspaces"], true);
|
||||
assert_eq!(value["_meta"]["x.ai/partial"]["reason"], "no_oauth");
|
||||
}
|
||||
}
|
||||
@@ -73,27 +73,6 @@ fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig
|
||||
tracing::info!(field = %e.path, value = %e.value, source = %e.source, "policy override");
|
||||
}
|
||||
|
||||
// Fallback: if the client didn't pre-supply remote settings, fetch them
|
||||
// now so remote-settings-gated features work regardless of which client
|
||||
// spawned us. Clients that already call `start_early_prefetch()` and
|
||||
// thread the result into `cfg.remote_settings` skip this entirely.
|
||||
if cfg.remote_settings.is_none()
|
||||
&& let Some(handle) =
|
||||
crate::agent::models::start_early_prefetch(Some(cfg.kimi_code_config.clone()))
|
||||
{
|
||||
match handle.join() {
|
||||
Ok(result) => {
|
||||
cfg.remote_settings = result.settings;
|
||||
crate::util::config::set_remote_campaigns_from_settings(
|
||||
cfg.remote_settings.as_ref(),
|
||||
);
|
||||
tracing::info!("remote_settings fetched as shell-level fallback");
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("remote_settings fallback prefetch thread panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::util::config::sync_campaign_fields(&mut cfg);
|
||||
crate::agent::config::apply_remote_settings_side_effects(cfg.remote_settings.as_ref());
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ pub mod chat_modes;
|
||||
pub mod config;
|
||||
pub mod config_model_override_parse;
|
||||
mod ext_parsers;
|
||||
pub mod feedback_client;
|
||||
pub(crate) mod feedback_client;
|
||||
pub mod folder_trust;
|
||||
pub(crate) mod handlers;
|
||||
pub mod init;
|
||||
pub mod models;
|
||||
pub(crate) mod models_fetch;
|
||||
pub mod mvp_agent;
|
||||
pub(crate) mod proxy;
|
||||
pub(crate) mod restore_code;
|
||||
|
||||
@@ -10,8 +10,8 @@ use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use crate::agent::config::{self, ModelEntry, resolve_credentials, sampling_config_for_model};
|
||||
use crate::agent::models_fetch::{FetchModelsResult, fetch_models_blocking};
|
||||
use crate::auth::{AuthManager, KimiAuth, KimiCodeConfig};
|
||||
use crate::remote::{FetchModelsResult, fetch_models_blocking};
|
||||
use crate::sampling::SamplerConfig as SamplingConfig;
|
||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
use kigi_sampling_types::{ReasoningEffort, ReasoningEffortOption};
|
||||
@@ -290,7 +290,7 @@ impl ModelsManager {
|
||||
cache
|
||||
.load_fresh(
|
||||
&fetch_auth.cache_auth_method(),
|
||||
&crate::remote::models_fetch_origin(
|
||||
&crate::agent::models_fetch::models_fetch_origin(
|
||||
&cfg.endpoints,
|
||||
fetch_auth,
|
||||
has_session,
|
||||
@@ -1035,11 +1035,6 @@ impl ModelsManager {
|
||||
current_model,
|
||||
credentials,
|
||||
config.endpoints.alpha_test_key.clone(),
|
||||
config.client_version.clone(),
|
||||
crate::managed_config::resolve_deployment_id(
|
||||
config.endpoints.deployment_key.as_deref(),
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1053,7 +1048,12 @@ impl ModelsManager {
|
||||
let fetch_auth = *self.inner.fetch_auth.read();
|
||||
let has_oauth = self.inner.auth_manager.current_or_expired().is_some();
|
||||
let platform_keys = PlatformApiKeys::resolve(&platforms);
|
||||
crate::remote::models_fetch_origin(&endpoints, fetch_auth, has_oauth, &platform_keys)
|
||||
crate::agent::models_fetch::models_fetch_origin(
|
||||
&endpoints,
|
||||
fetch_auth,
|
||||
has_oauth,
|
||||
&platform_keys,
|
||||
)
|
||||
}
|
||||
|
||||
fn try_load_cache(&self) -> bool {
|
||||
@@ -1327,7 +1327,7 @@ struct ModelsCache {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
auth_method: Option<CacheAuthMethod>,
|
||||
/// Models-list URL this catalog was fetched from
|
||||
/// ([`crate::remote::models_list_url`]). Compared on load so a cache
|
||||
/// ([`crate::agent::models_fetch::models_fetch_origin`]). Compared on load so a cache
|
||||
/// written against one backend is a miss for another: entries embed
|
||||
/// absolute `base_url`s, so adopting a foreign-origin cache silently
|
||||
/// re-points inference (the windows lifecycle e2e failed exactly this
|
||||
@@ -1575,43 +1575,6 @@ pub(crate) fn prefetch_models_blocking(
|
||||
.models
|
||||
}
|
||||
|
||||
/// Blocking models + `/v1/settings` prefetch pair, shared by the early
|
||||
/// prefetch thread and the leader's startup phase so the settings gate lives
|
||||
/// once. The remote_fetch knob is resolved a single time so the two fetch
|
||||
/// decisions cannot disagree mid-startup.
|
||||
pub(crate) fn prefetch_models_and_settings_blocking(
|
||||
endpoints: &config::EndpointsConfig,
|
||||
auth: Option<&KimiAuth>,
|
||||
fetch_auth: ModelFetchAuth,
|
||||
platform_keys: &PlatformApiKeys,
|
||||
) -> (
|
||||
Option<IndexMap<String, ModelEntry>>,
|
||||
Option<crate::util::config::RemoteSettings>,
|
||||
) {
|
||||
let remote_fetch_enabled = crate::util::config::resolve_remote_fetch_enabled();
|
||||
let models = prefetch_models_blocking_gated(
|
||||
endpoints,
|
||||
auth,
|
||||
fetch_auth,
|
||||
platform_keys,
|
||||
remote_fetch_enabled,
|
||||
)
|
||||
.models;
|
||||
// Settings need a subscription session; skip for API-key-only setups.
|
||||
let settings = match auth {
|
||||
Some(auth) if remote_fetch_enabled => {
|
||||
let _timer = crate::instrumentation_timer!("startup.early_settings_fetch");
|
||||
crate::remote::fetch_settings_blocking(
|
||||
&endpoints.proxy_url(),
|
||||
auth,
|
||||
endpoints.alpha_test_key.as_deref(),
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
(models, settings)
|
||||
}
|
||||
|
||||
/// `remote_fetch_enabled` is a parameter so the pair helper above resolves the
|
||||
/// knob once for both halves.
|
||||
fn prefetch_models_blocking_gated(
|
||||
@@ -1624,8 +1587,12 @@ fn prefetch_models_blocking_gated(
|
||||
let cache_auth = fetch_auth.cache_auth_method();
|
||||
// Same fetch plan the network path below executes — the cache is only
|
||||
// valid for it.
|
||||
let cache_origin =
|
||||
crate::remote::models_fetch_origin(endpoints, fetch_auth, auth.is_some(), platform_keys);
|
||||
let cache_origin = crate::agent::models_fetch::models_fetch_origin(
|
||||
endpoints,
|
||||
fetch_auth,
|
||||
auth.is_some(),
|
||||
platform_keys,
|
||||
);
|
||||
let cache = ModelsCacheManager::new();
|
||||
if let Some(cached) = cache.load_fresh(&cache_auth, &cache_origin) {
|
||||
tracing::info!(
|
||||
@@ -1703,10 +1670,9 @@ fn stale_cache_or_failure(
|
||||
ModelsFetchOutcome::failed(oauth_unauthorized)
|
||||
}
|
||||
|
||||
/// Startup prefetch result: models + remote settings.
|
||||
/// Startup prefetch result: the model catalog, when a fetch plan existed.
|
||||
pub struct EarlyPrefetchResult {
|
||||
pub models: Option<IndexMap<String, ModelEntry>>,
|
||||
pub settings: Option<crate::util::config::RemoteSettings>,
|
||||
}
|
||||
|
||||
/// Handle for a startup prefetch thread.
|
||||
@@ -1743,7 +1709,7 @@ fn resolve_prefetch_env_with_auth(auth: Option<KimiAuth>) -> Option<PrefetchEnv>
|
||||
/// `has_custom_endpoint()` (which otherwise forces the prefetch to run): the
|
||||
/// explicit off switch must hold even when a stray login, a platform API key,
|
||||
/// or a `deployment_key` would re-arm the prefetch — and with it the
|
||||
/// `/v1/settings` fetch and the deployment-config sync on the prefetch thread.
|
||||
/// deployment-config sync on the prefetch thread.
|
||||
///
|
||||
/// PRD F2 acceptance: a moonshot API key alone (no subscription login) must
|
||||
/// arm the prefetch so the catalog syncs on startup.
|
||||
@@ -1754,7 +1720,7 @@ fn resolve_prefetch_env_from_parts(
|
||||
remote_fetch_enabled: bool,
|
||||
) -> Option<PrefetchEnv> {
|
||||
if !remote_fetch_enabled {
|
||||
tracing::info!("startup model/settings prefetch skipped: remote_fetch disabled");
|
||||
tracing::info!("startup model prefetch skipped: remote_fetch disabled");
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1779,7 +1745,7 @@ fn resolve_prefetch_env(kimi_code_config: Option<KimiCodeConfig>) -> Option<Pref
|
||||
resolve_prefetch_env_with_auth(auth)
|
||||
}
|
||||
|
||||
/// Start model + settings prefetch on a background thread using pre-resolved auth.
|
||||
/// Start the model-catalog prefetch on a background thread using pre-resolved auth.
|
||||
///
|
||||
/// When the caller has already obtained valid credentials (e.g. via
|
||||
/// `try_ensure_fresh_auth`), pass them here to avoid re-reading stale cached
|
||||
@@ -1789,7 +1755,7 @@ pub fn start_early_prefetch_with_auth(auth: Option<KimiAuth>) -> Option<EarlyPre
|
||||
Some(spawn_prefetch_thread(env))
|
||||
}
|
||||
|
||||
/// Start model + settings prefetch on a background thread.
|
||||
/// Start the model-catalog prefetch on a background thread.
|
||||
///
|
||||
/// Convenience wrapper that reads cached auth from disk. Prefer
|
||||
/// `start_early_prefetch_with_auth` when you have pre-resolved credentials.
|
||||
@@ -1805,7 +1771,7 @@ fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
|
||||
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
|
||||
let proxy_endpoint = env.endpoints.proxy_url();
|
||||
timer.with_field("endpoint", proxy_endpoint.as_str());
|
||||
let (models, settings) = prefetch_models_and_settings_blocking(
|
||||
let models = prefetch_models_blocking(
|
||||
&env.endpoints,
|
||||
env.auth.as_ref(),
|
||||
env.model_fetch_auth,
|
||||
@@ -1824,7 +1790,7 @@ fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
|
||||
let _ = rt.block_on(crate::managed_config::sync());
|
||||
}
|
||||
|
||||
EarlyPrefetchResult { models, settings }
|
||||
EarlyPrefetchResult { models }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3868,7 +3834,7 @@ mod tests {
|
||||
|
||||
fn proxied_endpoints(server_uri: &str) -> config::EndpointsConfig {
|
||||
config::EndpointsConfig {
|
||||
cli_chat_proxy_base_url: Some(server_uri.to_string()),
|
||||
coding_api_base_url: Some(server_uri.to_string()),
|
||||
models_base_url: None,
|
||||
models_list_url: None,
|
||||
..config::EndpointsConfig::default()
|
||||
@@ -3900,7 +3866,7 @@ mod tests {
|
||||
..KimiAuth::test_default()
|
||||
};
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
crate::remote::fetch_models_blocking(
|
||||
crate::agent::models_fetch::fetch_models_blocking(
|
||||
&endpoints,
|
||||
Some(&auth),
|
||||
ModelFetchAuth::Platforms,
|
||||
@@ -3969,7 +3935,12 @@ mod tests {
|
||||
let endpoints = config::EndpointsConfig::default();
|
||||
let keys = PlatformApiKeys::test_keys(Some("sk-cn-secret"), None);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
crate::remote::fetch_models_blocking(&endpoints, None, ModelFetchAuth::Platforms, &keys)
|
||||
crate::agent::models_fetch::fetch_models_blocking(
|
||||
&endpoints,
|
||||
None,
|
||||
ModelFetchAuth::Platforms,
|
||||
&keys,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -4062,7 +4033,7 @@ mod tests {
|
||||
auth_manager.set_refresher(Arc::new(SwapRefresher));
|
||||
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.endpoints.cli_chat_proxy_base_url = Some(server.uri());
|
||||
cfg.endpoints.coding_api_base_url = Some(server.uri());
|
||||
let mgr = ModelsManager::new(
|
||||
None,
|
||||
IndexMap::new(),
|
||||
@@ -4120,8 +4091,12 @@ mod tests {
|
||||
assert!(bundled.contains_key("moonshot-ai/kimi-k2-turbo-preview"));
|
||||
|
||||
// 2. A STALE cache for the same fetch plan is served on sync failure.
|
||||
let origin =
|
||||
crate::remote::models_fetch_origin(&endpoints, ModelFetchAuth::Platforms, true, &keys);
|
||||
let origin = crate::agent::models_fetch::models_fetch_origin(
|
||||
&endpoints,
|
||||
ModelFetchAuth::Platforms,
|
||||
true,
|
||||
&keys,
|
||||
);
|
||||
let cache = ModelsCacheManager::new();
|
||||
let stale = ModelsCache {
|
||||
fetched_at: Utc::now() - ChronoDuration::seconds(86_400),
|
||||
@@ -4203,7 +4178,7 @@ mod tests {
|
||||
let _cache = EnvGuard::set("KIGI_MODELS_CACHE_DIR", cache_dir.path().to_str().unwrap());
|
||||
let endpoints = proxied_endpoints("http://127.0.0.1:9");
|
||||
// Cache written when a moonshot key was ALSO configured...
|
||||
let with_key_origin = crate::remote::models_fetch_origin(
|
||||
let with_key_origin = crate::agent::models_fetch::models_fetch_origin(
|
||||
&endpoints,
|
||||
ModelFetchAuth::Platforms,
|
||||
true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,6 @@ impl acp::Agent for MvpAgent {
|
||||
);
|
||||
});
|
||||
kigi_workspace::trust::migrate_legacy_hook_trust();
|
||||
self.maybe_sync_bundle_in_background(false);
|
||||
let mut client_type = arguments
|
||||
.meta
|
||||
.as_ref()
|
||||
@@ -278,11 +277,7 @@ impl acp::Agent for MvpAgent {
|
||||
}
|
||||
self.spawn_initialize_launch_mcp_setup(fetch_managed_mcps);
|
||||
self.spawn_managed_gateway_tool_catalog_fetch();
|
||||
let init_model_state = if crate::agent::chat_modes::process_chat_mode_enabled() {
|
||||
self.chat_modes.model_state().await
|
||||
} else {
|
||||
self.model_state(None)
|
||||
};
|
||||
let init_model_state = self.model_state(None);
|
||||
Ok(
|
||||
acp::InitializeResponse::new(acp::ProtocolVersion::V1)
|
||||
.agent_capabilities(
|
||||
@@ -374,9 +369,6 @@ impl acp::Agent for MvpAgent {
|
||||
}
|
||||
}
|
||||
self.set_auth_method(arguments.method_id.clone());
|
||||
if crate::agent::chat_modes::process_chat_mode_enabled() {
|
||||
self.chat_modes.warm_in_background();
|
||||
}
|
||||
emit_login_span(true, "api_key", None, None);
|
||||
Ok(Default::default())
|
||||
}
|
||||
@@ -421,10 +413,8 @@ impl acp::Agent for MvpAgent {
|
||||
.authenticate_after_cached_token_unavailable(arguments)
|
||||
.await;
|
||||
};
|
||||
self.refresh_remote_settings(&auth).await;
|
||||
self.emit_settings_update_notification();
|
||||
self.maybe_sync_bundle_in_background(false);
|
||||
{
|
||||
{
|
||||
let mut sampling_config = self.sampling_config.borrow_mut();
|
||||
sampling_config.api_key = Some(auth.key);
|
||||
tracing::debug!(
|
||||
@@ -437,12 +427,8 @@ impl acp::Agent for MvpAgent {
|
||||
);
|
||||
}
|
||||
self.set_auth_method(arguments.method_id.clone());
|
||||
if crate::agent::chat_modes::process_chat_mode_enabled() {
|
||||
self.chat_modes.warm_in_background();
|
||||
}
|
||||
let uid = self.auth_manager.current().map(|a| a.user_id);
|
||||
emit_login_span(true, "cached_token", uid.as_deref(), None);
|
||||
self.maybe_fetch_post_auth_settings().await;
|
||||
Ok(self.auth_response_with_meta())
|
||||
}
|
||||
auth_method::KIGI_COM_METHOD_ID => {
|
||||
@@ -517,21 +503,15 @@ impl acp::Agent for MvpAgent {
|
||||
);
|
||||
}
|
||||
self.auth_manager.hot_swap(auth.clone());
|
||||
self.refresh_remote_settings(&auth).await;
|
||||
self.emit_settings_update_notification();
|
||||
self.maybe_sync_bundle_in_background(false);
|
||||
self.set_auth_method(arguments.method_id.clone());
|
||||
self.set_auth_method(arguments.method_id.clone());
|
||||
self.models_manager.on_auth_changed().await;
|
||||
if crate::agent::chat_modes::process_chat_mode_enabled() {
|
||||
self.chat_modes.warm_in_background();
|
||||
}
|
||||
emit_login_span(
|
||||
true,
|
||||
arguments.method_id.0.as_ref(),
|
||||
Some(auth.user_id.as_str()),
|
||||
None,
|
||||
);
|
||||
self.maybe_fetch_post_auth_settings().await;
|
||||
Ok(self.auth_response_with_meta())
|
||||
}
|
||||
_ => {
|
||||
@@ -559,9 +539,7 @@ impl acp::Agent for MvpAgent {
|
||||
.data("initialize must be called before new_session")
|
||||
})?;
|
||||
self.seed_client_config_auth_if_available();
|
||||
if let Ok(auth) = self.auth_manager.auth().await {
|
||||
self.refresh_settings_and_reapply(&auth).await;
|
||||
}
|
||||
self.refresh_settings_and_reapply().await;
|
||||
let cwd = AbsPathBuf::new(arguments.cwd.clone())
|
||||
.map_err(|e| acp::Error::invalid_params().data(e.to_string()))?;
|
||||
let remote_settings = self.cfg.borrow().remote_settings.clone();
|
||||
@@ -858,8 +836,10 @@ impl acp::Agent for MvpAgent {
|
||||
Some(serde_json::json!({ "cwd" : cwd.as_str() })),
|
||||
);
|
||||
let models = if is_chat_kind {
|
||||
// The grok.com chat-mode model picker was removed with the xAI
|
||||
// proxy; a chat-kind session has no managed catalog to offer.
|
||||
chat_new_session_model_state(
|
||||
self.chat_modes.model_state().await,
|
||||
acp::SessionModelState::new(acp::ModelId::from(String::new()), Vec::new()),
|
||||
session_initial_model
|
||||
.filter(|_| matches!(bridge_attach, BridgeAttach::Spawned)),
|
||||
)
|
||||
@@ -982,14 +962,6 @@ impl acp::Agent for MvpAgent {
|
||||
.build_summary_client(&load_session_sampling)?;
|
||||
let mut persistence_timer = crate::instrumentation_timer!("session.load_light");
|
||||
persistence_timer.with_field("session_id", session_id.0.as_ref());
|
||||
let backend = if self.build_registry_config().is_some() {
|
||||
Some(
|
||||
crate::remote::BackendClient::new()
|
||||
.with_auth_manager(self.auth_manager.clone()),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let registry_title_sync = self
|
||||
.session_registry_client()
|
||||
.map(|client| crate::session::persistence::RegistryGeneratedTitleSync {
|
||||
@@ -999,9 +971,6 @@ impl acp::Agent for MvpAgent {
|
||||
let (persistence_info, persistence) = crate::session::persistence::load_light(
|
||||
&session_info,
|
||||
summary_client,
|
||||
self.storage_mode,
|
||||
Some(self.auth_manager.clone()),
|
||||
backend.as_ref(),
|
||||
Some(self.gateway.clone()),
|
||||
summary_model,
|
||||
registry_title_sync,
|
||||
@@ -2095,9 +2064,6 @@ impl acp::Agent for MvpAgent {
|
||||
| "x.ai/sessions/list" => {
|
||||
crate::agent::handlers::session::handle(self, &args).await
|
||||
}
|
||||
"x.ai/workspaces/list" => {
|
||||
crate::agent::handlers::workspaces::handle(self, &args).await
|
||||
}
|
||||
"x.ai/session/updates" => {
|
||||
crate::extensions::session_updates::handle(&args, &self.gateway).await
|
||||
}
|
||||
@@ -2122,6 +2088,7 @@ impl acp::Agent for MvpAgent {
|
||||
crate::extensions::session_admin::handle(self, &args).await
|
||||
}
|
||||
"x.ai/session/repair" => crate::extensions::repair::handle(self, &args).await,
|
||||
"x.ai/billing" => crate::extensions::billing::handle(self, &args).await,
|
||||
"x.ai/memory/flush" | "x.ai/memory/rewrite" => {
|
||||
crate::extensions::memory::handle(self, &args).await
|
||||
}
|
||||
@@ -2136,206 +2103,6 @@ impl acp::Agent for MvpAgent {
|
||||
crate::extensions::feedback::handle(self, &args).await
|
||||
}
|
||||
"x.ai/recap" => crate::extensions::recap::handle(self, &args).await,
|
||||
"x.ai/cloud/terminate" => {
|
||||
crate::extensions::auth_gate::require_xai_auth(
|
||||
&self.auth_manager,
|
||||
"Authentication required",
|
||||
"Run `grok login` to authenticate.",
|
||||
)?;
|
||||
let params: serde_json::Value = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(e.to_string()))?;
|
||||
let sandbox_id = params
|
||||
.get("sandbox_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
acp::Error::invalid_params().data("missing sandbox_id")
|
||||
})?;
|
||||
let sandbox_client = crate::remote::SandboxClient::new(
|
||||
self.cli_chat_proxy_base_url(),
|
||||
self.auth_manager.clone(),
|
||||
);
|
||||
sandbox_client
|
||||
.terminate_session(
|
||||
sandbox_id,
|
||||
&crate::remote::SandboxTerminateRequest {
|
||||
environment_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
acp::Error::internal_error()
|
||||
.data(format!("Failed to terminate sandbox: {e}"))
|
||||
})?;
|
||||
crate::extensions::to_raw_response(&serde_json::json!({ "ok" : true }))
|
||||
}
|
||||
"x.ai/cloud/env/list" => {
|
||||
crate::extensions::auth_gate::require_xai_auth(
|
||||
&self.auth_manager,
|
||||
"Authentication required",
|
||||
"Run `grok login` to authenticate.",
|
||||
)?;
|
||||
let sandbox_client = crate::remote::SandboxClient::new(
|
||||
self.cli_chat_proxy_base_url(),
|
||||
self.auth_manager.clone(),
|
||||
);
|
||||
let resp = sandbox_client
|
||||
.list_environments(
|
||||
&crate::remote::SandboxListEnvironmentsRequest::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
acp::Error::internal_error()
|
||||
.data(format!("Failed to list environments: {e}"))
|
||||
})?;
|
||||
crate::extensions::to_raw_response(
|
||||
&serde_json::json!({ "environments" : resp.environments, }),
|
||||
)
|
||||
}
|
||||
"x.ai/cloud/env/create" => {
|
||||
crate::extensions::auth_gate::require_xai_auth(
|
||||
&self.auth_manager,
|
||||
"Authentication required",
|
||||
"Run `grok login` to authenticate.",
|
||||
)?;
|
||||
let params: serde_json::Value = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(e.to_string()))?;
|
||||
let sandbox_client = crate::remote::SandboxClient::new(
|
||||
self.cli_chat_proxy_base_url(),
|
||||
self.auth_manager.clone(),
|
||||
);
|
||||
let resp = sandbox_client
|
||||
.create_environment(
|
||||
&crate::remote::SandboxCreateEnvironmentRequest {
|
||||
name: params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
description: params
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
repository: params
|
||||
.get("repository")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
default_branch: params
|
||||
.get("default_branch")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
container_image: params
|
||||
.get("container_image")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
setup_script: params
|
||||
.get("setup_script")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
workspace_directory: Some("/workspace".to_string()),
|
||||
internet_enabled: Some(true),
|
||||
domain_allowlist_preset: Some("common".to_string()),
|
||||
allowed_http_methods: Some("all".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
acp::Error::internal_error()
|
||||
.data(format!("Failed to create environment: {e}"))
|
||||
})?;
|
||||
crate::extensions::to_raw_response(
|
||||
&serde_json::json!({ "environment" : resp.environment, }),
|
||||
)
|
||||
}
|
||||
"x.ai/cloud/env/update" => {
|
||||
crate::extensions::auth_gate::require_xai_auth(
|
||||
&self.auth_manager,
|
||||
"Authentication required",
|
||||
"Run `grok login` to authenticate.",
|
||||
)?;
|
||||
let params: serde_json::Value = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(e.to_string()))?;
|
||||
let environment_id = params
|
||||
.get("environment_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
acp::Error::invalid_params().data("missing environment_id")
|
||||
})?;
|
||||
let sandbox_client = crate::remote::SandboxClient::new(
|
||||
self.cli_chat_proxy_base_url(),
|
||||
self.auth_manager.clone(),
|
||||
);
|
||||
let resp = sandbox_client
|
||||
.update_environment(
|
||||
environment_id,
|
||||
&crate::remote::SandboxUpdateEnvironmentRequest {
|
||||
name: params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
description: params
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
repository: params
|
||||
.get("repository")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
default_branch: params
|
||||
.get("default_branch")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
container_image: params
|
||||
.get("container_image")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
setup_script: params
|
||||
.get("setup_script")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
acp::Error::internal_error()
|
||||
.data(format!("Failed to update environment: {e}"))
|
||||
})?;
|
||||
crate::extensions::to_raw_response(
|
||||
&serde_json::json!({ "environment" : resp.environment, }),
|
||||
)
|
||||
}
|
||||
"x.ai/cloud/env/delete" => {
|
||||
crate::extensions::auth_gate::require_xai_auth(
|
||||
&self.auth_manager,
|
||||
"Authentication required",
|
||||
"Run `grok login` to authenticate.",
|
||||
)?;
|
||||
let params: serde_json::Value = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(e.to_string()))?;
|
||||
let environment_id = params
|
||||
.get("environment_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
acp::Error::invalid_params().data("missing environment_id")
|
||||
})?;
|
||||
let sandbox_client = crate::remote::SandboxClient::new(
|
||||
self.cli_chat_proxy_base_url(),
|
||||
self.auth_manager.clone(),
|
||||
);
|
||||
sandbox_client
|
||||
.delete_environment(environment_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
acp::Error::internal_error()
|
||||
.data(format!("Failed to delete environment: {e}"))
|
||||
})?;
|
||||
crate::extensions::to_raw_response(&serde_json::json!({ "ok" : true }))
|
||||
}
|
||||
"x.ai/billing" => crate::extensions::billing::handle(self, &args).await,
|
||||
"x.ai/auto-topup-rule" => {
|
||||
crate::extensions::billing::handle(self, &args).await
|
||||
}
|
||||
"x.ai/share_session" => crate::extensions::share::handle(self, &args).await,
|
||||
"x.ai/rollout/survey" => {
|
||||
crate::extensions::rollout::handle(self, &args).await
|
||||
}
|
||||
@@ -2395,9 +2162,6 @@ impl acp::Agent for MvpAgent {
|
||||
s if s.starts_with("x.ai/search/") => {
|
||||
crate::extensions::search::handle(self, &args).await
|
||||
}
|
||||
s if s.starts_with("x.ai/bundle/") => {
|
||||
crate::extensions::bundle::handle(self, &args).await
|
||||
}
|
||||
s if s.starts_with("x.ai/code/") => {
|
||||
let ops = self.resolve_workspace_ops()?;
|
||||
crate::extensions::code_nav::handle(self, &ops, &args).await
|
||||
|
||||
@@ -28,23 +28,15 @@ impl MvpAgent {
|
||||
let session_key = self.auth_manager.current_or_expired().map(|a| a.key.clone());
|
||||
let models = self.models_manager.models();
|
||||
let endpoints = self.models_manager.endpoints();
|
||||
let (alpha_test_key, client_version) = {
|
||||
let cfg = self.cfg.borrow();
|
||||
(
|
||||
cfg.endpoints.alpha_test_key.clone(),
|
||||
cfg.client_version.clone(),
|
||||
)
|
||||
};
|
||||
let alpha_test_key = self.cfg.borrow().endpoints.alpha_test_key.clone();
|
||||
let config = match crate::agent::config::resolve_aux_model_sampling_config(
|
||||
&slug,
|
||||
&models,
|
||||
&endpoints,
|
||||
session_key.as_deref(),
|
||||
alpha_test_key,
|
||||
client_version,
|
||||
) {
|
||||
Some(mut cfg) => {
|
||||
cfg.client_identifier = primary.client_identifier.clone();
|
||||
cfg.attribution_callback = primary.attribution_callback.clone();
|
||||
cfg.bearer_resolver = primary.bearer_resolver.clone();
|
||||
cfg.max_retries = primary.max_retries;
|
||||
@@ -60,10 +52,6 @@ impl MvpAgent {
|
||||
let client = OaiCompatClient::new(config).map_err(map_sampling_err_to_acp)?;
|
||||
Ok((client, model))
|
||||
}
|
||||
fn has_proxy_credentials(&self) -> bool {
|
||||
self.cfg.borrow().endpoints.deployment_key.is_some()
|
||||
|| self.auth_manager.current_or_expired().is_some_and(|a| a.is_session_auth())
|
||||
}
|
||||
/// `true` for session-based ACP auth methods.
|
||||
fn is_session_based_auth(&self) -> bool {
|
||||
self.auth_method_id
|
||||
@@ -384,39 +372,23 @@ impl MvpAgent {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Extract feedback credentials when proxy credentials are available.
|
||||
///
|
||||
/// Returns `(base_url, user_token, optional_extra_access_key, deployment_key)`.
|
||||
/// Used by both [`feedback_client`] and session spawning to avoid
|
||||
/// duplicating the credential assembly logic.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn feedback_credentials(
|
||||
&self,
|
||||
) -> Option<(String, Option<String>, Option<String>, Option<String>)> {
|
||||
if !self.has_proxy_credentials() {
|
||||
return None;
|
||||
}
|
||||
let user_token = self
|
||||
/// Feedback endpoint base when this is a subscription (OAuth) session —
|
||||
/// the Kimi Code feedback endpoint only takes the OAuth Bearer, so
|
||||
/// API-key-only setups get `None` (they are pointed at the issue
|
||||
/// tracker instead; kimi-cli slash.py parity).
|
||||
fn feedback_base_url(&self) -> Option<String> {
|
||||
let has_session = self
|
||||
.auth_manager
|
||||
.current_or_expired()
|
||||
.filter(|a| a.is_session_auth())
|
||||
.map(|a| a.key.clone());
|
||||
let cfg = self.cfg.borrow();
|
||||
let base_url = cfg.endpoints.resolve_feedback_base_url();
|
||||
let alpha_test_key = cfg.endpoints.alpha_test_key.clone();
|
||||
let deployment_key = cfg.endpoints.deployment_key.clone();
|
||||
Some((base_url, user_token, alpha_test_key, deployment_key))
|
||||
.is_some_and(|a| a.is_session_auth());
|
||||
has_session.then(|| self.cfg.borrow().endpoints.resolve_feedback_base_url())
|
||||
}
|
||||
/// Build a `FeedbackClient` with resolved feedback URL and credentials.
|
||||
/// Build a `FeedbackClient` for subscription sessions.
|
||||
pub(crate) fn feedback_client(&self) -> Option<FeedbackClient> {
|
||||
let (base_url, user_token, alpha_test_key, deployment_key) = self
|
||||
.feedback_credentials()?;
|
||||
Some(
|
||||
FeedbackClient::new(base_url, user_token)
|
||||
.with_alpha_test_key(alpha_test_key)
|
||||
.with_deployment_key(deployment_key)
|
||||
.with_auth_manager(self.auth_manager.clone()),
|
||||
)
|
||||
Some(FeedbackClient::new(
|
||||
self.feedback_base_url()?,
|
||||
self.auth_manager.clone(),
|
||||
))
|
||||
}
|
||||
/// Build a `RegistryConfig` if the feature is enabled (for passing to persistence actor).
|
||||
pub(super) fn build_registry_config(
|
||||
@@ -460,17 +432,6 @@ impl MvpAgent {
|
||||
.with_auth(self.auth_manager.clone()),
|
||||
)
|
||||
}
|
||||
pub(crate) fn conversations_client(
|
||||
&self,
|
||||
) -> Option<crate::remote::ConversationsClient> {
|
||||
if !crate::session::unified_list::conversations_lane_active() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::remote::ConversationsClient::new(self.auth_manager.clone()))
|
||||
}
|
||||
pub(crate) fn workspaces_client(&self) -> crate::remote::WorkspacesClient {
|
||||
crate::remote::WorkspacesClient::new(self.auth_manager.clone())
|
||||
}
|
||||
/// Pre-session command availability snapshot.
|
||||
///
|
||||
/// Used by the `x.ai/commands/list` ext method and the
|
||||
@@ -515,13 +476,9 @@ impl MvpAgent {
|
||||
) -> &kigi_agent::plugins::SharedPluginRegistryHandle {
|
||||
&self.plugin_registry_handle
|
||||
}
|
||||
/// `true` when the agent runs in writeback storage mode.
|
||||
pub(crate) fn is_writeback_storage(&self) -> bool {
|
||||
matches!(self.storage_mode, StorageMode::Writeback)
|
||||
}
|
||||
/// Resolved cli-chat-proxy base for session features (via
|
||||
/// `proxy_url`). Not for the deployment-config fetch.
|
||||
pub(crate) fn cli_chat_proxy_base_url(&self) -> String {
|
||||
pub(crate) fn coding_api_base_url(&self) -> String {
|
||||
self.cfg.borrow().endpoints.proxy_url()
|
||||
}
|
||||
pub(crate) fn alpha_test_key(&self) -> Option<String> {
|
||||
@@ -635,54 +592,14 @@ impl MvpAgent {
|
||||
pub(crate) fn deployment_key(&self) -> Option<String> {
|
||||
self.cfg.borrow().endpoints.deployment_key.clone()
|
||||
}
|
||||
/// Re-fetch remote settings and re-init the telemetry client.
|
||||
///
|
||||
/// Called unconditionally from both auth handlers so that:
|
||||
/// - First install / expired OIDC token: settings are fetched for
|
||||
/// the first time (the early prefetch had no auth to use).
|
||||
/// - Reauth / account switch: settings are refreshed to reflect
|
||||
/// the new user's remote settings targeting attributes.
|
||||
///
|
||||
/// This only refreshes `cfg.remote_settings` and re-inits the
|
||||
/// telemetry client (the only global static). Other settings
|
||||
/// derived from `remote_settings` (`web_fetch_enabled`, etc.) are
|
||||
/// resolved lazily per-turn from `cfg` and pick up the new values
|
||||
/// automatically.
|
||||
/// Agent-level fields materialised at startup (`worktree_type`,
|
||||
/// `restore_code`) are NOT re-resolved here; that requires a
|
||||
/// broader refactor of the init path.
|
||||
pub(super) async fn refresh_remote_settings(&self, auth: &crate::auth::KimiAuth) {
|
||||
if !crate::util::config::resolve_remote_fetch_enabled() {
|
||||
tracing::debug!("post-auth settings refresh skipped: remote_fetch disabled");
|
||||
return;
|
||||
}
|
||||
let Some(settings) = self.fetch_remote_settings(auth.clone()).await else {
|
||||
tracing::warn!("post-auth settings refresh failed (HTTP or parse error)");
|
||||
return;
|
||||
};
|
||||
tracing::info!("post-auth settings refreshed");
|
||||
{
|
||||
let mut cfg = self.cfg.borrow_mut();
|
||||
cfg.remote_settings = Some(settings);
|
||||
crate::util::config::sync_campaign_fields(&mut cfg);
|
||||
crate::agent::config::apply_remote_settings_side_effects(
|
||||
cfg.remote_settings.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Refresh remote settings settings and re-resolve eagerly-resolved config fields.
|
||||
/// Re-resolve eagerly-resolved config fields from the local config.
|
||||
///
|
||||
/// Called on `/new` session creation so feature flags reflect the latest
|
||||
/// remote settings state without requiring a TUI restart. Extends
|
||||
/// [`refresh_remote_settings`] by also re-running [`resolve_runtime_fields`]
|
||||
/// with the fresh settings.
|
||||
/// on-disk config without requiring a TUI restart. (Formerly this also
|
||||
/// re-fetched the xAI proxy's remote settings; that endpoint is gone.)
|
||||
///
|
||||
/// In-flight sessions are unaffected — they snapshot config at creation.
|
||||
pub(super) async fn refresh_settings_and_reapply(
|
||||
&self,
|
||||
auth: &crate::auth::KimiAuth,
|
||||
) {
|
||||
self.refresh_remote_settings(auth).await;
|
||||
pub(super) async fn refresh_settings_and_reapply(&self) {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
{
|
||||
let mut cfg = self.cfg.borrow_mut();
|
||||
@@ -698,36 +615,6 @@ impl MvpAgent {
|
||||
}
|
||||
self.emit_settings_update_notification();
|
||||
}
|
||||
/// Shared fetch half of every settings refresh: endpoint fields from a
|
||||
/// scoped `cfg` borrow, `fetch_settings_blocking` off-executor (it already
|
||||
/// retries transient errors internally), failures normalized to `None`.
|
||||
/// Callers own their miss logging.
|
||||
pub(super) async fn fetch_remote_settings(
|
||||
&self,
|
||||
auth: crate::auth::KimiAuth,
|
||||
) -> Option<crate::util::config::RemoteSettings> {
|
||||
if !crate::util::config::resolve_remote_fetch_enabled() {
|
||||
tracing::debug!("settings fetch skipped: remote_fetch disabled");
|
||||
return None;
|
||||
}
|
||||
let (base_url, alpha_test_key) = {
|
||||
let cfg = self.cfg.borrow();
|
||||
(cfg.endpoints.proxy_url(), cfg.endpoints.alpha_test_key.clone())
|
||||
};
|
||||
match tokio::task::spawn_blocking(move || crate::remote::fetch_settings_blocking(
|
||||
&base_url,
|
||||
&auth,
|
||||
alpha_test_key.as_deref(),
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(settings) => settings,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = % e, "settings fetch task panicked");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) async fn send_model_auto_switched(
|
||||
&self,
|
||||
session_id: &acp::SessionId,
|
||||
@@ -828,26 +715,9 @@ impl MvpAgent {
|
||||
),
|
||||
);
|
||||
}
|
||||
let cfg = self.cfg.borrow();
|
||||
let alpha_test_key = cfg.endpoints.alpha_test_key.clone();
|
||||
let client_version = cfg.client_version.clone();
|
||||
let deployment_id = crate::managed_config::resolve_deployment_id(
|
||||
cfg.endpoints.deployment_key.as_deref(),
|
||||
);
|
||||
drop(cfg);
|
||||
let user_id = self
|
||||
.auth_manager
|
||||
.current_or_expired()
|
||||
.filter(|a| a.is_session_auth())
|
||||
.map(|a| a.user_id);
|
||||
let mut config = crate::agent::config::sampling_config_for_model(
|
||||
model,
|
||||
credentials,
|
||||
alpha_test_key,
|
||||
client_version,
|
||||
deployment_id,
|
||||
user_id,
|
||||
);
|
||||
let alpha_test_key = self.cfg.borrow().endpoints.alpha_test_key.clone();
|
||||
let mut config =
|
||||
crate::agent::config::sampling_config_for_model(model, credentials, alpha_test_key);
|
||||
config.origin_client = origin_client;
|
||||
config
|
||||
}
|
||||
@@ -912,13 +782,7 @@ impl MvpAgent {
|
||||
.unwrap_or_else(|| kigi_version::VERSION.to_string());
|
||||
let alpha_test_key = cfg.endpoints.alpha_test_key.clone();
|
||||
let mut headers = indexmap::IndexMap::new();
|
||||
headers.insert("user-agent".to_string(), format!("xai-grok-build/{version}"));
|
||||
inject_proxy_headers(
|
||||
&mut headers,
|
||||
cfg.client_version.as_deref(),
|
||||
alpha_test_key.as_deref(),
|
||||
&base_url,
|
||||
);
|
||||
headers.insert("user-agent".to_string(), format!("kigi/{version}"));
|
||||
ImageGenConfig::Enabled {
|
||||
api_key: api_key.clone(),
|
||||
base_url,
|
||||
@@ -961,13 +825,7 @@ impl MvpAgent {
|
||||
.unwrap_or_else(|| kigi_version::VERSION.to_string());
|
||||
let alpha_test_key = cfg.endpoints.alpha_test_key.clone();
|
||||
let mut headers = indexmap::IndexMap::new();
|
||||
headers.insert("user-agent".to_string(), format!("xai-grok-build/{version}"));
|
||||
inject_proxy_headers(
|
||||
&mut headers,
|
||||
cfg.client_version.as_deref(),
|
||||
alpha_test_key.as_deref(),
|
||||
&base_url,
|
||||
);
|
||||
headers.insert("user-agent".to_string(), format!("kigi/{version}"));
|
||||
VideoGenConfig::Enabled {
|
||||
api_key,
|
||||
base_url,
|
||||
@@ -987,15 +845,8 @@ impl MvpAgent {
|
||||
&models,
|
||||
session.as_ref().map(|a| a.key.as_str()),
|
||||
alpha_test_key.clone(),
|
||||
client_version,
|
||||
&self.cfg.borrow().endpoints,
|
||||
)?;
|
||||
inject_proxy_headers(
|
||||
&mut cfg.extra_headers,
|
||||
cfg.client_version.as_deref(),
|
||||
alpha_test_key.as_deref(),
|
||||
&cfg.base_url,
|
||||
);
|
||||
Some(cfg)
|
||||
}
|
||||
/// Returns `Err` with a user-facing message on invalid config; the caller at
|
||||
@@ -1112,15 +963,6 @@ impl MvpAgent {
|
||||
.map(|(name, p)| p.render_io_summary(name))
|
||||
.collect(),
|
||||
models_manager,
|
||||
chat_modes: {
|
||||
let chat_modes = crate::agent::chat_modes::ChatModesManager::new(
|
||||
auth_manager.clone(),
|
||||
);
|
||||
if crate::agent::chat_modes::process_chat_mode_enabled() {
|
||||
chat_modes.warm_in_background();
|
||||
}
|
||||
chat_modes
|
||||
},
|
||||
cfg: RefCell::new(cfg.clone()),
|
||||
auth_method_id: crate::agent::auth_method::new_shared_auth_method_id(None),
|
||||
sampling_config: RefCell::new(sampling_config),
|
||||
@@ -1159,7 +1001,6 @@ impl MvpAgent {
|
||||
subagent_event_rx: RefCell::new(Some(subagent_event_rx)),
|
||||
subagent_coordinator: RefCell::new(subagent_coordinator),
|
||||
monitor_event_buffer: kigi_tools::implementations::grok_build::task::types::MonitorEventBuffer::default(),
|
||||
bundle_sync_in_flight: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
workspace_ops: RefCell::new(None),
|
||||
require_gateway_sessions: Rc::new(
|
||||
RefCell::new(std::collections::HashSet::new()),
|
||||
@@ -2221,19 +2062,9 @@ impl MvpAgent {
|
||||
let auto_update = self.cfg.borrow().cli.auto_update;
|
||||
let client_type = *self.client_type.borrow();
|
||||
let buffering_settings = self.buffering_settings.borrow().clone();
|
||||
let (
|
||||
feedback_proxy_url,
|
||||
feedback_user_token,
|
||||
feedback_alpha_test_key,
|
||||
deployment_key,
|
||||
) = if let Some((url, token, alpha, deploy)) = self.feedback_credentials() {
|
||||
(Some(url), token, alpha, deploy)
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
};
|
||||
let feedback_base_url = self.feedback_base_url();
|
||||
tracing::info!(
|
||||
session_id = % session_info.id.0, feedback_url = ? feedback_proxy_url,
|
||||
authenticated = feedback_user_token.is_some(),
|
||||
session_id = % session_info.id.0, feedback_url = ? feedback_base_url,
|
||||
"Initializing feedback manager for session"
|
||||
);
|
||||
let skills = self.cfg.borrow().skills.clone();
|
||||
@@ -2489,7 +2320,6 @@ impl MvpAgent {
|
||||
self.auth_type(),
|
||||
),
|
||||
alpha_test_key: self.alpha_test_key(),
|
||||
client_version: sampling_config.client_version.clone(),
|
||||
};
|
||||
let attribution_callback: Option<
|
||||
kigi_sampler::SharedAttributionCallback,
|
||||
@@ -2599,10 +2429,7 @@ impl MvpAgent {
|
||||
self.codebase_indexes.clone(),
|
||||
client_code_nav_enabled,
|
||||
fs_watch_caps,
|
||||
feedback_proxy_url,
|
||||
feedback_user_token,
|
||||
feedback_alpha_test_key,
|
||||
deployment_key,
|
||||
feedback_base_url,
|
||||
client_terminal,
|
||||
client_fs_read && client_fs_write,
|
||||
gateway_enabled,
|
||||
@@ -2617,7 +2444,6 @@ impl MvpAgent {
|
||||
persisted_goal_mode,
|
||||
persisted_announcement_state,
|
||||
self.memory_config.clone(),
|
||||
loc_tracking_enabled,
|
||||
feedback_flags,
|
||||
self.managed_mcp_cache.clone(),
|
||||
managed_mcp_expires_at,
|
||||
|
||||
@@ -406,17 +406,11 @@ struct SettingsUpdateNotification {
|
||||
sharing_enabled: Option<bool>,
|
||||
session_picker_grouped: Option<bool>,
|
||||
tips: Option<Vec<String>>,
|
||||
gate_message: Option<String>,
|
||||
gate_url: Option<String>,
|
||||
gate_label: Option<String>,
|
||||
allow_access: Option<bool>,
|
||||
subscription_tier_display: Option<String>,
|
||||
auto_permission_mode_enabled: Option<bool>,
|
||||
/// Soft-default permission mode for the pager (post-auth / `/new` refresh).
|
||||
permission_mode: Option<String>,
|
||||
group_tool_verbs: Option<bool>,
|
||||
collapsed_edit_blocks: Option<bool>,
|
||||
subscription_watch_interval_secs: Option<u64>,
|
||||
}
|
||||
/// Reason why a client is not eligible to use codebase indexing.
|
||||
///
|
||||
@@ -509,9 +503,6 @@ pub struct MvpAgent {
|
||||
pub(crate) sampling_config: RefCell<SamplingConfig>,
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
pub(crate) models_manager: crate::agent::models::ModelsManager,
|
||||
/// grok.com chat-product catalog (`/rest/modes`) for chat sessions; distinct
|
||||
/// from `models_manager` (the build `/v1/models` catalog).
|
||||
pub(crate) chat_modes: crate::agent::chat_modes::ChatModesManager,
|
||||
/// Forwards pasted codes from `handle_auth_submit_code` to the auth flow.
|
||||
pub(crate) auth_code_tx: RefCell<Option<tokio::sync::mpsc::Sender<String>>>,
|
||||
/// Receives the auth URL from the auth flow; read by `handle_auth_get_url`.
|
||||
@@ -672,20 +663,6 @@ pub struct MvpAgent {
|
||||
/// this flag keeps that to a single discovery walk.
|
||||
plugin_registry_initialized: std::cell::Cell<bool>,
|
||||
persona_io_summaries: Vec<String>,
|
||||
/// Single-flight guard for the proactive bundle sync background task.
|
||||
///
|
||||
/// `maybe_sync_bundle_in_background` is invoked from each post-auth path
|
||||
/// (initialize, cached-token reauth, oidc) and a rapid reconnect can fire
|
||||
/// all three within the TTL window, giving us multiple concurrent
|
||||
/// `tokio::task::spawn_local` tasks racing to extract the tar archive,
|
||||
/// rewrite `manifest.json`, and prune stale files. The non-atomic
|
||||
/// per-file write/prune semantics in `bundle::extract_bundle_archive`
|
||||
/// make that race observable as a partially-written cache.
|
||||
///
|
||||
/// We use an `Arc<AtomicBool>` so the spawned task can clear the flag
|
||||
/// on completion without re-borrowing `&self`. `Send` is required
|
||||
/// because the inner `sync_bundle_to_root` now uses `spawn_blocking`.
|
||||
bundle_sync_in_flight: Arc<std::sync::atomic::AtomicBool>,
|
||||
/// Local workspace ops, built lazily via [`Self::ensure_local_workspace_ops`].
|
||||
/// The agent never opens Computer Hub as a harness/client; remote cloud
|
||||
/// sandboxes are gateway-owned (`gateway_bridge` / `computer_sessions`).
|
||||
@@ -944,50 +921,6 @@ impl AuthRequestMeta {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
/// Inject standard proxy headers into an `extra_headers` map.
|
||||
///
|
||||
/// Every authenticated request to cli-chat-proxy (web search, image gen, and
|
||||
/// any future tools that go through the proxy) must carry these headers.
|
||||
/// Centralising them here means new tool code paths only need one call instead
|
||||
/// of remembering which headers the proxy expects.
|
||||
///
|
||||
/// Headers injected:
|
||||
/// - `x-grok-client-version` -- required by the proxy's version-gate check.
|
||||
/// Uses `client_version` when provided, otherwise falls back to cli-chat-proxy
|
||||
/// compile-time `CARGO_PKG_VERSION`.
|
||||
/// - `X-XAI-Token-Auth` / `x-authenticateresponse` -- required by the
|
||||
/// cli-chat-proxy auth middleware when the `base_url` is a known proxy URL.
|
||||
/// - optional extra access header -- only set when the corresponding key is
|
||||
/// `Some` *and* the `base_url` points at a matching non-production host
|
||||
/// (requires the optional non-production feature).
|
||||
///
|
||||
/// Existing entries are never overwritten so callers can pre-set a value.
|
||||
fn inject_proxy_headers(
|
||||
headers: &mut indexmap::IndexMap<String, String>,
|
||||
client_version: Option<&str>,
|
||||
alpha_test_key: Option<&str>,
|
||||
base_url: &str,
|
||||
) {
|
||||
headers
|
||||
.entry("x-grok-client-version".to_string())
|
||||
.or_insert_with(|| {
|
||||
client_version
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| kigi_version::VERSION.to_string())
|
||||
});
|
||||
if crate::util::is_cli_chat_proxy_url(base_url) {
|
||||
headers
|
||||
.entry("X-XAI-Token-Auth".to_string())
|
||||
.or_insert_with(|| "xai-grok-cli".to_string());
|
||||
headers
|
||||
.entry("x-authenticateresponse".to_string())
|
||||
.or_insert_with(|| "authenticate-response".to_string());
|
||||
headers
|
||||
.entry(crate::http::CLIENT_MODE_HEADER.to_string())
|
||||
.or_insert_with(|| crate::http::process_client_mode().to_string());
|
||||
}
|
||||
let _ = (alpha_test_key, base_url);
|
||||
}
|
||||
fn resolve_inference_idle_timeout_secs(
|
||||
models: &indexmap::IndexMap<String, crate::agent::config::ModelEntry>,
|
||||
model: &str,
|
||||
@@ -1580,47 +1513,6 @@ impl MvpAgent {
|
||||
});
|
||||
AuthenticateResponse::new().meta(meta)
|
||||
}
|
||||
/// Fetch remote settings after authentication when early prefetch had none.
|
||||
/// Notifies the pager so soft-default permission_mode applies post-login.
|
||||
pub(super) async fn maybe_fetch_post_auth_settings(&self) {
|
||||
if self.cfg.borrow().remote_settings.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(auth) = self.auth_manager.current() else {
|
||||
return;
|
||||
};
|
||||
let is_session_auth = auth.is_session_auth();
|
||||
let Some(settings) = self.fetch_remote_settings(auth).await else {
|
||||
return;
|
||||
};
|
||||
tracing::info!("post-auth remote_settings fetch succeeded");
|
||||
{
|
||||
let mut cfg = self.cfg.borrow_mut();
|
||||
cfg.remote_settings = Some(settings);
|
||||
crate::agent::config::apply_remote_settings_side_effects(
|
||||
cfg.remote_settings.as_ref(),
|
||||
);
|
||||
if cfg.storage_mode == StorageMode::Local
|
||||
&& cfg.mode != crate::agent::config::AgentMode::Generic
|
||||
{
|
||||
cfg.storage_mode = StorageMode::resolve(
|
||||
None,
|
||||
cfg.remote_settings.as_ref(),
|
||||
);
|
||||
if cfg.storage_mode == StorageMode::Writeback && !is_session_auth {
|
||||
cfg.storage_mode = StorageMode::Local;
|
||||
}
|
||||
}
|
||||
if let Some(v) = cfg
|
||||
.remote_settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.path_not_found_hints)
|
||||
{
|
||||
cfg.path_not_found_hints = v;
|
||||
}
|
||||
}
|
||||
self.emit_settings_update_notification();
|
||||
}
|
||||
/// Fire-and-forget `x.ai/settings/update` from the current remote snapshot.
|
||||
pub(super) fn emit_settings_update_notification(&self) {
|
||||
let payload = {
|
||||
@@ -1631,20 +1523,12 @@ impl MvpAgent {
|
||||
sharing_enabled: rs.and_then(|s| s.sharing_enabled),
|
||||
session_picker_grouped: rs.and_then(|s| s.session_picker_grouped),
|
||||
tips: rs.and_then(|s| s.tips.clone()),
|
||||
gate_message: rs.and_then(|s| s.gate_message.clone()),
|
||||
gate_url: rs.and_then(|s| s.gate_url.clone()),
|
||||
gate_label: rs.and_then(|s| s.gate_label.clone()),
|
||||
allow_access: rs.and_then(|s| s.allow_access),
|
||||
subscription_tier_display: rs
|
||||
.and_then(|s| s.subscription_tier_display.clone()),
|
||||
auto_permission_mode_enabled: crate::util::config::remote_auto_mode_enabled(
|
||||
rs,
|
||||
),
|
||||
permission_mode: rs.and_then(|s| s.permission_mode.clone()),
|
||||
group_tool_verbs: rs.and_then(|s| s.group_tool_verbs),
|
||||
collapsed_edit_blocks: rs.and_then(|s| s.collapsed_edit_blocks),
|
||||
subscription_watch_interval_secs: rs
|
||||
.and_then(|s| s.subscription_watch_interval_secs),
|
||||
}
|
||||
};
|
||||
if let Ok(params) = serde_json::value::to_raw_value(&payload) {
|
||||
@@ -1719,78 +1603,6 @@ impl MvpAgent {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Spawn a best-effort bundle sync. Re-fires on every call site (init,
|
||||
/// cached_token, grok.com/oidc); the cheap pre-checks below absorb repeats
|
||||
/// so reconnects are cheap.
|
||||
///
|
||||
/// Pre-spawn gating order (cheapest first, all synchronous):
|
||||
/// 1. Auth gate — avoid spawning a no-op task on every init.
|
||||
/// 2. Freshness check — skip the sender snapshot + spawn entirely on
|
||||
/// cache hits, which is the steady-state on every reconnect.
|
||||
/// 3. Single-flight guard — if a previous sync is still in flight (e.g.,
|
||||
/// initialize + cached_token + oidc fired in quick succession before
|
||||
/// the first sync's tar extract finished), drop this call to avoid
|
||||
/// racing concurrent extracts that would interleave per-file writes
|
||||
/// against `~/.kigi/bundled/` and the manifest.
|
||||
pub(crate) fn maybe_sync_bundle_in_background(&self, force: bool) {
|
||||
use crate::extensions::bundle::{
|
||||
BUNDLE_SYNC_TTL, bundle_cache_is_fresh, has_bundle_credentials,
|
||||
maybe_sync_bundle_to_root,
|
||||
};
|
||||
use std::sync::atomic::Ordering;
|
||||
let am = self.auth_manager.clone();
|
||||
let deployment_key = self.deployment_key();
|
||||
if !has_bundle_credentials(Some(&am), deployment_key.as_deref()) {
|
||||
return;
|
||||
}
|
||||
let root = crate::bundle::bundled_root();
|
||||
if !force && bundle_cache_is_fresh(&root, BUNDLE_SYNC_TTL) {
|
||||
tracing::debug!("proactive bundle sync skipped pre-spawn: cache is fresh");
|
||||
return;
|
||||
}
|
||||
let in_flight = self.bundle_sync_in_flight.clone();
|
||||
if in_flight
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
tracing::debug!(
|
||||
"proactive bundle sync skipped: another sync is already in flight"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let proxy_base_url = self.cli_chat_proxy_base_url();
|
||||
let alpha_test_key = self.alpha_test_key();
|
||||
let senders: Vec<
|
||||
tokio::sync::mpsc::UnboundedSender<crate::session::SessionCommand>,
|
||||
> = self.sessions.borrow().values().map(|h| h.cmd_tx.clone()).collect();
|
||||
tokio::task::spawn_local(async move {
|
||||
let result = maybe_sync_bundle_to_root(
|
||||
&root,
|
||||
&proxy_base_url,
|
||||
Some(&am),
|
||||
deployment_key.as_deref(),
|
||||
alpha_test_key.as_deref(),
|
||||
force,
|
||||
BUNDLE_SYNC_TTL,
|
||||
)
|
||||
.await;
|
||||
in_flight.store(false, Ordering::Release);
|
||||
match result {
|
||||
Ok(Some(res)) => {
|
||||
tracing::info!(
|
||||
version = % res.version, personas = res.personas_count, roles =
|
||||
res.roles_count, agents = res.agents_count, skills = res
|
||||
.skills_count, "proactive bundle sync complete"
|
||||
);
|
||||
Self::broadcast_refresh_skill_baseline(senders);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(error = % err, "proactive bundle sync failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Parse `_meta.agentProfile` as a JSON object or string name.
|
||||
/// Returns `None` if absent or invalid.
|
||||
|
||||
@@ -402,7 +402,7 @@ impl MvpAgent {
|
||||
client_hooks: Default::default(),
|
||||
sampling_config: self.sampling_config.borrow().clone(),
|
||||
managed_mcp_proxy_base_url: parent_managed_mcp_proxy_base_url
|
||||
.unwrap_or_else(|| self.cli_chat_proxy_base_url()),
|
||||
.unwrap_or_else(|| self.coding_api_base_url()),
|
||||
alpha_test_key: self.alpha_test_key(),
|
||||
auth_method_id: self
|
||||
.auth_method_id
|
||||
|
||||
@@ -1822,18 +1822,6 @@ fn orphaned_tasks_filters_rewind_dead_branches() {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn allow_access_from_remote_settings() {
|
||||
let json = serde_json::json!({ "allow_access" : true });
|
||||
let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(rs.allow_access, Some(true));
|
||||
let json = serde_json::json!({ "allow_access" : false });
|
||||
let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(rs.allow_access, Some(false));
|
||||
let json = serde_json::json!({});
|
||||
let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(rs.allow_access, None);
|
||||
}
|
||||
#[test]
|
||||
fn on_demand_enabled_from_remote_settings() {
|
||||
let json = serde_json::json!({ "on_demand_enabled" : false });
|
||||
let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
|
||||
|
||||
@@ -708,7 +708,6 @@ pub(crate) async fn handle_subagent_request(
|
||||
api_key: effective_sampling_config.api_key.clone(),
|
||||
auth_type: inherited_auth_type,
|
||||
alpha_test_key: ctx.alpha_test_key.clone(),
|
||||
client_version: effective_sampling_config.client_version.clone(),
|
||||
};
|
||||
kigi_log::unified_log::info(
|
||||
"subagent spawn credentials",
|
||||
@@ -1020,9 +1019,6 @@ pub(crate) async fn handle_subagent_request(
|
||||
false,
|
||||
subagent_fs_watch,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
|
||||
@@ -1057,7 +1053,6 @@ pub(crate) async fn handle_subagent_request(
|
||||
} else {
|
||||
ctx.memory_config.clone()
|
||||
},
|
||||
false,
|
||||
Default::default(),
|
||||
ctx.managed_mcp_state.clone(),
|
||||
None,
|
||||
|
||||
@@ -898,15 +898,11 @@ async fn read_parent_sampling_config(
|
||||
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: None,
|
||||
stream_tool_calls: cfg.stream_tool_calls.unwrap_or(false),
|
||||
idle_timeout_secs: None,
|
||||
client_identifier: ctx.sampling_config.client_identifier.clone(),
|
||||
deployment_id: ctx.sampling_config.deployment_id.clone(),
|
||||
user_id: ctx.sampling_config.user_id.clone(),
|
||||
origin_client: ctx.sampling_config.origin_client.clone(),
|
||||
attribution_callback: ctx.attribution_callback.clone(),
|
||||
bearer_resolver: None,
|
||||
@@ -997,14 +993,7 @@ fn resolve_model_override_to_config(
|
||||
let mut credentials = resolve_credentials(&entry, session_key);
|
||||
credentials.auth_type = subagent_auth_type(Some(&entry), &ctx.auth_method_id);
|
||||
let resolved_auth_type = credentials.auth_type;
|
||||
let config = sampling_config_for_model(
|
||||
&entry,
|
||||
credentials,
|
||||
ctx.alpha_test_key.clone(),
|
||||
ctx.sampling_config.client_version.clone(),
|
||||
ctx.sampling_config.deployment_id.clone(),
|
||||
ctx.sampling_config.user_id.clone(),
|
||||
);
|
||||
let config = sampling_config_for_model(&entry, credentials, ctx.alpha_test_key.clone());
|
||||
kigi_log::unified_log::debug(
|
||||
"subagent resolve_model_override_to_config",
|
||||
None,
|
||||
|
||||
@@ -29,12 +29,14 @@ pub(crate) fn ascii_header_value(value: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The three device-identity headers sent on every OAuth call.
|
||||
/// The three device-identity headers sent on every OAuth call and, via
|
||||
/// `agent::config::inject_url_derived_headers`, on every first-party
|
||||
/// inference request (mirroring kimi-cli src/kimi_cli/llm.py:317-323).
|
||||
///
|
||||
/// Errors when the persistent device id cannot be created (e.g. read-only
|
||||
/// `~/.kigi`): the OAuth endpoints require `X-Msh-Device-Id`, so login cannot
|
||||
/// proceed without it.
|
||||
pub(crate) fn device_headers() -> anyhow::Result<[(&'static str, String); 3]> {
|
||||
/// proceed without it. Inference callers treat the error as skip-with-warning.
|
||||
pub fn device_headers() -> anyhow::Result<[(&'static str, String); 3]> {
|
||||
Ok([
|
||||
("X-Msh-Device-Name", ascii_header_value(&device_name())),
|
||||
("X-Msh-Device-Model", ascii_header_value(device_model())),
|
||||
|
||||
@@ -150,6 +150,11 @@ async fn complete_device_code_login(
|
||||
/// caller can decide how to notify the user (eprintln on CLI, nothing on TUI
|
||||
/// where the URL is already rendered in the widget).
|
||||
async fn open_browser_detached(url: &str) -> bool {
|
||||
// Unit tests drive the full login flow against mock servers — their
|
||||
// fixture URLs must never reach a real browser.
|
||||
if cfg!(test) {
|
||||
return false;
|
||||
}
|
||||
let url = url.to_owned();
|
||||
match tokio::task::spawn_blocking(move || webbrowser::open(&url)).await {
|
||||
Ok(Ok(())) => true,
|
||||
@@ -171,13 +176,16 @@ mod tests {
|
||||
use wiremock::matchers::{body_string_contains, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Fixture mirroring the live `device_authorization` payload (verified
|
||||
/// against auth.kimi.com): verification URLs are passed through verbatim
|
||||
/// by the login flow, so they use the real shape.
|
||||
fn device_auth_json(code: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"user_code": "ABCD-1234",
|
||||
"user_code": "WXYZ-6789",
|
||||
"device_code": code,
|
||||
"verification_uri": "https://auth.kimi.com/device",
|
||||
"verification_uri_complete": "https://auth.kimi.com/device?code=ABCD-1234",
|
||||
"expires_in": 600,
|
||||
"verification_uri": "https://www.kimi.com/code/authorize_device",
|
||||
"verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=WXYZ-6789",
|
||||
"expires_in": 1800,
|
||||
"interval": 0, // floored to 1s by the poll loop
|
||||
})
|
||||
}
|
||||
|
||||
@@ -359,11 +359,11 @@ mod tests {
|
||||
"client_id={KIMI_CODE_CLIENT_ID}"
|
||||
)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"user_code": "ABCD-1234",
|
||||
"user_code": "WXYZ-6789",
|
||||
"device_code": "dev-code-1",
|
||||
"verification_uri": "https://auth.kimi.com/device",
|
||||
"verification_uri_complete": "https://auth.kimi.com/device?code=ABCD-1234",
|
||||
"expires_in": 600,
|
||||
"verification_uri": "https://www.kimi.com/code/authorize_device",
|
||||
"verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=WXYZ-6789",
|
||||
"expires_in": 1800,
|
||||
"interval": 7,
|
||||
})))
|
||||
.expect(1)
|
||||
@@ -371,13 +371,13 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let auth = request_device_authorization(&server.uri()).await.unwrap();
|
||||
assert_eq!(auth.user_code, "ABCD-1234");
|
||||
assert_eq!(auth.user_code, "WXYZ-6789");
|
||||
assert_eq!(auth.device_code, "dev-code-1");
|
||||
assert_eq!(auth.interval, 7);
|
||||
assert_eq!(auth.expires_in, Some(600));
|
||||
assert_eq!(auth.expires_in, Some(1800));
|
||||
assert_eq!(
|
||||
auth.verification_uri_complete,
|
||||
"https://auth.kimi.com/device?code=ABCD-1234"
|
||||
"https://www.kimi.com/code/authorize_device?user_code=WXYZ-6789"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ mod tests {
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"user_code": "AAAA",
|
||||
"device_code": "d",
|
||||
"verification_uri_complete": "https://auth.kimi.com/device?code=AAAA",
|
||||
"verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=AAAA",
|
||||
"interval": 5,
|
||||
})))
|
||||
.expect(1)
|
||||
@@ -409,7 +409,7 @@ mod tests {
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"user_code": "AAAA",
|
||||
"device_code": "d",
|
||||
"verification_uri_complete": "https://auth.kimi.com/device?code=AAAA",
|
||||
"verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=AAAA",
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Access-gate copy resolved from remote settings (message + optional CTA).
|
||||
/// Auth no longer produces gates (tier gating was an xAI concept); the pager
|
||||
/// still renders one when remote settings carry a gate message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GateInfo {
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Typed auth metadata passed from the shell to the pager via ACP.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AuthMeta {
|
||||
|
||||
@@ -20,9 +20,10 @@ pub use flow::{
|
||||
run_auth_flow_with_stderr_bridge, run_cli_login, run_cli_logout, try_ensure_fresh_auth,
|
||||
};
|
||||
mod meta;
|
||||
pub use device::device_headers;
|
||||
pub use error::{AuthError, RefreshTokenError, RefreshTokenFailedReason};
|
||||
pub use manager::{AuthManager, shared_api_key_provider};
|
||||
pub use meta::{AuthMeta, GateInfo};
|
||||
pub use meta::AuthMeta;
|
||||
pub use model::{AuthMode, KimiAuth, lookup_auth};
|
||||
pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix};
|
||||
pub use storage::{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1094,11 +1094,11 @@ fn apply_requirements_inner(
|
||||
config.endpoints.xai_api_base_url = val.to_owned();
|
||||
push("endpoints.xai_api_base_url", val.to_owned());
|
||||
}
|
||||
if let Some(val) = req_str(req, "endpoints", "cli_chat_proxy_base_url")
|
||||
&& config.endpoints.cli_chat_proxy_base_url.as_deref() != Some(val)
|
||||
if let Some(val) = req_str(req, "endpoints", "coding_api_base_url")
|
||||
&& config.endpoints.coding_api_base_url.as_deref() != Some(val)
|
||||
{
|
||||
config.endpoints.cli_chat_proxy_base_url = Some(val.to_owned());
|
||||
push("endpoints.cli_chat_proxy_base_url", val.to_owned());
|
||||
config.endpoints.coding_api_base_url = Some(val.to_owned());
|
||||
push("endpoints.coding_api_base_url", val.to_owned());
|
||||
}
|
||||
enforce_str!(
|
||||
"endpoints",
|
||||
|
||||
@@ -2564,7 +2564,7 @@ fn config_layers_user_overrides_managed() {
|
||||
fn enterprise_two_file_merge_routes_deployment_key_to_proxy() {
|
||||
for k in [
|
||||
"KIGI_MANAGED_CONFIG_URL",
|
||||
"KIGI_CLI_CHAT_PROXY_BASE_URL",
|
||||
"KIGI_CODE_BASE_URL",
|
||||
"KIGI_TRACE_UPLOAD_ENDPOINT_URL",
|
||||
] {
|
||||
unsafe { std::env::remove_var(k) };
|
||||
@@ -2573,7 +2573,7 @@ fn enterprise_two_file_merge_routes_deployment_key_to_proxy() {
|
||||
r#"
|
||||
[endpoints]
|
||||
xai_api_base_url = "https://inference.acme-corp.example/xai/v1"
|
||||
cli_chat_proxy_base_url = "https://cli-chat-proxy.kigi.com/v1"
|
||||
coding_api_base_url = "https://cli-chat-proxy.kigi.com/v1"
|
||||
|
||||
[model.kigi-build]
|
||||
base_url = "https://inference.acme-corp.example/xai/v1"
|
||||
@@ -2668,13 +2668,13 @@ fn config_layers_system_managed_lowest_priority() {
|
||||
#[test]
|
||||
fn apply_requirements_value_overrides_user_settings() {
|
||||
let raw_config: toml::Value = toml::from_str(
|
||||
"[cli]\nauto_update = true\nchannel = \"beta\"\n\n[features]\nfeedback = true\nlsp_tools = true\nweb_fetch = true\nwrite_file = true\n\n[ui]\nyolo = true\n\n[models]\ndefault = \"user-model\"\nweb_search = \"user-ws-model\"\n\n[endpoints]\ncli_chat_proxy_base_url = \"https://user-proxy.example/v1\"\nxai_api_base_url = \"https://user-api.example/v1\"\nmodels_base_url = \"https://user-models.example/v1\"\nmodels_list_url = \"https://user-models.example/v1/models\"\n",
|
||||
"[cli]\nauto_update = true\nchannel = \"beta\"\n\n[features]\nfeedback = true\nlsp_tools = true\nweb_fetch = true\nwrite_file = true\n\n[ui]\nyolo = true\n\n[models]\ndefault = \"user-model\"\nweb_search = \"user-ws-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://user-proxy.example/v1\"\nxai_api_base_url = \"https://user-api.example/v1\"\nmodels_base_url = \"https://user-models.example/v1\"\nmodels_list_url = \"https://user-models.example/v1/models\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
|
||||
cfg.default_yolo_mode = true;
|
||||
let requirements: toml::Value = toml::from_str(
|
||||
"[cli]\nauto_update = false\nchannel = \"stable\"\n\n[features]\nfeedback = false\nlsp_tools = false\nweb_fetch = false\nwrite_file = false\nremote_fetch = false\n\n[ui]\nyolo = false\n\n[models]\ndefault = \"managed-model\"\nweb_search = \"managed-ws-model\"\n\n[endpoints]\ncli_chat_proxy_base_url = \"https://managed-proxy.example/v1\"\nxai_api_base_url = \"https://managed-api.example/v1\"\nmodels_base_url = \"https://managed-models.example/v1\"\nmodels_list_url = \"https://managed-models.example/v1/models\"\ndeployment_key = \"enterprise-deploy-key-should-not-log\"\n",
|
||||
"[cli]\nauto_update = false\nchannel = \"stable\"\n\n[features]\nfeedback = false\nlsp_tools = false\nweb_fetch = false\nwrite_file = false\nremote_fetch = false\n\n[ui]\nyolo = false\n\n[models]\ndefault = \"managed-model\"\nweb_search = \"managed-ws-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://managed-proxy.example/v1\"\nxai_api_base_url = \"https://managed-api.example/v1\"\nmodels_base_url = \"https://managed-models.example/v1\"\nmodels_list_url = \"https://managed-models.example/v1/models\"\ndeployment_key = \"enterprise-deploy-key-should-not-log\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let source = RequirementSource::Requirements {
|
||||
@@ -2697,7 +2697,7 @@ fn apply_requirements_value_overrides_user_settings() {
|
||||
assert_eq!(Some("managed-ws-model"), cfg.models.web_search.as_deref());
|
||||
assert_eq!(Some("stable"), cfg.cli.channel.as_deref());
|
||||
assert_eq!(
|
||||
Some("https://managed-proxy.example/v1"), cfg.endpoints.cli_chat_proxy_base_url
|
||||
Some("https://managed-proxy.example/v1"), cfg.endpoints.coding_api_base_url
|
||||
.as_deref()
|
||||
);
|
||||
assert_eq!("https://managed-api.example/v1", cfg.endpoints.xai_api_base_url);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! `x.ai/billing` extension handler.
|
||||
//! `x.ai/billing` extension handler — Kimi Code usage/quota.
|
||||
//!
|
||||
//! Fetches the authenticated user's Grok Build billing configuration
|
||||
//! (credit limit, usage, on-demand cap, billing period, history) from
|
||||
//! the backend. Used by the pager/desktop to display credits and usage.
|
||||
//! Port of kimi-cli's `/usage` command (kimi-cli `src/kimi_cli/ui/shell/usage.py`):
|
||||
//! `GET {coding_api_base_url}/usages` with the OAuth Bearer token, parsed into
|
||||
//! display rows (`{usage: {...}, limits: [{detail, window, ...}]}` payload
|
||||
//! shape). The TUI renders the rows as label + remaining-quota bar +
|
||||
//! reset hint. The xAI credits/auto-topup surface this file used to serve is
|
||||
//! gone with the xAI proxy.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -10,593 +13,396 @@ use serde::{Deserialize, Serialize};
|
||||
use super::{ExtResult, to_raw_response};
|
||||
use crate::agent::MvpAgent;
|
||||
|
||||
/// Billing period cycle identifier.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BillingCycle {
|
||||
pub year: i32,
|
||||
pub month: i32,
|
||||
}
|
||||
|
||||
/// Cent value from the billing API (USD cents).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Cent {
|
||||
/// proto3 JSON omits zero-valued scalars, so a `$0` Cent arrives as `{}`;
|
||||
/// default to 0 rather than failing the whole parse.
|
||||
#[serde(default)]
|
||||
pub val: i64,
|
||||
}
|
||||
|
||||
/// A usage period (weekly or monthly) from the newer credits config.
|
||||
/// One usage row: a named quota with `used`/`limit` and an optional
|
||||
/// human-readable reset hint (e.g. "resets in 2h 5m").
|
||||
///
|
||||
/// `start`/`end` are RFC 3339 timestamps. `period_type` is the proto enum name
|
||||
/// (e.g. `USAGE_PERIOD_TYPE_WEEKLY`); kept so callers can distinguish weekly
|
||||
/// vs monthly cycles.
|
||||
/// `Deserialize` because the TUI parses this back out of the
|
||||
/// `x.ai/billing` ext response.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageRow {
|
||||
pub label: String,
|
||||
pub used: i64,
|
||||
pub limit: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reset_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Response for `x.ai/billing`: the parsed usage rows, in display order
|
||||
/// (summary row first when the payload carries one).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsagePeriod {
|
||||
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
|
||||
pub period_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub end: Option<String>,
|
||||
pub struct UsageResponse {
|
||||
pub rows: Vec<UsageRow>,
|
||||
}
|
||||
|
||||
/// Usage summary for one past billing period.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BillingPeriodUsage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing_cycle: Option<BillingCycle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_used: Option<Cent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_demand_used: Option<Cent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_used: Option<Cent>,
|
||||
}
|
||||
|
||||
/// Current billing configuration for Grok Build coding credits.
|
||||
///
|
||||
/// Carries both the newer credits-config fields (`credit_usage_percent`,
|
||||
/// `current_period`) and the deprecated `GrokBuildBillingConfig` fields
|
||||
/// (`monthly_limit`, `used`, `billing_period_*`). Consumers should prefer the
|
||||
/// new fields and fall back to the deprecated ones, so the same struct works
|
||||
/// against both the new `GetGrokCreditsConfig` and the legacy
|
||||
/// `GetGrokBuildBillingConfig` backend responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BillingConfig {
|
||||
/// Included credit usage as a percentage of the allowance (0.0–100.0).
|
||||
/// Preferred over deriving from `monthly_limit`/`used`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credit_usage_percent: Option<f64>,
|
||||
/// Current usage period (weekly or monthly). Preferred over
|
||||
/// `billing_period_start`/`billing_period_end`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_period: Option<UsagePeriod>,
|
||||
/// Deprecated: included monthly credit budget. Use `credit_usage_percent`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub monthly_limit: Option<Cent>,
|
||||
/// Deprecated: credits used this period. Use `credit_usage_percent`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub used: Option<Cent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_demand_cap: Option<Cent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_demand_used: Option<Cent>,
|
||||
/// Remaining prepaid (purchased) credit balance, positive — the "bought
|
||||
/// credits" the user has topped up. Populated from the credits config
|
||||
/// (`GetGrokCreditsConfig.prepaid_balance`); absent in the legacy billing
|
||||
/// shape.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prepaid_balance: Option<Cent>,
|
||||
/// Whether this user is on unified usage billing (shared weekly/monthly
|
||||
/// pool). From `GrokCreditsConfig.is_unified_billing_user`, which billing
|
||||
/// sets from remote settings `unified_consumer_billing_enabled`. `None` when
|
||||
/// absent (legacy `GetGrokBuildBillingConfig` shape or older servers).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_unified_billing_user: Option<bool>,
|
||||
/// Deprecated: use `current_period.start`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing_period_start: Option<String>,
|
||||
/// Deprecated: use `current_period.end`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing_period_end: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub history: Vec<BillingPeriodUsage>,
|
||||
}
|
||||
|
||||
/// Top-level response (primarily from `GET /rest/grok/credits` + auto-topup-rule).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BillingConfigResponse {
|
||||
pub config: Option<BillingConfig>,
|
||||
/// Whether on-demand credit usage is enabled. When `false`, the pager
|
||||
/// should hide on-demand controls. Populated from `RemoteSettings`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_demand_enabled: Option<bool>,
|
||||
/// User-friendly subscription tier name (e.g. "SuperGrok Heavy").
|
||||
/// Populated from `RemoteSettings` so the pager can update its cached
|
||||
/// tier on every billing fetch without an extra request.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subscription_tier: Option<String>,
|
||||
}
|
||||
|
||||
/// Auto top-up configuration (from GetAutoTopupRule).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AutoTopupRule {
|
||||
/// proto3 JSON omits `false`, so a disabled rule arrives without this field;
|
||||
/// default to `false` rather than failing the parse (which would otherwise
|
||||
/// keep a stale cached rule in the pager).
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
pub min_before_hitting_sl: Option<Cent>,
|
||||
pub topup_amount: Option<Cent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_amount_per_month: Option<Cent>,
|
||||
}
|
||||
|
||||
/// Wrapper for the auto top-up rule response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GetAutoTopupRuleResponse {
|
||||
#[serde(default)]
|
||||
pub rule: Option<AutoTopupRule>,
|
||||
/// Error from the usages fetch, mapped to the same user-facing messages
|
||||
/// kimi-cli shows (usage.py error handling).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UsageError {
|
||||
#[error("Authorization failed. Please check your credentials.")]
|
||||
Unauthorized,
|
||||
#[error("Usage endpoint not available. Try Kimi for Coding.")]
|
||||
NotFound,
|
||||
#[error("Failed to fetch usage (HTTP {status}).")]
|
||||
Http { status: u16 },
|
||||
#[error("Failed to fetch usage: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("Failed to parse usage response: {0}")]
|
||||
Parse(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/billing" => {
|
||||
tracing::info!("handling billing config request");
|
||||
handle_get_billing(agent).await
|
||||
}
|
||||
"x.ai/auto-topup-rule" => {
|
||||
tracing::info!("handling auto top-up rule request");
|
||||
handle_get_auto_topup_rule(agent).await
|
||||
tracing::info!("handling usage request");
|
||||
handle_get_usage(agent).await
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured context for unified-log entries from a successful billing fetch.
|
||||
///
|
||||
/// Keeps history to a count + the most recent period so `~/.kigi/logs/unified.jsonl`
|
||||
/// stays useful without dumping unbounded period arrays.
|
||||
fn billing_unified_log_ctx(billing: &BillingConfigResponse) -> serde_json::Value {
|
||||
let history_len = billing
|
||||
.config
|
||||
.as_ref()
|
||||
.map(|c| c.history.len())
|
||||
.unwrap_or(0);
|
||||
let latest_history = billing
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.history.last())
|
||||
.and_then(|p| serde_json::to_value(p).ok());
|
||||
|
||||
let mut config_value = billing
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
if let Some(obj) = config_value.as_object_mut() {
|
||||
// Drop full history array; surface length + latest entry instead.
|
||||
obj.remove("history");
|
||||
obj.insert("historyLen".into(), serde_json::json!(history_len));
|
||||
if let Some(latest) = latest_history {
|
||||
obj.insert("latestHistory".into(), latest);
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"config": config_value,
|
||||
"onDemandEnabled": billing.on_demand_enabled,
|
||||
"subscriptionTier": billing.subscription_tier,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_get_billing(agent: &MvpAgent) -> ExtResult {
|
||||
async fn handle_get_usage(agent: &MvpAgent) -> ExtResult {
|
||||
let auth = super::auth_gate::require_xai_auth(
|
||||
&agent.auth_manager,
|
||||
"Authentication required to fetch billing data",
|
||||
"Billing data requires auth with grok.com. Run `grok login` to authenticate.",
|
||||
"Authentication required to fetch usage data",
|
||||
"Usage data requires a Kimi Code subscription session. Run `kigi login` to authenticate.",
|
||||
)?;
|
||||
|
||||
let proxy_base = agent.cli_chat_proxy_base_url();
|
||||
let base = proxy_base.trim_end_matches('/');
|
||||
|
||||
// Credits balance / usage (new billing system) via the CLI proxy, which
|
||||
// forwards to the backend `GetGrokCreditsConfig`.
|
||||
let credits_url = format!("{}/billing?format=credits", base);
|
||||
let credits_resp = crate::http::shared_client()
|
||||
.get(&credits_url)
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header("x-userid", &auth.user_id)
|
||||
.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(15))
|
||||
.send()
|
||||
let base = agent.cfg.borrow().endpoints.proxy_url();
|
||||
let usage = fetch_usage(&crate::http::shared_client(), &base, &auth.key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "billing: upstream request failed");
|
||||
tracing::warn!(error = %e, "usage fetch failed");
|
||||
kigi_log::unified_log::warn(
|
||||
"billing: upstream request failed",
|
||||
"usage: fetch failed",
|
||||
None,
|
||||
Some(serde_json::json!({ "error": e.to_string() })),
|
||||
);
|
||||
acp::Error::internal_error().data(format!("Failed to fetch billing data: {e}"))
|
||||
acp::Error::internal_error().data(e.to_string())
|
||||
})?;
|
||||
|
||||
if !credits_resp.status().is_success() {
|
||||
let status = credits_resp.status().as_u16();
|
||||
let body = credits_resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(status, url = %credits_url, "billing: upstream error");
|
||||
|
||||
let detail = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| format!("HTTP {status}"));
|
||||
|
||||
kigi_log::unified_log::warn(
|
||||
"billing: upstream error",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
})),
|
||||
);
|
||||
|
||||
return Err(acp::Error::internal_error().data(format!("Billing service error: {detail}")));
|
||||
}
|
||||
|
||||
let mut billing: BillingConfigResponse = credits_resp.json().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "billing: failed to parse response");
|
||||
kigi_log::unified_log::warn(
|
||||
"billing: failed to parse response",
|
||||
None,
|
||||
Some(serde_json::json!({ "error": e.to_string() })),
|
||||
);
|
||||
acp::Error::internal_error().data(format!("Failed to parse billing data: {e}"))
|
||||
})?;
|
||||
|
||||
// Enrich with fields from remote settings.
|
||||
let rs = agent.cfg.borrow().remote_settings.clone();
|
||||
billing.on_demand_enabled = rs.as_ref().and_then(|rs| rs.on_demand_enabled);
|
||||
billing.subscription_tier = rs.as_ref().and_then(|rs| {
|
||||
rs.subscription_tier_display
|
||||
.clone()
|
||||
.or_else(|| rs.subscription_tier.clone())
|
||||
});
|
||||
|
||||
// Every prompt / /usage / poll path hits `x.ai/billing`; log the fetched
|
||||
// credits snapshot so support can correlate limit UX with real balances.
|
||||
kigi_log::unified_log::info(
|
||||
"billing: fetched credits config",
|
||||
"usage: fetched quota rows",
|
||||
None,
|
||||
Some(billing_unified_log_ctx(&billing)),
|
||||
serde_json::to_value(&usage).ok(),
|
||||
);
|
||||
|
||||
to_raw_response(&billing)
|
||||
to_raw_response(&usage)
|
||||
}
|
||||
|
||||
async fn handle_get_auto_topup_rule(agent: &MvpAgent) -> ExtResult {
|
||||
let auth = super::auth_gate::require_xai_auth(
|
||||
&agent.auth_manager,
|
||||
"Authentication required to fetch auto top-up rule",
|
||||
"Auto top-up data requires auth with grok.com. Run `grok login` to authenticate.",
|
||||
)?;
|
||||
|
||||
let proxy_base = agent.cli_chat_proxy_base_url();
|
||||
let base = proxy_base.trim_end_matches('/');
|
||||
|
||||
// Auto top-up rule via the CLI proxy, which forwards to the backend
|
||||
// `GetAutoTopupRule`.
|
||||
let url = format!("{}/auto-topup-rule", base);
|
||||
let response = crate::http::shared_client()
|
||||
/// `GET {base}/usages` with a Bearer token, parsed per kimi-cli usage.py.
|
||||
pub(crate) async fn fetch_usage(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> Result<UsageResponse, UsageError> {
|
||||
let url = format!("{}/usages", base_url.trim_end_matches('/'));
|
||||
let response = http
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header("x-userid", &auth.user_id)
|
||||
.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(10))
|
||||
.bearer_auth(token)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "auto-topup: upstream request failed");
|
||||
acp::Error::internal_error().data(format!("Failed to fetch auto top-up rule: {e}"))
|
||||
})?;
|
||||
.await?;
|
||||
match response.status().as_u16() {
|
||||
200..=299 => {}
|
||||
401 => return Err(UsageError::Unauthorized),
|
||||
404 => return Err(UsageError::NotFound),
|
||||
status => return Err(UsageError::Http { status }),
|
||||
}
|
||||
let payload: serde_json::Value = serde_json::from_str(&response.text().await?)?;
|
||||
Ok(parse_usage_payload(&payload))
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(status, url = %url, "auto-topup: upstream error");
|
||||
/// Port of usage.py `_parse_usage_payload`: `usage` (summary) + `limits[]`.
|
||||
fn parse_usage_payload(payload: &serde_json::Value) -> UsageResponse {
|
||||
let mut rows = Vec::new();
|
||||
|
||||
let detail = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| format!("HTTP {status}"));
|
||||
|
||||
return Err(
|
||||
acp::Error::internal_error().data(format!("Auto top-up service error: {detail}"))
|
||||
);
|
||||
if let Some(usage) = payload.get("usage").filter(|v| v.is_object())
|
||||
&& let Some(row) = to_usage_row(usage, "Weekly limit")
|
||||
{
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
// Return the upstream response body verbatim (as a JSON value) so /usage
|
||||
// can print the exact data from this request unformatted.
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&body_text).unwrap_or(serde_json::json!({"raw": body_text}));
|
||||
to_raw_response(&value)
|
||||
if let Some(limits) = payload.get("limits").and_then(|v| v.as_array()) {
|
||||
for (idx, item) in limits.iter().enumerate() {
|
||||
if !item.is_object() {
|
||||
continue;
|
||||
}
|
||||
let detail = match item.get("detail") {
|
||||
Some(d) if d.is_object() => d,
|
||||
_ => item,
|
||||
};
|
||||
let empty = serde_json::json!({});
|
||||
let window = match item.get("window") {
|
||||
Some(w) if w.is_object() => w,
|
||||
_ => &empty,
|
||||
};
|
||||
let label = limit_label(item, detail, window, idx);
|
||||
if let Some(row) = to_usage_row(detail, &label) {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UsageResponse { rows }
|
||||
}
|
||||
|
||||
/// Port of usage.py `_to_usage_row`: `used`/`limit`, with
|
||||
/// `used = limit - remaining` fallback; row dropped when both absent.
|
||||
fn to_usage_row(data: &serde_json::Value, default_label: &str) -> Option<UsageRow> {
|
||||
let limit = to_int(data.get("limit"));
|
||||
let used = to_int(data.get("used")).or_else(|| match (to_int(data.get("remaining")), limit) {
|
||||
(Some(remaining), Some(limit)) => Some(limit - remaining),
|
||||
_ => None,
|
||||
});
|
||||
if used.is_none() && limit.is_none() {
|
||||
return None;
|
||||
}
|
||||
let label = data
|
||||
.get("name")
|
||||
.and_then(non_empty_str)
|
||||
.or_else(|| data.get("title").and_then(non_empty_str))
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| default_label.to_owned());
|
||||
Some(UsageRow {
|
||||
label,
|
||||
used: used.unwrap_or(0),
|
||||
limit: limit.unwrap_or(0),
|
||||
reset_hint: reset_hint(data),
|
||||
})
|
||||
}
|
||||
|
||||
/// Port of usage.py `_limit_label`: name/title/scope, else the window
|
||||
/// duration ("5h limit"), else "Limit #N".
|
||||
fn limit_label(
|
||||
item: &serde_json::Value,
|
||||
detail: &serde_json::Value,
|
||||
window: &serde_json::Value,
|
||||
idx: usize,
|
||||
) -> String {
|
||||
for key in ["name", "title", "scope"] {
|
||||
if let Some(val) = item
|
||||
.get(key)
|
||||
.and_then(non_empty_str)
|
||||
.or_else(|| detail.get(key).and_then(non_empty_str))
|
||||
{
|
||||
return val.to_owned();
|
||||
}
|
||||
}
|
||||
|
||||
let duration = to_int(window.get("duration"))
|
||||
.or_else(|| to_int(item.get("duration")))
|
||||
.or_else(|| to_int(detail.get("duration")));
|
||||
let time_unit = window
|
||||
.get("timeUnit")
|
||||
.and_then(non_empty_str)
|
||||
.or_else(|| item.get("timeUnit").and_then(non_empty_str))
|
||||
.or_else(|| detail.get("timeUnit").and_then(non_empty_str))
|
||||
.unwrap_or("");
|
||||
if let Some(duration) = duration.filter(|&d| d != 0) {
|
||||
if time_unit.contains("MINUTE") {
|
||||
if duration >= 60 && duration % 60 == 0 {
|
||||
return format!("{}h limit", duration / 60);
|
||||
}
|
||||
return format!("{duration}m limit");
|
||||
}
|
||||
if time_unit.contains("HOUR") {
|
||||
return format!("{duration}h limit");
|
||||
}
|
||||
if time_unit.contains("DAY") {
|
||||
return format!("{duration}d limit");
|
||||
}
|
||||
return format!("{duration}s limit");
|
||||
}
|
||||
|
||||
format!("Limit #{}", idx + 1)
|
||||
}
|
||||
|
||||
/// Port of usage.py `_reset_hint`: absolute reset keys first, then
|
||||
/// seconds-until keys.
|
||||
fn reset_hint(data: &serde_json::Value) -> Option<String> {
|
||||
for key in ["reset_at", "resetAt", "reset_time", "resetTime"] {
|
||||
if let Some(val) = data.get(key).and_then(non_empty_str) {
|
||||
return Some(format_reset_time(val));
|
||||
}
|
||||
}
|
||||
for key in ["reset_in", "resetIn", "ttl", "window"] {
|
||||
if let Some(seconds) = to_int(data.get(key)).filter(|&s| s != 0) {
|
||||
return Some(format!(
|
||||
"resets in {}",
|
||||
format_duration(seconds.max(0) as u64)
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Port of usage.py `_format_reset_time`: ISO timestamp → "resets in …" /
|
||||
/// "reset" (already past) / "resets at <raw>" when unparseable.
|
||||
fn format_reset_time(val: &str) -> String {
|
||||
match chrono::DateTime::parse_from_rfc3339(val) {
|
||||
Ok(dt) => {
|
||||
let delta = dt.with_timezone(&chrono::Utc) - chrono::Utc::now();
|
||||
let seconds = delta.num_seconds();
|
||||
if seconds <= 0 {
|
||||
"reset".to_owned()
|
||||
} else {
|
||||
format!("resets in {}", format_duration(seconds as u64))
|
||||
}
|
||||
}
|
||||
Err(_) => format!("resets at {val}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Port of kimi-cli `utils/datetime.py` `format_duration`: short units,
|
||||
/// seconds shown only for sub-minute durations.
|
||||
fn format_duration(seconds: u64) -> String {
|
||||
let days = seconds / 86_400;
|
||||
let hours = (seconds % 86_400) / 3_600;
|
||||
let minutes = (seconds % 3_600) / 60;
|
||||
let secs = seconds % 60;
|
||||
let mut parts = Vec::new();
|
||||
if days > 0 {
|
||||
parts.push(format!("{days}d"));
|
||||
}
|
||||
if hours > 0 {
|
||||
parts.push(format!("{hours}h"));
|
||||
}
|
||||
if minutes > 0 {
|
||||
parts.push(format!("{minutes}m"));
|
||||
}
|
||||
if secs > 0 && parts.is_empty() {
|
||||
parts.push(format!("{secs}s"));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"0s".to_owned()
|
||||
} else {
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_str(v: &serde_json::Value) -> Option<&str> {
|
||||
v.as_str().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Port of usage.py `_to_int`: ints and int-shaped floats/strings; anything
|
||||
/// else is `None`.
|
||||
fn to_int(value: Option<&serde_json::Value>) -> Option<i64> {
|
||||
let value = value?;
|
||||
if let Some(i) = value.as_i64() {
|
||||
return Some(i);
|
||||
}
|
||||
if let Some(f) = value.as_f64() {
|
||||
return Some(f as i64);
|
||||
}
|
||||
value.as_str()?.trim().parse::<i64>().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{bearer_token, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[test]
|
||||
fn auto_topup_disabled_rule_omits_enabled_field() {
|
||||
// proto3 JSON omits `false` / `0`, so a disabled rule arrives without
|
||||
// `enabled` (and zero Cents as `{}`). It must still deserialize (as
|
||||
// disabled) rather than erroring — otherwise the pager keeps a stale
|
||||
// cached rule.
|
||||
let json = serde_json::json!({
|
||||
"rule": { "topupAmount": {"val": 500}, "minBeforeHittingSl": {} }
|
||||
});
|
||||
let resp: GetAutoTopupRuleResponse = serde_json::from_value(json).unwrap();
|
||||
let rule = resp.rule.expect("rule present");
|
||||
assert!(!rule.enabled);
|
||||
assert_eq!(rule.topup_amount.unwrap().val, 500);
|
||||
assert_eq!(rule.min_before_hitting_sl.unwrap().val, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_response_deserializes_from_backend_json() {
|
||||
let json = serde_json::json!({
|
||||
"config": {
|
||||
"monthlyLimit": {"val": 2000},
|
||||
"used": {"val": 1234},
|
||||
"onDemandCap": {"val": 500},
|
||||
"billingPeriodStart": "2025-04-01T00:00:00Z",
|
||||
"billingPeriodEnd": "2025-05-01T00:00:00Z",
|
||||
"history": [
|
||||
/// Happy path: GET /usages with Bearer, kimi payload shape → rows with
|
||||
/// summary first, remaining-derived `used`, and window-derived labels.
|
||||
#[tokio::test]
|
||||
async fn fetch_usage_parses_kimi_payload() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/usages"))
|
||||
.and(bearer_token("tok-42"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"usage": { "limit": 1000, "used": 250, "reset_at": "2099-01-01T00:00:00Z" },
|
||||
"limits": [
|
||||
{
|
||||
"billingCycle": {"year": 2025, "month": 3},
|
||||
"includedUsed": {"val": 1800},
|
||||
"onDemandUsed": {"val": 0},
|
||||
"totalUsed": {"val": 1800}
|
||||
}
|
||||
"window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
|
||||
"detail": { "limit": 50, "remaining": 30, "resetIn": 1800 }
|
||||
},
|
||||
{ "name": "RPM", "limit": 60, "used": 12 }
|
||||
]
|
||||
}
|
||||
});
|
||||
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
|
||||
let config = resp.config.unwrap();
|
||||
assert_eq!(config.monthly_limit.unwrap().val, 2000);
|
||||
assert_eq!(config.used.unwrap().val, 1234);
|
||||
assert_eq!(config.on_demand_cap.unwrap().val, 500);
|
||||
assert_eq!(
|
||||
config.billing_period_start.as_deref(),
|
||||
Some("2025-04-01T00:00:00Z")
|
||||
);
|
||||
assert_eq!(config.history.len(), 1);
|
||||
let period = &config.history[0];
|
||||
let cycle = period.billing_cycle.as_ref().unwrap();
|
||||
assert_eq!(cycle.year, 2025);
|
||||
assert_eq!(cycle.month, 3);
|
||||
assert_eq!(period.included_used.as_ref().unwrap().val, 1800);
|
||||
assert_eq!(period.total_used.as_ref().unwrap().val, 1800);
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
#[test]
|
||||
fn billing_unified_log_ctx_includes_credits_and_collapses_history() {
|
||||
let resp = BillingConfigResponse {
|
||||
config: Some(BillingConfig {
|
||||
credit_usage_percent: Some(42.5),
|
||||
current_period: Some(UsagePeriod {
|
||||
period_type: Some("USAGE_PERIOD_TYPE_WEEKLY".into()),
|
||||
start: Some("2025-04-01T00:00:00Z".into()),
|
||||
end: Some("2025-04-08T00:00:00Z".into()),
|
||||
}),
|
||||
monthly_limit: Some(Cent { val: 2000 }),
|
||||
used: Some(Cent { val: 850 }),
|
||||
on_demand_cap: Some(Cent { val: 500 }),
|
||||
on_demand_used: Some(Cent { val: 0 }),
|
||||
prepaid_balance: Some(Cent { val: 100 }),
|
||||
is_unified_billing_user: Some(true),
|
||||
billing_period_start: None,
|
||||
billing_period_end: None,
|
||||
history: vec![
|
||||
BillingPeriodUsage {
|
||||
billing_cycle: Some(BillingCycle {
|
||||
year: 2025,
|
||||
month: 2,
|
||||
}),
|
||||
included_used: Some(Cent { val: 1000 }),
|
||||
on_demand_used: Some(Cent { val: 0 }),
|
||||
total_used: Some(Cent { val: 1000 }),
|
||||
},
|
||||
BillingPeriodUsage {
|
||||
billing_cycle: Some(BillingCycle {
|
||||
year: 2025,
|
||||
month: 3,
|
||||
}),
|
||||
included_used: Some(Cent { val: 1800 }),
|
||||
on_demand_used: Some(Cent { val: 0 }),
|
||||
total_used: Some(Cent { val: 1800 }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
on_demand_enabled: Some(true),
|
||||
subscription_tier: Some("SuperGrok".into()),
|
||||
};
|
||||
let ctx = billing_unified_log_ctx(&resp);
|
||||
assert_eq!(ctx["onDemandEnabled"], true);
|
||||
assert_eq!(ctx["subscriptionTier"], "SuperGrok");
|
||||
let config = ctx["config"].as_object().expect("config object");
|
||||
let usage = fetch_usage(&reqwest::Client::new(), &server.uri(), "tok-42")
|
||||
.await
|
||||
.expect("usage fetch should succeed");
|
||||
|
||||
assert_eq!(usage.rows.len(), 3);
|
||||
assert_eq!(usage.rows[0].label, "Weekly limit");
|
||||
assert_eq!(usage.rows[0].used, 250);
|
||||
assert_eq!(usage.rows[0].limit, 1000);
|
||||
assert!(
|
||||
config.get("history").is_none(),
|
||||
"full history must be collapsed"
|
||||
usage.rows[0]
|
||||
.reset_hint
|
||||
.as_deref()
|
||||
.is_some_and(|h| h.starts_with("resets in")),
|
||||
"absolute reset_at renders a relative hint: {:?}",
|
||||
usage.rows[0].reset_hint
|
||||
);
|
||||
assert_eq!(config["historyLen"], 2);
|
||||
assert_eq!(
|
||||
config["latestHistory"]["billingCycle"]["month"], 3,
|
||||
"latest history period retained"
|
||||
);
|
||||
assert_eq!(config["creditUsagePercent"], 42.5);
|
||||
assert_eq!(config["prepaidBalance"]["val"], 100);
|
||||
// 300 minutes → "5h limit"; used derived from remaining (50-30=20).
|
||||
assert_eq!(usage.rows[1].label, "5h limit");
|
||||
assert_eq!(usage.rows[1].used, 20);
|
||||
assert_eq!(usage.rows[1].limit, 50);
|
||||
assert_eq!(usage.rows[1].reset_hint.as_deref(), Some("resets in 30m"));
|
||||
// Item-level fields when there is no `detail` object.
|
||||
assert_eq!(usage.rows[2].label, "RPM");
|
||||
assert_eq!(usage.rows[2].used, 12);
|
||||
assert_eq!(usage.rows[2].limit, 60);
|
||||
}
|
||||
|
||||
/// Auth failure: 401 maps to the typed `Unauthorized` error (kimi-cli's
|
||||
/// "Authorization failed" path).
|
||||
#[tokio::test]
|
||||
async fn fetch_usage_maps_401_to_unauthorized() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/usages"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = fetch_usage(&reqwest::Client::new(), &server.uri(), "bad")
|
||||
.await
|
||||
.expect_err("401 must fail");
|
||||
assert!(matches!(err, UsageError::Unauthorized));
|
||||
}
|
||||
|
||||
/// 404 maps to the "endpoint not available" error (kimi-cli parity).
|
||||
#[tokio::test]
|
||||
async fn fetch_usage_maps_404_to_not_found() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/usages"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = fetch_usage(&reqwest::Client::new(), &server.uri(), "tok")
|
||||
.await
|
||||
.expect_err("404 must fail");
|
||||
assert!(matches!(err, UsageError::NotFound));
|
||||
}
|
||||
|
||||
/// Empty payload parses to zero rows (TUI shows "No usage data").
|
||||
#[test]
|
||||
fn parse_usage_payload_empty_object_yields_no_rows() {
|
||||
let usage = parse_usage_payload(&serde_json::json!({}));
|
||||
assert!(usage.rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_response_roundtrips_through_json() {
|
||||
let config = BillingConfig {
|
||||
credit_usage_percent: None,
|
||||
current_period: None,
|
||||
monthly_limit: Some(Cent { val: 5000 }),
|
||||
used: Some(Cent { val: 123 }),
|
||||
on_demand_cap: Some(Cent { val: 0 }),
|
||||
on_demand_used: Some(Cent { val: 50 }),
|
||||
prepaid_balance: Some(Cent { val: 750 }),
|
||||
is_unified_billing_user: None,
|
||||
billing_period_start: Some("2025-04-01T00:00:00Z".to_string()),
|
||||
billing_period_end: Some("2025-05-01T00:00:00Z".to_string()),
|
||||
history: vec![BillingPeriodUsage {
|
||||
billing_cycle: Some(BillingCycle {
|
||||
year: 2025,
|
||||
month: 3,
|
||||
}),
|
||||
included_used: Some(Cent { val: 4500 }),
|
||||
on_demand_used: Some(Cent { val: 100 }),
|
||||
total_used: Some(Cent { val: 4600 }),
|
||||
}],
|
||||
};
|
||||
let resp = BillingConfigResponse {
|
||||
config: Some(config),
|
||||
on_demand_enabled: None,
|
||||
subscription_tier: None,
|
||||
};
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
let roundtripped: BillingConfigResponse = serde_json::from_value(json).unwrap();
|
||||
let rt_config = roundtripped.config.unwrap();
|
||||
assert_eq!(rt_config.monthly_limit.unwrap().val, 5000);
|
||||
assert_eq!(rt_config.used.unwrap().val, 123);
|
||||
assert_eq!(rt_config.prepaid_balance.unwrap().val, 750);
|
||||
assert_eq!(rt_config.history.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_response_handles_null_config() {
|
||||
let json = serde_json::json!({"config": null});
|
||||
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
|
||||
assert!(resp.config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_response_handles_empty_history() {
|
||||
let json = serde_json::json!({
|
||||
"config": {
|
||||
"monthlyLimit": {"val": 1000},
|
||||
"used": {"val": 0}
|
||||
}
|
||||
});
|
||||
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
|
||||
let config = resp.config.unwrap();
|
||||
assert_eq!(config.monthly_limit.unwrap().val, 1000);
|
||||
assert!(config.history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_serializes_camel_case() {
|
||||
let config = BillingConfig {
|
||||
credit_usage_percent: None,
|
||||
current_period: None,
|
||||
monthly_limit: Some(Cent { val: 100 }),
|
||||
used: None,
|
||||
on_demand_cap: None,
|
||||
on_demand_used: None,
|
||||
prepaid_balance: None,
|
||||
is_unified_billing_user: None,
|
||||
billing_period_start: None,
|
||||
billing_period_end: None,
|
||||
history: vec![],
|
||||
};
|
||||
let json = serde_json::to_value(&config).unwrap();
|
||||
assert!(json.get("monthlyLimit").is_some());
|
||||
// Fields with None are skipped
|
||||
assert!(json.get("creditUsagePercent").is_none());
|
||||
assert!(json.get("currentPeriod").is_none());
|
||||
assert!(json.get("used").is_none());
|
||||
assert!(json.get("onDemandCap").is_none());
|
||||
assert!(json.get("onDemandUsed").is_none());
|
||||
assert!(json.get("prepaidBalance").is_none());
|
||||
assert!(json.get("billingPeriodStart").is_none());
|
||||
// Empty history is skipped
|
||||
assert!(json.get("history").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_deserializes_credits_config_shape() {
|
||||
// Newer `GetGrokCreditsConfig` response: percentage-based usage,
|
||||
// a typed current period, and history keyed by `period`.
|
||||
let json = serde_json::json!({
|
||||
"config": {
|
||||
"creditUsagePercent": 42.5,
|
||||
"currentPeriod": {
|
||||
"type": "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
"start": "2026-06-01T00:00:00Z",
|
||||
"end": "2026-06-08T00:00:00Z"
|
||||
},
|
||||
"onDemandCap": {"val": 5000},
|
||||
"onDemandUsed": {"val": 300},
|
||||
"prepaidBalance": {"val": 1250},
|
||||
"isUnifiedBillingUser": true,
|
||||
"productUsage": [
|
||||
{"product": "PRODUCT_GROK_BUILD", "usagePercent": 61.2}
|
||||
],
|
||||
"history": [
|
||||
{
|
||||
"period": {
|
||||
"type": "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
"start": "2026-05-25T00:00:00Z",
|
||||
"end": "2026-06-01T00:00:00Z"
|
||||
},
|
||||
"onDemandUsed": {"val": 120}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
|
||||
let config = resp.config.unwrap();
|
||||
assert_eq!(config.credit_usage_percent, Some(42.5));
|
||||
let period = config.current_period.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
period.period_type.as_deref(),
|
||||
Some("USAGE_PERIOD_TYPE_WEEKLY")
|
||||
);
|
||||
assert_eq!(period.end.as_deref(), Some("2026-06-08T00:00:00Z"));
|
||||
// Deprecated fields are absent in the credits shape.
|
||||
assert!(config.monthly_limit.is_none());
|
||||
assert!(config.billing_period_end.is_none());
|
||||
assert_eq!(config.on_demand_cap.unwrap().val, 5000);
|
||||
assert_eq!(config.on_demand_used.unwrap().val, 300);
|
||||
// Bought (prepaid) credit balance is parsed from the credits config.
|
||||
assert_eq!(config.prepaid_balance.unwrap().val, 1250);
|
||||
assert_eq!(config.is_unified_billing_user, Some(true));
|
||||
// productUsage is still unused by the CLI billing surface.
|
||||
assert_eq!(config.history.len(), 1);
|
||||
assert_eq!(config.history[0].on_demand_used.as_ref().unwrap().val, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cent_serializes_as_val_field() {
|
||||
let c = Cent { val: 4299 };
|
||||
let json = serde_json::to_value(&c).unwrap();
|
||||
assert_eq!(json, serde_json::json!({"val": 4299}));
|
||||
fn format_duration_matches_kimi_semantics() {
|
||||
assert_eq!(format_duration(0), "0s");
|
||||
assert_eq!(format_duration(45), "45s");
|
||||
assert_eq!(format_duration(90), "1m");
|
||||
assert_eq!(format_duration(3_661), "1h 1m");
|
||||
assert_eq!(format_duration(90_000), "1d 1h");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
//! `x.ai/feedback`, `x.ai/feedback/dismiss`, `x.ai/btw`, and `x.ai/review/*`
|
||||
//! extension handlers.
|
||||
//!
|
||||
//! - `feedback`/`feedback/dismiss`: persist user ratings/text locally and
|
||||
//! forward to cli-chat-proxy.
|
||||
//! - `feedback`/`feedback/dismiss`: persist user ratings/text locally; text
|
||||
//! feedback from subscription (OAuth) sessions is forwarded to the Kimi
|
||||
//! Code feedback endpoint (`POST {base}/feedback`, kimi-cli slash.py
|
||||
//! parity). Without a subscription session the record stays local and the
|
||||
//! response points at the GitHub issue tracker.
|
||||
//! - `btw`: dispatch a side question to the active session via
|
||||
//! `SessionCommand::SideQuestion` and return the answer.
|
||||
//! - `review/comment` and `review/comment/delete`: record inline code review
|
||||
//! events to cloud storage.
|
||||
//! events locally.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -15,6 +18,7 @@ use tokio::sync::oneshot;
|
||||
|
||||
use super::{ExtResult, parse_params};
|
||||
use crate::agent::MvpAgent;
|
||||
use crate::agent::feedback_client::FEEDBACK_ISSUES_URL;
|
||||
use crate::session::persistence::{LocalFeedbackEntry, UserFeedbackEntry};
|
||||
use crate::session::{
|
||||
ClientFeedbackInput, CommentDeleteRequest, CommentDeleteResponse, CommentRequest,
|
||||
@@ -34,7 +38,7 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
}
|
||||
m if m.starts_with("x.ai/review") => {
|
||||
tracing::info!("handling review comment");
|
||||
handle_review(agent, args).await
|
||||
handle_review(args).await
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
@@ -97,8 +101,7 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
|
||||
let simple: crate::session::FeedbackRequest = parse_params(args)?;
|
||||
ClientFeedbackInput {
|
||||
session_id: simple.session_id,
|
||||
client_type:
|
||||
prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Tui,
|
||||
client_type: crate::session::feedback_types::ClientType::Tui,
|
||||
rating_type: None,
|
||||
rating_value: None,
|
||||
feedback_text: Some(simple.feedback_text),
|
||||
@@ -151,7 +154,7 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
|
||||
submission.merge_metadata(user_meta);
|
||||
}
|
||||
|
||||
// Enrich with session context for Slack notifications (best-effort).
|
||||
// Enrich with session context (persisted alongside the record).
|
||||
if let Some(ref session_handle) = session_handle {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let _ = session_handle
|
||||
@@ -174,7 +177,7 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
|
||||
if let (Some(session_handle), Some(rating_value)) =
|
||||
(&session_handle, feedback_input.rating_value)
|
||||
{
|
||||
use prod_mc_cli_chat_proxy_types::feedback_types::RatingType;
|
||||
use crate::session::feedback_types::RatingType;
|
||||
let (is_positive, is_negative) = match feedback_input.rating_type {
|
||||
// Thumbs: -1 = down, 0 = neutral, 1 = up
|
||||
Some(RatingType::Thumbs) | None => (rating_value > 0, rating_value < 0),
|
||||
@@ -208,8 +211,8 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
|
||||
|
||||
let client = agent.feedback_client();
|
||||
if client.is_none() {
|
||||
tracing::warn!(
|
||||
"no feedback client available (missing proxy credentials); feedback saved locally only"
|
||||
tracing::info!(
|
||||
"no subscription session; feedback saved locally — submit at {FEEDBACK_ISSUES_URL}"
|
||||
);
|
||||
}
|
||||
let outcome = crate::session::feedback_manager::submit_feedback_workflow(
|
||||
@@ -222,15 +225,17 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
|
||||
|
||||
match &outcome {
|
||||
crate::session::feedback_manager::SubmitOutcome::Submitted => {
|
||||
tracing::info!("feedback submitted to proxy successfully");
|
||||
tracing::info!("feedback submitted to the Kimi Code endpoint");
|
||||
}
|
||||
crate::session::feedback_manager::SubmitOutcome::LocalOnly => {
|
||||
tracing::warn!("feedback saved locally only (no proxy client)");
|
||||
tracing::info!("feedback saved locally only");
|
||||
}
|
||||
crate::session::feedback_manager::SubmitOutcome::Failed(e) => {
|
||||
tracing::error!(error = %e, "feedback submission to proxy failed");
|
||||
return Err(acp::Error::internal_error()
|
||||
.data(format!("Feedback submission failed: {e}")));
|
||||
tracing::error!(error = %e, "feedback submission failed");
|
||||
return Err(acp::Error::internal_error().data(format!(
|
||||
"Feedback submission failed: {e}. \
|
||||
Please submit feedback at {FEEDBACK_ISSUES_URL}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,36 +286,14 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
|
||||
}
|
||||
}
|
||||
|
||||
let request_id = dismiss_input.request_id.clone();
|
||||
let client = agent
|
||||
.feedback_client()
|
||||
.ok_or_else(|| acp::Error::internal_error().data("No credentials for feedback"))?;
|
||||
let feedback_base_url = agent.cfg.borrow().endpoints.resolve_feedback_base_url();
|
||||
match client.dismiss_request(&request_id).await {
|
||||
Ok(response) => {
|
||||
tracing::info!(
|
||||
request_id = %response.request_id,
|
||||
status = %response.status,
|
||||
feedback_url = %feedback_base_url,
|
||||
"Feedback request dismissed"
|
||||
);
|
||||
let value = serde_json::to_value(&response)
|
||||
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
request_id = %request_id,
|
||||
feedback_url = %feedback_base_url,
|
||||
"Failed to dismiss feedback request"
|
||||
);
|
||||
Err(acp::Error::internal_error()
|
||||
.data(format!("Failed to dismiss feedback request: {e}")))
|
||||
}
|
||||
}
|
||||
let value = serde_json::to_value(serde_json::json!({
|
||||
"requestId": dismiss_input.request_id,
|
||||
"status": "dismissed",
|
||||
}))
|
||||
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
@@ -319,9 +302,9 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
|
||||
/// Record inline code review events.
|
||||
///
|
||||
/// Methods:
|
||||
/// - `x.ai/review/comment`: record a new inline code comment to cloud storage
|
||||
/// - `x.ai/review/comment`: record a new inline code comment
|
||||
/// - `x.ai/review/comment/delete`: record a tombstone event for a deleted comment
|
||||
async fn handle_review(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
async fn handle_review(args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/review/comment" => {
|
||||
let request: CommentRequest = parse_params(args)?;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod auth;
|
||||
pub(crate) mod auth_gate;
|
||||
pub mod billing;
|
||||
pub mod bundle;
|
||||
pub mod chat_conversation_history;
|
||||
pub mod code_nav;
|
||||
pub mod debug;
|
||||
@@ -28,7 +27,6 @@ pub mod search;
|
||||
pub mod session_admin;
|
||||
pub mod session_search;
|
||||
pub mod session_updates;
|
||||
pub mod share;
|
||||
pub mod skills;
|
||||
pub mod suggest;
|
||||
pub mod task;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! persistent or shared agent state but are not part of the per-turn prompt
|
||||
//! lifecycle:
|
||||
//!
|
||||
//! - `x.ai/session/rename` rename a session locally + remote
|
||||
//! - `x.ai/session/delete` delete a session locally + remote
|
||||
//! - `x.ai/session/rename` rename a session locally
|
||||
//! - `x.ai/session/delete` delete a session locally
|
||||
//! - `x.ai/session/update_mcp_servers` mid-session MCP server swap
|
||||
//! - `x.ai/session/fork` fork a session into a new one
|
||||
//! - `x.ai/internal/reload_all_mcp_servers` config hot-reload, all sessions
|
||||
@@ -77,7 +77,8 @@ async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
|
||||
}
|
||||
|
||||
if req.kind == SessionKind::Chat {
|
||||
return rename_chat_conversation(agent, &req.session_id, &req.title).await;
|
||||
return Err(acp::Error::invalid_request()
|
||||
.data("chat conversations are not available in kigi (local sessions only)"));
|
||||
}
|
||||
|
||||
let session_id = acp::SessionId::new(Arc::from(req.session_id.as_str()));
|
||||
@@ -111,22 +112,6 @@ async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
|
||||
// Send a SessionSummaryGenerated notification so the TUI updates its title
|
||||
notify_session_title(agent, session_id, &req.title).await;
|
||||
|
||||
if agent.is_writeback_storage() && agent.current_auth().is_some() {
|
||||
use crate::remote::client::BackendClient;
|
||||
use crate::session::export::ExportedMetadata;
|
||||
|
||||
let mut metadata = ExportedMetadata::from_summary(summary);
|
||||
metadata.title = Some(req.title.clone());
|
||||
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
if let Err(e) = BackendClient::new()
|
||||
.with_auth_manager(agent.auth_manager.clone())
|
||||
.save_session_data(&req.session_id, &[], Some(&metadata))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, session_id = %req.session_id, "failed to sync renamed title to backend");
|
||||
}
|
||||
}
|
||||
|
||||
// Hook 2: update session replica with summary (fire-and-forget)
|
||||
if let Some(client) = agent.session_registry_client() {
|
||||
let sid = req.session_id.to_string();
|
||||
@@ -169,49 +154,6 @@ async fn notify_session_title(agent: &MvpAgent, session_id: acp::SessionId, titl
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename_chat_conversation(
|
||||
agent: &MvpAgent,
|
||||
conversation_id: &str,
|
||||
title: &str,
|
||||
) -> ExtResult {
|
||||
use crate::remote::{ConvError, UpdateConversationBody};
|
||||
|
||||
let Some(client) = agent.conversations_client() else {
|
||||
return Err(acp::Error::invalid_request()
|
||||
.data("chat session rename requires the conversations lane (OIDC + chat feature)"));
|
||||
};
|
||||
|
||||
let body = UpdateConversationBody {
|
||||
title: Some(title.to_owned()),
|
||||
starred: None,
|
||||
};
|
||||
client
|
||||
.update_conversation(conversation_id, &body)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
ConvError::NoOauth => acp::Error::invalid_request()
|
||||
.data("chat session rename requires xAI OAuth credentials"),
|
||||
ConvError::Http { status: 404 } => acp::Error::invalid_request()
|
||||
.data(format!("conversation not found: {conversation_id}")),
|
||||
other => acp::Error::internal_error()
|
||||
.data(format!("chat conversation rename failed: {other}")),
|
||||
})?;
|
||||
|
||||
// If this conversation is open live, notify clients of the new title.
|
||||
let session_id = acp::SessionId::new(Arc::from(conversation_id));
|
||||
if agent.sessions.borrow().contains_key(&session_id) {
|
||||
notify_session_title(agent, session_id, title).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
session_id = %conversation_id,
|
||||
title = %title,
|
||||
"Chat conversation renamed"
|
||||
);
|
||||
|
||||
to_raw_response(&serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
// session/delete
|
||||
|
||||
/// Delete a session from history.
|
||||
@@ -229,31 +171,17 @@ async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
|
||||
let req: DeleteRequest = parse_params(args)?;
|
||||
|
||||
if req.kind == SessionKind::Chat {
|
||||
return soft_delete_chat_conversation(agent, &req.session_id).await;
|
||||
return Err(acp::Error::invalid_request()
|
||||
.data("chat conversations are not available in kigi (local sessions only)"));
|
||||
}
|
||||
|
||||
let session_id = acp::SessionId::new(Arc::from(req.session_id.as_str()));
|
||||
|
||||
// For writeback storage (non-ZDR): remote delete is authoritative for
|
||||
// the cloud history and runs first; on failure no local bits are
|
||||
// touched so the pager does not remove the row or toast success.
|
||||
let needs_remote = agent.is_writeback_storage() && agent.current_auth().is_some();
|
||||
|
||||
// Shared delete: remote-first, then local disk + FTS eviction.
|
||||
// Mirrored by the `grok sessions delete <id>` CLI path.
|
||||
crate::session::persistence::delete_session_history(
|
||||
&req.session_id,
|
||||
req.cwd.as_deref(),
|
||||
needs_remote,
|
||||
agent.auth_manager.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let crate::session::persistence::DeleteSessionError::Remote(_) = &e {
|
||||
tracing::warn!(?e, session_id = %req.session_id, "failed to delete remote session data");
|
||||
}
|
||||
acp::Error::internal_error().data(e.to_string())
|
||||
})?;
|
||||
// Local disk + FTS eviction. Mirrored by the `kigi sessions delete <id>`
|
||||
// CLI path.
|
||||
crate::session::persistence::delete_session_history(&req.session_id, req.cwd.as_deref())
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
|
||||
// If an in-memory live session exists for this id (e.g. the user
|
||||
// deleted history for a session that is still open in another agent
|
||||
@@ -269,35 +197,6 @@ async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
|
||||
to_raw_response(&serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
async fn soft_delete_chat_conversation(agent: &MvpAgent, conversation_id: &str) -> ExtResult {
|
||||
use crate::remote::ConvError;
|
||||
|
||||
let Some(client) = agent.conversations_client() else {
|
||||
return Err(acp::Error::invalid_request()
|
||||
.data("chat session delete requires the conversations lane (OIDC + chat feature)"));
|
||||
};
|
||||
|
||||
client
|
||||
.soft_delete_conversation(conversation_id)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
ConvError::NoOauth => acp::Error::invalid_request()
|
||||
.data("chat session delete requires xAI OAuth credentials"),
|
||||
other => acp::Error::internal_error()
|
||||
.data(format!("chat conversation soft-delete failed: {other}")),
|
||||
})?;
|
||||
|
||||
let session_id = acp::SessionId::new(Arc::from(conversation_id));
|
||||
if agent.sessions.borrow().contains_key(&session_id) {
|
||||
agent.request_session_shutdown(&session_id);
|
||||
agent.remove_session(&session_id);
|
||||
}
|
||||
|
||||
tracing::info!(session_id = %conversation_id, "Chat conversation soft-deleted");
|
||||
|
||||
to_raw_response(&serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
// session/update_mcp_servers
|
||||
|
||||
async fn handle_update_mcp_servers(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
@@ -708,8 +607,7 @@ async fn handle_session_fork(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtRes
|
||||
|
||||
let request: ForkSessionRequest = parse_params(args)?;
|
||||
|
||||
let agent_id = crate::util::agent_id::agent_id();
|
||||
let response = fork_session(request, &agent_id, Some(agent.auth_manager.clone()))
|
||||
let response = fork_session(request)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
//! `x.ai/share_session` extension handler.
|
||||
//!
|
||||
//! Loads a local session, exports it, and asks the backend for a public
|
||||
//! share URL.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
use super::{ExtResult, parse_params, to_raw_response};
|
||||
use crate::agent::MvpAgent;
|
||||
use crate::remote::client::BackendClient;
|
||||
use crate::session::export::ExportedSession;
|
||||
use crate::session::info::Info as SessionInfo;
|
||||
use crate::session::persistence::list_summaries;
|
||||
use crate::session::share::{ShareSessionRequest, ShareSessionResponse};
|
||||
|
||||
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/share_session" => {
|
||||
tracing::info!("handling share session request");
|
||||
handle_share_session(agent, args).await
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_share_session(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
let request: ShareSessionRequest = parse_params(args)?;
|
||||
|
||||
// Get auth - required for sharing.
|
||||
let auth = require_xai_auth_for_share(&agent.auth_manager)?;
|
||||
|
||||
// Remote settings / feature-flag gate: sharing_enabled defaults to false
|
||||
// and is only enabled for eligible accounts.
|
||||
let sharing_enabled = agent
|
||||
.cfg
|
||||
.borrow()
|
||||
.remote_settings
|
||||
.as_ref()
|
||||
.and_then(|rs| rs.sharing_enabled)
|
||||
.unwrap_or(false);
|
||||
if !sharing_enabled {
|
||||
return Err(
|
||||
acp::Error::invalid_params().data("Session sharing is not available for your account.")
|
||||
);
|
||||
}
|
||||
|
||||
// Find session info by searching through summaries
|
||||
let summaries = list_summaries(None).await.map_err(|e| {
|
||||
acp::Error::internal_error().data(format!("Failed to list sessions: {}", e))
|
||||
})?;
|
||||
|
||||
let summary = summaries
|
||||
.iter()
|
||||
.find(|s| s.info.id.0.as_ref() == request.session_id.as_str())
|
||||
.ok_or_else(|| acp::Error::resource_not_found(Some("Session not found".into())))?;
|
||||
|
||||
let info = SessionInfo {
|
||||
id: acp::SessionId::new(request.session_id.clone()),
|
||||
cwd: summary.info.cwd.clone(),
|
||||
};
|
||||
|
||||
// Load and export session
|
||||
let exported = ExportedSession::from_local_session(&info)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("Failed to load session: {}", e)))?;
|
||||
|
||||
// Check for empty session
|
||||
if exported.messages.is_empty() {
|
||||
return Err(acp::Error::invalid_params().data("No messages to share yet"));
|
||||
}
|
||||
|
||||
// Upload to backend and get share URL.
|
||||
let client = BackendClient::new().with_auth_manager(agent.auth_manager.clone());
|
||||
let agent_id = crate::util::agent_id::agent_id();
|
||||
let share_url = client
|
||||
.share_session(&exported, &agent_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to share session with backend");
|
||||
acp::Error::internal_error().data(format!("Failed to share session: {}", e))
|
||||
})?;
|
||||
|
||||
let response = ShareSessionResponse { share_url };
|
||||
to_raw_response(&response)
|
||||
}
|
||||
|
||||
fn require_xai_auth_for_share(
|
||||
auth_manager: &crate::auth::AuthManager,
|
||||
) -> Result<crate::auth::KimiAuth, acp::Error> {
|
||||
super::auth_gate::require_xai_auth(
|
||||
auth_manager,
|
||||
"Authentication required to share session",
|
||||
"Share session is disabled. Run `grok login` to authenticate.",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::KimiCodeConfig;
|
||||
use crate::auth::{AuthMode, KimiAuth};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn make_auth_manager_with_token_expiring_in(
|
||||
ttl: Duration,
|
||||
) -> (Arc<crate::auth::AuthManager>, tempfile::TempDir) {
|
||||
let dir = tempdir().expect("tempdir for share auth test");
|
||||
let mgr = Arc::new(crate::auth::AuthManager::new(
|
||||
dir.path(),
|
||||
KimiCodeConfig::default(),
|
||||
));
|
||||
|
||||
let expires_at = Utc::now() + ttl;
|
||||
|
||||
// We must explicitly set oidc_issuer to a first-party xAI issuer.
|
||||
// Only OIDC tokens against https://auth.x.ai (or the local-dev equivalent)
|
||||
// return true from is_xai_auth(). This is required for the share tests to
|
||||
// exercise the happy path through require_xai_auth_for_share.
|
||||
let auth = KimiAuth {
|
||||
auth_mode: AuthMode::OAuth,
|
||||
key: "test-key".into(),
|
||||
expires_at: Some(expires_at),
|
||||
create_time: Utc::now() - Duration::hours(1),
|
||||
..Default::default()
|
||||
};
|
||||
mgr.hot_swap(auth);
|
||||
(mgr, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_works_outside_the_5m_early_invalidation_window() {
|
||||
let (mgr, _dir) = make_auth_manager_with_token_expiring_in(Duration::minutes(10));
|
||||
assert!(mgr.current().is_some());
|
||||
assert!(require_xai_auth_for_share(&mgr).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_succeeds_inside_the_5m_early_invalidation_window() {
|
||||
let (mgr, _dir) = make_auth_manager_with_token_expiring_in(Duration::seconds(1));
|
||||
// This is exactly the state that triggered the user bug:
|
||||
assert!(
|
||||
mgr.current().is_none(),
|
||||
"current() drops the token inside the buffer"
|
||||
);
|
||||
assert!(mgr.expired_auth().is_some());
|
||||
|
||||
// Now that we use current_or_expired(), this passes.
|
||||
let res = require_xai_auth_for_share(&mgr);
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"require_xai_auth_for_share must succeed for a still-valid buffered xAI token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_fails_with_no_auth_at_all() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let mgr = Arc::new(crate::auth::AuthManager::new(
|
||||
dir.path(),
|
||||
KimiCodeConfig::default(),
|
||||
));
|
||||
assert!(require_xai_auth_for_share(&mgr).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_rejects_non_xai_auth_with_actionable_grok_login_message() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let mgr = Arc::new(crate::auth::AuthManager::new(
|
||||
dir.path(),
|
||||
KimiCodeConfig::default(),
|
||||
));
|
||||
|
||||
// API key is the simplest non-xAI credential (External and enterprise OIDC
|
||||
// are also rejected the same way).
|
||||
let non_xai = KimiAuth {
|
||||
auth_mode: AuthMode::ApiKey,
|
||||
key: "xai-test-key".into(),
|
||||
create_time: Utc::now(),
|
||||
..Default::default()
|
||||
};
|
||||
mgr.hot_swap(non_xai);
|
||||
|
||||
let err = require_xai_auth_for_share(&mgr)
|
||||
.expect_err("non-xAI accounts (API key, External, enterprise IdP) must be rejected");
|
||||
|
||||
// This is the key assertion the review asked for: we must test the *exact*
|
||||
// actionable data string for the non-xAI path (distinct from the generic
|
||||
// "Authentication required to share session" path).
|
||||
let serialized =
|
||||
serde_json::to_value(&err).expect("acp::Error serializes to JSON-RPC shape");
|
||||
let data = serialized
|
||||
.get("data")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("auth_required error carries a data string");
|
||||
|
||||
assert_eq!(
|
||||
data,
|
||||
"Share session is disabled. Run `grok login` to authenticate."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ pub mod managed_config;
|
||||
pub mod mcp_doctor;
|
||||
pub use kigi_models as models;
|
||||
pub mod plugin;
|
||||
pub mod remote;
|
||||
pub mod sampling;
|
||||
pub mod session;
|
||||
pub mod terminal;
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
//! Remote sandbox client for cli-chat-proxy.
|
||||
//!
|
||||
//! This module provides an HTTP client to interact with cli-chat-proxy
|
||||
//! for managing sandbox sessions and environments via REST API.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::{AuthManager, KimiCodeConfig};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
// Re-export sandbox API types from cli-chat-proxy-types for convenience.
|
||||
// Sorted alphabetically; see sandbox_types.rs for logical grouping.
|
||||
pub use prod_mc_cli_chat_proxy_types::{
|
||||
SandboxCreateEnvironmentRequest, SandboxEnvironment, SandboxEnvironmentResponse,
|
||||
SandboxEnvironmentVariable, SandboxEnvironmentWithMetadata, SandboxForkRequest,
|
||||
SandboxForkResponse, SandboxForkedSession, SandboxHibernateResponse,
|
||||
SandboxListEnvironmentsRequest, SandboxListEnvironmentsResponse,
|
||||
SandboxListPreinstalledPackagesResponse, SandboxLogsExitCodes, SandboxLogsResponse,
|
||||
SandboxMode, SandboxPreinstalledPackage, SandboxRestoreRequest, SandboxRestoreResponse,
|
||||
SandboxSecretInput, SandboxStartRequest, SandboxStartResponse, SandboxStatusResponse,
|
||||
SandboxTerminateRequest, SandboxUpdateEnvironmentRequest,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sandbox Client
|
||||
// ============================================================================
|
||||
|
||||
/// HTTP client for interacting with the sandbox API via cli-chat-proxy.
|
||||
///
|
||||
/// Path parameters (`session_id`, `environment_id`) are interpolated directly
|
||||
/// into URLs without percent-encoding. This is safe because these IDs are
|
||||
/// UUIDs in practice. If ID formats ever change to include URL-unsafe
|
||||
/// characters, the `format!()` calls should be updated to use percent-encoding.
|
||||
pub struct SandboxClient {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
}
|
||||
|
||||
impl SandboxClient {
|
||||
pub fn new(base_url: impl Into<String>, auth_manager: Arc<AuthManager>) -> Self {
|
||||
Self {
|
||||
client: crate::http::shared_client(),
|
||||
base_url: base_url.into(),
|
||||
auth_manager,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the base URL.
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
// Do not set Content-Type — callers use .json() and reqwest .header() appends.
|
||||
async fn auth_headers(
|
||||
&self,
|
||||
builder: reqwest::RequestBuilder,
|
||||
) -> Result<reqwest::RequestBuilder> {
|
||||
let auth = self
|
||||
.auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.context("failed to resolve sandbox auth")?;
|
||||
let mut builder = builder
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header("x-userid", &auth.user_id)
|
||||
.header("x-grok-client-version", kigi_version::VERSION);
|
||||
|
||||
if let Some(email) = &auth.email {
|
||||
builder = builder.header("x-email", email);
|
||||
}
|
||||
|
||||
builder = builder
|
||||
.header(
|
||||
"x-grok-client-identifier",
|
||||
crate::http::process_client_identifier(),
|
||||
)
|
||||
.header(
|
||||
crate::http::CLIENT_MODE_HEADER,
|
||||
crate::http::process_client_mode(),
|
||||
);
|
||||
|
||||
Ok(kigi_file_utils::trace_context::inject_trace_context_into_request(builder))
|
||||
}
|
||||
|
||||
/// Check an HTTP response for errors, then deserialize the JSON body.
|
||||
async fn parse_response<T: DeserializeOwned>(
|
||||
response: reqwest::Response,
|
||||
operation: &str,
|
||||
) -> Result<T> {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!("{operation} failed: {status} - {body}");
|
||||
}
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.with_context(|| format!("failed to parse {operation} response"))
|
||||
}
|
||||
|
||||
/// Check an HTTP response for errors, discarding the body.
|
||||
async fn check_response(response: reqwest::Response, operation: &str) -> Result<()> {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!("{operation} failed: {status} - {body}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fork an existing sandbox session.
|
||||
pub async fn fork_session(&self, request: &SandboxForkRequest) -> Result<SandboxForkResponse> {
|
||||
let url = format!("{}/sandbox/sessions/fork", self.base_url);
|
||||
let response = self
|
||||
.auth_headers(self.client.post(&url))
|
||||
.await?
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send fork session request")?;
|
||||
Self::parse_response(response, "fork session").await
|
||||
}
|
||||
|
||||
/// Terminate a sandbox session.
|
||||
pub async fn terminate_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
request: &SandboxTerminateRequest,
|
||||
) -> Result<()> {
|
||||
let mut url = format!("{}/sandbox/sessions/{}", self.base_url, session_id);
|
||||
if let Some(env_id) = &request.environment_id {
|
||||
url = format!("{}?environmentId={}", url, env_id);
|
||||
}
|
||||
|
||||
let response = self
|
||||
.auth_headers(self.client.delete(&url))
|
||||
.await?
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send terminate session request")?;
|
||||
|
||||
if response.status().as_u16() == 404 {
|
||||
bail!("session not found: {session_id}");
|
||||
}
|
||||
Self::check_response(response, "terminate session").await
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Session Lifecycle
|
||||
// ========================================================================
|
||||
|
||||
/// Start a sandbox session (non-TUI).
|
||||
pub async fn start_session(
|
||||
&self,
|
||||
request: &SandboxStartRequest,
|
||||
) -> Result<SandboxStartResponse> {
|
||||
let url = format!("{}/sandbox/sessions/start", self.base_url);
|
||||
let response = self
|
||||
.auth_headers(self.client.post(&url))
|
||||
.await?
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send start session request")?;
|
||||
Self::parse_response(response, "start session").await
|
||||
}
|
||||
|
||||
/// Get sandbox session status.
|
||||
pub async fn get_session_status(&self, session_id: &str) -> Result<SandboxStatusResponse> {
|
||||
let url = format!("{}/sandbox/sessions/{}/status", self.base_url, session_id);
|
||||
let response = self
|
||||
.auth_headers(self.client.get(&url))
|
||||
.await?
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send get session status request")?;
|
||||
Self::parse_response(response, "get session status").await
|
||||
}
|
||||
|
||||
/// Get sandbox session logs.
|
||||
pub async fn get_session_logs(&self, session_id: &str) -> Result<SandboxLogsResponse> {
|
||||
let url = format!("{}/sandbox/sessions/{}/logs", self.base_url, session_id);
|
||||
let response = self
|
||||
.auth_headers(self.client.get(&url))
|
||||
.await?
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send get session logs request")?;
|
||||
Self::parse_response(response, "get session logs").await
|
||||
}
|
||||
|
||||
/// Hibernate a sandbox session (snapshot rootfs to GCS and terminate).
|
||||
pub async fn hibernate_session(&self, session_id: &str) -> Result<SandboxHibernateResponse> {
|
||||
let url = format!(
|
||||
"{}/sandbox/sessions/{}/hibernate",
|
||||
self.base_url, session_id
|
||||
);
|
||||
let response = self
|
||||
.auth_headers(self.client.post(&url))
|
||||
.await?
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send hibernate session request")?;
|
||||
Self::parse_response(response, "hibernate session").await
|
||||
}
|
||||
|
||||
/// Restore a previously hibernated sandbox session from its snapshot.
|
||||
pub async fn restore_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
request: &SandboxRestoreRequest,
|
||||
) -> Result<SandboxRestoreResponse> {
|
||||
let url = format!("{}/sandbox/sessions/{}/restore", self.base_url, session_id);
|
||||
let response = self
|
||||
.auth_headers(self.client.post(&url))
|
||||
.await?
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send restore session request")?;
|
||||
Self::parse_response(response, "restore session").await
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Environment CRUD
|
||||
// ========================================================================
|
||||
|
||||
/// List sandbox environments.
|
||||
pub async fn list_environments(
|
||||
&self,
|
||||
request: &SandboxListEnvironmentsRequest,
|
||||
) -> Result<SandboxListEnvironmentsResponse> {
|
||||
let url = format!("{}/sandbox/environments", self.base_url);
|
||||
let mut builder = self.auth_headers(self.client.get(&url)).await?;
|
||||
if let Some(page) = request.page {
|
||||
builder = builder.query(&[("page", page)]);
|
||||
}
|
||||
if let Some(page_size) = request.page_size {
|
||||
builder = builder.query(&[("pageSize", page_size)]);
|
||||
}
|
||||
let response = builder
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send list environments request")?;
|
||||
Self::parse_response(response, "list environments").await
|
||||
}
|
||||
|
||||
/// Create a new sandbox environment.
|
||||
pub async fn create_environment(
|
||||
&self,
|
||||
request: &SandboxCreateEnvironmentRequest,
|
||||
) -> Result<SandboxEnvironmentResponse> {
|
||||
let url = format!("{}/sandbox/environments", self.base_url);
|
||||
let response = self
|
||||
.auth_headers(self.client.post(&url))
|
||||
.await?
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send create environment request")?;
|
||||
Self::parse_response(response, "create environment").await
|
||||
}
|
||||
|
||||
/// Get a sandbox environment by ID.
|
||||
pub async fn get_environment(
|
||||
&self,
|
||||
environment_id: &str,
|
||||
) -> Result<SandboxEnvironmentResponse> {
|
||||
let url = format!("{}/sandbox/environments/{}", self.base_url, environment_id);
|
||||
let response = self
|
||||
.auth_headers(self.client.get(&url))
|
||||
.await?
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send get environment request")?;
|
||||
Self::parse_response(response, "get environment").await
|
||||
}
|
||||
|
||||
/// Update a sandbox environment.
|
||||
pub async fn update_environment(
|
||||
&self,
|
||||
environment_id: &str,
|
||||
request: &SandboxUpdateEnvironmentRequest,
|
||||
) -> Result<SandboxEnvironmentResponse> {
|
||||
let url = format!("{}/sandbox/environments/{}", self.base_url, environment_id);
|
||||
let response = self
|
||||
.auth_headers(self.client.put(&url))
|
||||
.await?
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send update environment request")?;
|
||||
Self::parse_response(response, "update environment").await
|
||||
}
|
||||
|
||||
/// Delete a sandbox environment.
|
||||
pub async fn delete_environment(&self, environment_id: &str) -> Result<()> {
|
||||
let url = format!("{}/sandbox/environments/{}", self.base_url, environment_id);
|
||||
let response = self
|
||||
.auth_headers(self.client.delete(&url))
|
||||
.await?
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send delete environment request")?;
|
||||
Self::check_response(response, "delete environment").await
|
||||
}
|
||||
|
||||
/// List preinstalled packages available for sandbox environments.
|
||||
pub async fn list_preinstalled_packages(
|
||||
&self,
|
||||
) -> Result<SandboxListPreinstalledPackagesResponse> {
|
||||
let url = format!(
|
||||
"{}/sandbox/environments/preinstalled-packages",
|
||||
self.base_url
|
||||
);
|
||||
let response = self
|
||||
.auth_headers(self.client.get(&url))
|
||||
.await?
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send list preinstalled packages request")?;
|
||||
Self::parse_response(response, "list preinstalled packages").await
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
//! grok.com chat-product model catalog (`POST /rest/modes`) — the models
|
||||
//! grok-web's chat picker shows, distinct from the CLI `/v1/models` build
|
||||
//! catalog. Transport only; cache + ACP mapping live in
|
||||
//! [`crate::agent::chat_modes`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::AuthManager;
|
||||
|
||||
const KIGI_WEB_URL: &str = "https://grok.com";
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Mode {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub badge_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub availability: ModeAvailability,
|
||||
#[serde(default)]
|
||||
pub icon_hint: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn is_available(&self) -> bool {
|
||||
self.availability.available.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// proto3-JSON oneof: exactly one field is present.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModeAvailability {
|
||||
#[serde(default)]
|
||||
pub available: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub unavailable: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub requires_upgrade: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub coming_soon: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListModesResponse {
|
||||
#[serde(default)]
|
||||
pub modes: Vec<Mode>,
|
||||
#[serde(default)]
|
||||
pub default_mode_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ChatModelsError {
|
||||
#[error("no grok.com credentials")]
|
||||
NoAuth,
|
||||
#[error("request timed out")]
|
||||
Timeout,
|
||||
#[error("network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("request failed: {status}")]
|
||||
Http { status: u16 },
|
||||
#[error("parse error: {0}")]
|
||||
Parse(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Stateless transport for `POST /rest/modes`; caching lives in
|
||||
/// [`crate::agent::chat_modes::ChatModesManager`].
|
||||
pub struct ChatModelsClient {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
auth: Arc<AuthManager>,
|
||||
}
|
||||
|
||||
impl ChatModelsClient {
|
||||
pub fn new(auth: Arc<AuthManager>) -> Self {
|
||||
let base_url = std::env::var("KIGI_MODES_BASE_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("KIGI_CONVERSATIONS_BASE_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
.or_else(|| {
|
||||
std::env::var("KIGI_CODE_WEB_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| KIGI_WEB_URL.to_string());
|
||||
Self {
|
||||
http: crate::http::shared_client(),
|
||||
base_url,
|
||||
auth,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gated only on a valid grok.com bearer — deliberately NOT `is_xai_auth()`
|
||||
/// (unlike workspaces/conversations), since `/rest/modes` is the public chat
|
||||
/// endpoint and that gate would exclude API-key / cached-token chat users.
|
||||
pub async fn list_modes(&self, locale: &str) -> Result<ListModesResponse, ChatModelsError> {
|
||||
let auth = self
|
||||
.auth
|
||||
.auth()
|
||||
.await
|
||||
.map_err(|_| ChatModelsError::NoAuth)?;
|
||||
|
||||
let url = format!("{}/rest/modes", self.base_url);
|
||||
let body = serde_json::json!({ "locale": locale });
|
||||
let mut builder = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header("x-userid", &auth.user_id)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
"x-grok-client-identifier",
|
||||
crate::http::process_client_identifier(),
|
||||
)
|
||||
.header(
|
||||
crate::http::CLIENT_MODE_HEADER,
|
||||
crate::http::process_client_mode(),
|
||||
)
|
||||
.header(reqwest::header::ACCEPT, "application/json");
|
||||
if let Some(email) = &auth.email {
|
||||
builder = builder.header("x-email", email);
|
||||
}
|
||||
let builder = kigi_file_utils::trace_context::inject_trace_context_into_request(builder);
|
||||
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(ChatModelsError::Http {
|
||||
status: status.as_u16(),
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
let resp: ListModesResponse = serde_json::from_slice(&bytes)?;
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modes_parse_camelcase_wire() {
|
||||
let json = serde_json::json!({
|
||||
"modes": [{
|
||||
"id": "auto",
|
||||
"title": "Auto",
|
||||
"description": "Picks the best model",
|
||||
"badgeText": "New",
|
||||
"availability": { "available": {} },
|
||||
"iconHint": "rocket",
|
||||
"tags": ["TAG_PRIMARY"]
|
||||
}, {
|
||||
"id": "heavy",
|
||||
"title": "Heavy",
|
||||
"availability": { "requiresUpgrade": { "message": "Upgrade" } }
|
||||
}],
|
||||
"defaultModeId": "auto"
|
||||
});
|
||||
let resp: ListModesResponse = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(resp.modes.len(), 2);
|
||||
assert_eq!(resp.default_mode_id, "auto");
|
||||
let auto = &resp.modes[0];
|
||||
assert_eq!(auto.id, "auto");
|
||||
assert_eq!(auto.title, "Auto");
|
||||
assert_eq!(auto.badge_text.as_deref(), Some("New"));
|
||||
assert_eq!(auto.icon_hint, "rocket");
|
||||
assert_eq!(auto.tags, vec!["TAG_PRIMARY".to_string()]);
|
||||
assert!(auto.is_available());
|
||||
assert!(!resp.modes[1].is_available());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_default_gracefully() {
|
||||
let json = serde_json::json!({ "modes": [{ "id": "m1" }] });
|
||||
let resp: ListModesResponse = serde_json::from_value(json).unwrap();
|
||||
let m = &resp.modes[0];
|
||||
assert_eq!(m.id, "m1");
|
||||
assert!(m.title.is_empty());
|
||||
assert!(m.description.is_empty());
|
||||
assert!(m.badge_text.is_none());
|
||||
// No availability field on the wire → not selectable.
|
||||
assert!(!m.is_available());
|
||||
assert!(resp.default_mode_id.is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,305 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::{AuthManager, KimiAuth};
|
||||
|
||||
const KIGI_WEB_URL: &str = "https://grok.com";
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Conversation {
|
||||
#[serde(default)]
|
||||
pub conversation_id: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub starred: bool,
|
||||
#[serde(default)]
|
||||
pub create_time: Option<String>,
|
||||
#[serde(default)]
|
||||
pub modify_time: Option<String>,
|
||||
#[serde(default)]
|
||||
pub workspaces: Vec<Workspace>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Workspace {
|
||||
#[serde(default)]
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ConvQuery {
|
||||
pub page_size: i64,
|
||||
pub page_token: Option<String>,
|
||||
pub search_query: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ListConversationsPage {
|
||||
pub conversations: Vec<Conversation>,
|
||||
pub next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Body for `PUT /rest/app-chat/conversations/{id}` (grok-web `chatUpdateConversation`).
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateConversationBody {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub starred: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConvError {
|
||||
#[error("no OAuth credentials for conversations:read")]
|
||||
NoOauth,
|
||||
#[error("network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("request failed: {status}")]
|
||||
Http { status: u16 },
|
||||
#[error("parse error: {0}")]
|
||||
Parse(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ListConversationsResponseWire {
|
||||
#[serde(default)]
|
||||
conversations: Vec<Conversation>,
|
||||
#[serde(default)]
|
||||
next_page_token: Option<String>,
|
||||
#[serde(default)]
|
||||
text_search_matches: Vec<ListConversationsMatchWire>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ListConversationsMatchWire {
|
||||
#[serde(default)]
|
||||
conversation: Option<Conversation>,
|
||||
}
|
||||
|
||||
pub struct ConversationsClient {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
auth: Arc<AuthManager>,
|
||||
}
|
||||
|
||||
impl ConversationsClient {
|
||||
pub fn new(auth: Arc<AuthManager>) -> Self {
|
||||
let base_url = std::env::var("KIGI_CONVERSATIONS_BASE_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("KIGI_CODE_WEB_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| KIGI_WEB_URL.to_string());
|
||||
Self {
|
||||
http: crate::http::shared_client(),
|
||||
base_url,
|
||||
auth,
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_xai_auth(&self) -> Result<KimiAuth, ConvError> {
|
||||
let auth = self.auth.auth().await.map_err(|_| ConvError::NoOauth)?;
|
||||
if !auth.is_session_auth() {
|
||||
return Err(ConvError::NoOauth);
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
fn apply_auth_headers(
|
||||
&self,
|
||||
builder: reqwest::RequestBuilder,
|
||||
auth: &KimiAuth,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let mut builder = builder
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header("x-userid", &auth.user_id)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
"x-grok-client-identifier",
|
||||
crate::http::process_client_identifier(),
|
||||
)
|
||||
.header(
|
||||
crate::http::CLIENT_MODE_HEADER,
|
||||
crate::http::process_client_mode(),
|
||||
)
|
||||
.header(reqwest::header::ACCEPT, "application/json");
|
||||
if let Some(email) = &auth.email {
|
||||
builder = builder.header("x-email", email);
|
||||
}
|
||||
kigi_file_utils::trace_context::inject_trace_context_into_request(builder)
|
||||
}
|
||||
|
||||
pub async fn list_conversations(
|
||||
&self,
|
||||
q: &ConvQuery,
|
||||
) -> Result<ListConversationsPage, ConvError> {
|
||||
let auth = self.require_xai_auth().await?;
|
||||
|
||||
let url = format!("{}/rest/app-chat/conversations", self.base_url);
|
||||
let mut query: Vec<(&str, String)> = vec![("pageSize", q.page_size.to_string())];
|
||||
if let Some(token) = q.page_token.as_deref().filter(|s| !s.is_empty()) {
|
||||
query.push(("pageToken", token.to_owned()));
|
||||
}
|
||||
if let Some(search) = q.search_query.as_deref().filter(|s| !s.is_empty()) {
|
||||
query.push(("searchQuery", search.to_owned()));
|
||||
}
|
||||
if let Some(workspace) = q.workspace_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
query.push(("workspaceId", workspace.to_owned()));
|
||||
}
|
||||
|
||||
let builder = self.apply_auth_headers(self.http.get(&url).query(&query), &auth);
|
||||
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(ConvError::Http {
|
||||
status: status.as_u16(),
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
let wire: ListConversationsResponseWire = serde_json::from_slice(&bytes)?;
|
||||
|
||||
let searching = q.search_query.as_deref().is_some_and(|s| !s.is_empty());
|
||||
// During an active search, results come exclusively from
|
||||
// `text_search_matches`. Never fall back to `wire.conversations` here:
|
||||
// an empty match set means "no hits", and the server may return
|
||||
// recent/unfiltered conversations in `conversations` that are NOT search
|
||||
// matches — surfacing those would be wrong.
|
||||
let conversations = if searching {
|
||||
wire.text_search_matches
|
||||
.into_iter()
|
||||
.filter_map(|m| m.conversation)
|
||||
.collect()
|
||||
} else {
|
||||
wire.conversations
|
||||
};
|
||||
|
||||
Ok(ListConversationsPage {
|
||||
conversations,
|
||||
next_page_token: wire.next_page_token.filter(|t| !t.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
/// `PUT /rest/app-chat/conversations/{conversation_id}` — rename and/or star.
|
||||
pub async fn update_conversation(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
body: &UpdateConversationBody,
|
||||
) -> Result<(), ConvError> {
|
||||
let auth = self.require_xai_auth().await?;
|
||||
let url = format!(
|
||||
"{}/rest/app-chat/conversations/{}",
|
||||
self.base_url,
|
||||
urlencoding::encode(conversation_id)
|
||||
);
|
||||
let builder = self
|
||||
.apply_auth_headers(self.http.put(&url), &auth)
|
||||
.json(body);
|
||||
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(ConvError::Http {
|
||||
status: status.as_u16(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `DELETE /rest/app-chat/conversations/soft/{conversation_id}` — soft-delete.
|
||||
pub async fn soft_delete_conversation(&self, conversation_id: &str) -> Result<(), ConvError> {
|
||||
let auth = self.require_xai_auth().await?;
|
||||
let url = format!(
|
||||
"{}/rest/app-chat/conversations/soft/{}",
|
||||
self.base_url,
|
||||
urlencoding::encode(conversation_id)
|
||||
);
|
||||
let builder = self.apply_auth_headers(self.http.delete(&url), &auth);
|
||||
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
// 404 = already soft-deleted; keep deletion idempotent like the
|
||||
// build path's `classify_remote_delete`.
|
||||
if !status.is_success() && status.as_u16() != 404 {
|
||||
return Err(ConvError::Http {
|
||||
status: status.as_u16(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn conversation_parses_camelcase_wire() {
|
||||
let json = serde_json::json!({
|
||||
"conversations": [{
|
||||
"conversationId": "conv_abc",
|
||||
"title": "Compare GPU vendors",
|
||||
"starred": true,
|
||||
"createTime": "2026-06-18T17:30:00Z",
|
||||
"modifyTime": "2026-06-18T18:02:00Z",
|
||||
"workspaces": [{ "workspaceId": "ws_9f3a" }]
|
||||
}],
|
||||
"nextPageToken": "tok2"
|
||||
});
|
||||
let wire: ListConversationsResponseWire = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(wire.conversations.len(), 1);
|
||||
let c = &wire.conversations[0];
|
||||
assert_eq!(c.conversation_id, "conv_abc");
|
||||
assert_eq!(c.title, "Compare GPU vendors");
|
||||
assert!(c.starred);
|
||||
assert_eq!(c.modify_time.as_deref(), Some("2026-06-18T18:02:00Z"));
|
||||
assert_eq!(c.workspaces[0].workspace_id, "ws_9f3a");
|
||||
assert_eq!(wire.next_page_token.as_deref(), Some("tok2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_default_gracefully() {
|
||||
let json = serde_json::json!({ "conversations": [{ "conversationId": "c1" }] });
|
||||
let wire: ListConversationsResponseWire = serde_json::from_value(json).unwrap();
|
||||
let c = &wire.conversations[0];
|
||||
assert_eq!(c.conversation_id, "c1");
|
||||
assert!(c.title.is_empty());
|
||||
assert!(c.modify_time.is_none());
|
||||
assert!(c.create_time.is_none());
|
||||
assert!(c.workspaces.is_empty());
|
||||
assert!(wire.next_page_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_body_serializes_only_set_fields() {
|
||||
let title_only = UpdateConversationBody {
|
||||
title: Some("New title".into()),
|
||||
starred: None,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&title_only).unwrap(),
|
||||
serde_json::json!({ "title": "New title" })
|
||||
);
|
||||
|
||||
let both = UpdateConversationBody {
|
||||
title: Some("T".into()),
|
||||
starred: Some(true),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&both).unwrap(),
|
||||
serde_json::json!({ "title": "T", "starred": true })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//! Remote storage client for the backend.
|
||||
|
||||
pub mod agent;
|
||||
pub mod chat_models_client;
|
||||
pub mod client;
|
||||
pub mod conversations_client;
|
||||
pub mod pull;
|
||||
#[cfg(test)]
|
||||
mod pull_smoke_test;
|
||||
pub mod sync;
|
||||
pub mod workspaces_client;
|
||||
|
||||
pub use agent::{
|
||||
SandboxClient, SandboxCreateEnvironmentRequest, SandboxEnvironment, SandboxEnvironmentResponse,
|
||||
SandboxEnvironmentVariable, SandboxEnvironmentWithMetadata, SandboxForkRequest,
|
||||
SandboxForkResponse, SandboxForkedSession, SandboxHibernateResponse,
|
||||
SandboxListEnvironmentsRequest, SandboxListEnvironmentsResponse,
|
||||
SandboxListPreinstalledPackagesResponse, SandboxLogsExitCodes, SandboxLogsResponse,
|
||||
SandboxMode, SandboxPreinstalledPackage, SandboxRestoreRequest, SandboxRestoreResponse,
|
||||
SandboxSecretInput, SandboxStartRequest, SandboxStartResponse, SandboxStatusResponse,
|
||||
SandboxTerminateRequest, SandboxUpdateEnvironmentRequest,
|
||||
};
|
||||
pub use chat_models_client::{
|
||||
ChatModelsClient, ChatModelsError, ListModesResponse, Mode, ModeAvailability,
|
||||
};
|
||||
pub use client::{
|
||||
BackendClient, BackendError, FetchModelsResult, FetchedBundle, fetch_bundle,
|
||||
fetch_login_device_flow, fetch_settings_blocking, fetch_subagent_bundle, share_url,
|
||||
};
|
||||
pub(crate) use client::{DEFAULT_CONTEXT_WINDOW, fetch_models_blocking, models_fetch_origin};
|
||||
pub use conversations_client::{
|
||||
ConvError, ConvQuery, Conversation, ConversationsClient, ListConversationsPage,
|
||||
UpdateConversationBody,
|
||||
};
|
||||
pub use pull::{PullResult, pull_session_to_local};
|
||||
pub use sync::RemoteSync;
|
||||
pub use workspaces_client::{ListWorkspacesPage, Workspace, WorkspacesClient, WsError, WsQuery};
|
||||
@@ -1,772 +0,0 @@
|
||||
//! Pull-on-miss: fetch a session from the backend and hydrate local JSONL storage.
|
||||
|
||||
use crate::remote::client::{BackendClient, BackendError};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PullResult {
|
||||
/// Written to local storage. The [`Info`] cwd comes from the backend (may differ from caller's).
|
||||
Hydrated(crate::session::info::Info),
|
||||
/// Not found on the backend.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Fetch a session from the backend and hydrate local JSONL storage.
|
||||
pub async fn pull_session_to_local(
|
||||
session_id: &str,
|
||||
client: &BackendClient,
|
||||
) -> Result<PullResult, BackendError> {
|
||||
let loaded = match client.load_session_data(session_id).await {
|
||||
Ok(resp) => resp,
|
||||
Err(BackendError::SessionNotFound { .. }) => return Ok(PullResult::NotFound),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let remote = match loaded.session.as_ref() {
|
||||
Some(s) => s,
|
||||
None => return Ok(PullResult::NotFound),
|
||||
};
|
||||
|
||||
// cwd required for local dir placement; null means pre-writeback session.
|
||||
let cwd = match remote.cwd.as_ref() {
|
||||
Some(cwd) => cwd,
|
||||
None => {
|
||||
tracing::warn!(session_id, "Cannot pull session: backend has cwd=null");
|
||||
return Ok(PullResult::NotFound);
|
||||
}
|
||||
};
|
||||
|
||||
let info = crate::session::info::Info {
|
||||
id: agent_client_protocol::SessionId::new(std::sync::Arc::from(session_id)),
|
||||
cwd: cwd.clone(),
|
||||
};
|
||||
let dir = crate::session::persistence::session_dir(&info);
|
||||
|
||||
let num_messages = hydrate::write_to_dir(&dir, &loaded)?;
|
||||
|
||||
tracing::info!(session_id, %cwd, num_messages, "Pulled session from backend");
|
||||
|
||||
Ok(PullResult::Hydrated(info))
|
||||
}
|
||||
|
||||
pub(crate) mod hydrate {
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::remote::client::{BackendError, LoadDataResponse, LoadedMessage, SessionInfo};
|
||||
use crate::session::info::Info;
|
||||
use crate::session::persistence::{CHAT_FORMAT_VERSION, Summary, default_model_id};
|
||||
|
||||
fn io_err(path: &Path, source: std::io::Error) -> BackendError {
|
||||
BackendError::Hydration {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write all session files to `dir`.
|
||||
pub(super) fn write_to_dir(
|
||||
dir: &Path,
|
||||
loaded: &LoadDataResponse,
|
||||
) -> Result<usize, BackendError> {
|
||||
let remote = loaded
|
||||
.session
|
||||
.as_ref()
|
||||
.expect("caller checked session.is_some()");
|
||||
|
||||
let info = Info {
|
||||
id: agent_client_protocol::SessionId::new(Arc::from(remote.session_id.as_str())),
|
||||
cwd: remote.cwd.clone().expect("caller verified cwd is Some"),
|
||||
};
|
||||
|
||||
std::fs::create_dir_all(dir).map_err(|e| io_err(dir, e))?;
|
||||
|
||||
let num_messages = loaded.messages.as_ref().map_or(0, |m| m.len());
|
||||
let mut num_chat_messages = 0;
|
||||
|
||||
if let Some(ref messages) = loaded.messages {
|
||||
write_updates(dir, messages)?;
|
||||
num_chat_messages = rebuild_chat_history(dir)?;
|
||||
}
|
||||
|
||||
write_summary(dir, &info, remote, num_messages, num_chat_messages)?;
|
||||
write_remote_origin_marker(dir);
|
||||
|
||||
Ok(num_messages)
|
||||
}
|
||||
|
||||
fn write_summary(
|
||||
dir: &Path,
|
||||
info: &Info,
|
||||
remote: &SessionInfo,
|
||||
num_messages: usize,
|
||||
num_chat_messages: usize,
|
||||
) -> Result<(), BackendError> {
|
||||
let meta = remote.metadata.as_ref();
|
||||
|
||||
let model_id = meta
|
||||
.and_then(|m| m.get("modelId"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(agent_client_protocol::ModelId::new)
|
||||
.unwrap_or_else(default_model_id);
|
||||
|
||||
let parent_session_id = meta
|
||||
.and_then(|m| m.get("parentSessionId"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let summary = Summary {
|
||||
info: info.clone(),
|
||||
session_summary: remote.title.clone().unwrap_or_default(),
|
||||
created_at: parse_rfc3339_or_now(remote.created_at.as_deref()),
|
||||
updated_at: parse_rfc3339_or_now(remote.updated_at.as_deref()),
|
||||
num_messages,
|
||||
num_chat_messages,
|
||||
current_model_id: model_id,
|
||||
parent_session_id,
|
||||
forked_at: None,
|
||||
collection_id: None,
|
||||
next_trace_turn: 0,
|
||||
chat_format_version: CHAT_FORMAT_VERSION,
|
||||
prompt_display_cwd: None,
|
||||
session_kind: None,
|
||||
fork_context_source: None,
|
||||
fork_parent_prompt_id: None,
|
||||
inherited_prefix_len: None,
|
||||
hidden: None,
|
||||
source_workspace_dir: None,
|
||||
git_root_dir: None,
|
||||
git_remotes: Vec::new(),
|
||||
head_commit: None,
|
||||
head_branch: None,
|
||||
request_id: None,
|
||||
// Record the *local* kigi_home (where this hydrated copy lives),
|
||||
// not the original remote session's, since reconstruction runs locally.
|
||||
kigi_home: crate::session::persistence::kigi_home_string(),
|
||||
last_active_at: None,
|
||||
generated_title: None,
|
||||
title_is_manual: false,
|
||||
worktree_label: None,
|
||||
agent_name: None,
|
||||
// Hydrated locally — record the profile this process runs under.
|
||||
sandbox_profile: kigi_sandbox::configured_profile_name().map(String::from),
|
||||
reasoning_effort: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&summary)?;
|
||||
write_file(&dir.join("summary.json"), json.as_bytes())
|
||||
}
|
||||
|
||||
/// Convert backend JSON-RPC messages to local updates.jsonl (replayable methods only).
|
||||
pub(super) fn write_updates(
|
||||
dir: &Path,
|
||||
messages: &[LoadedMessage],
|
||||
) -> Result<(), BackendError> {
|
||||
use std::io::Write;
|
||||
|
||||
let path = dir.join("updates.jsonl");
|
||||
let file = std::fs::File::create(&path).map_err(|e| io_err(&path, e))?;
|
||||
let mut w = std::io::BufWriter::new(file);
|
||||
|
||||
for msg in messages {
|
||||
let parsed = match serde_json::from_str::<serde_json::Value>(&msg.content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !is_session_update(&parsed) {
|
||||
continue;
|
||||
}
|
||||
if let Some(line) = to_envelope_line(&parsed) {
|
||||
let _ = w.write_all(line.as_bytes());
|
||||
let _ = w.write_all(b"\n");
|
||||
}
|
||||
}
|
||||
|
||||
w.flush().map_err(|e| io_err(&path, e))
|
||||
}
|
||||
|
||||
/// Rebuild `chat_history.jsonl` from `updates.jsonl` so pulled sessions are continuable.
|
||||
fn rebuild_chat_history(dir: &Path) -> Result<usize, BackendError> {
|
||||
use crate::session::storage::UpdatesIterator;
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
let updates_path = dir.join("updates.jsonl");
|
||||
let Some(iter) =
|
||||
UpdatesIterator::open(&updates_path).map_err(|e| io_err(&updates_path, e))?
|
||||
else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
let chat_path = dir.join("chat_history.jsonl");
|
||||
let file = std::fs::File::create(&chat_path).map_err(|e| io_err(&chat_path, e))?;
|
||||
let mut writer = std::io::BufWriter::new(file);
|
||||
let mut reducer = ChatReducer::new();
|
||||
|
||||
for result in iter {
|
||||
let update = match result {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for item in reducer.process(&update) {
|
||||
if let Ok(line) = serde_json::to_string(&item) {
|
||||
let _ = writer.write_all(line.as_bytes());
|
||||
let _ = writer.write_all(b"\n");
|
||||
}
|
||||
}
|
||||
|
||||
// CompactionCheckpoint: truncate file and reset
|
||||
if reducer.should_truncate() {
|
||||
reducer.clear_truncate_flag();
|
||||
let _ = writer.seek(std::io::SeekFrom::Start(0));
|
||||
let _ = writer.get_mut().set_len(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush trailing state
|
||||
for item in reducer.flush() {
|
||||
if let Ok(line) = serde_json::to_string(&item) {
|
||||
let _ = writer.write_all(line.as_bytes());
|
||||
let _ = writer.write_all(b"\n");
|
||||
}
|
||||
}
|
||||
|
||||
writer.flush().map_err(|e| io_err(&chat_path, e))?;
|
||||
Ok(reducer.count())
|
||||
}
|
||||
|
||||
use crate::sampling::{AssistantItem, ContentPart, ConversationItem, ToolCall};
|
||||
use agent_client_protocol as acp;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Reduces ACP session updates into conversation items.
|
||||
///
|
||||
/// Turn boundaries: User→Agent flushes user, Agent→User flushes agent,
|
||||
/// tool completion flushes agent before emitting result.
|
||||
struct ChatReducer {
|
||||
user_parts: Vec<ContentPart>,
|
||||
agent_text: String,
|
||||
agent_tool_calls: Vec<ToolCall>,
|
||||
|
||||
in_user_turn: bool,
|
||||
has_agent_content: bool,
|
||||
needs_truncate: bool,
|
||||
|
||||
tool_args: HashMap<String, String>,
|
||||
emitted_tool_results: HashSet<String>,
|
||||
item_count: usize,
|
||||
}
|
||||
|
||||
impl ChatReducer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
user_parts: Vec::new(),
|
||||
agent_text: String::new(),
|
||||
agent_tool_calls: Vec::new(),
|
||||
in_user_turn: false,
|
||||
has_agent_content: false,
|
||||
needs_truncate: false,
|
||||
tool_args: HashMap::new(),
|
||||
emitted_tool_results: HashSet::new(),
|
||||
item_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
update: &crate::session::storage::SessionUpdate,
|
||||
) -> Vec<ConversationItem> {
|
||||
use crate::session::storage::SessionUpdate;
|
||||
|
||||
match update {
|
||||
SessionUpdate::Acp(n) => self.handle_acp(&n.update),
|
||||
SessionUpdate::Xai(n) => self.handle_xai(&n.update),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_acp(&mut self, update: &acp::SessionUpdate) -> Vec<ConversationItem> {
|
||||
match update {
|
||||
acp::SessionUpdate::UserMessageChunk(chunk) => self.on_user_chunk(chunk),
|
||||
acp::SessionUpdate::AgentMessageChunk(chunk) => self.on_agent_chunk(chunk),
|
||||
acp::SessionUpdate::ToolCall(tc) => self.on_tool_call(tc),
|
||||
acp::SessionUpdate::ToolCallUpdate(tc) => self.on_tool_call_update(tc),
|
||||
_ => Vec::new(), // AgentThoughtChunk, Retry, Plan not needed
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_xai(
|
||||
&mut self,
|
||||
update: &crate::extensions::notification::SessionUpdate,
|
||||
) -> Vec<ConversationItem> {
|
||||
use crate::extensions::notification::SessionUpdate as XaiUpdate;
|
||||
|
||||
match update {
|
||||
XaiUpdate::CompactionCheckpoint(_) => {
|
||||
self.reset();
|
||||
self.needs_truncate = true;
|
||||
Vec::new()
|
||||
}
|
||||
_ => Vec::new(), // DiffReview, MemoryFlush, etc. not needed
|
||||
}
|
||||
}
|
||||
|
||||
fn on_user_chunk(&mut self, chunk: &acp::ContentChunk) -> Vec<ConversationItem> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
if !self.in_user_turn {
|
||||
out.extend(self.flush_agent());
|
||||
self.in_user_turn = true;
|
||||
}
|
||||
|
||||
match &chunk.content {
|
||||
acp::ContentBlock::Text(t) => {
|
||||
self.user_parts.push(ContentPart::Text {
|
||||
text: std::sync::Arc::<str>::from(t.text.clone()),
|
||||
});
|
||||
}
|
||||
acp::ContentBlock::Image(img) => {
|
||||
if let Some(uri) = &img.uri {
|
||||
self.user_parts.push(ContentPart::Image {
|
||||
url: std::sync::Arc::<str>::from(uri.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {} // Audio, Resource, etc. not needed for chat replay
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn on_agent_chunk(&mut self, chunk: &acp::ContentChunk) -> Vec<ConversationItem> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
if self.in_user_turn {
|
||||
out.extend(self.flush_user());
|
||||
self.in_user_turn = false;
|
||||
}
|
||||
|
||||
if let acp::ContentBlock::Text(t) = &chunk.content {
|
||||
self.agent_text.push_str(&t.text);
|
||||
self.has_agent_content = true;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn on_tool_call(&mut self, tc: &acp::ToolCall) -> Vec<ConversationItem> {
|
||||
let id = tc.tool_call_id.0.to_string();
|
||||
let args = tc
|
||||
.raw_input
|
||||
.as_ref()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
self.tool_args.insert(id.clone(), args.clone());
|
||||
self.agent_tool_calls.push(ToolCall {
|
||||
id: std::sync::Arc::<str>::from(id),
|
||||
name: tc.title.clone(),
|
||||
arguments: std::sync::Arc::<str>::from(args),
|
||||
});
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn on_tool_call_update(&mut self, tc: &acp::ToolCallUpdate) -> Vec<ConversationItem> {
|
||||
let id = tc.tool_call_id.0.to_string();
|
||||
self.maybe_backfill_args(&id, &tc.fields);
|
||||
|
||||
if Self::is_completed(&tc.fields) && self.emitted_tool_results.insert(id.clone()) {
|
||||
return self.emit_tool_result(&id, &tc.fields);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Backfill tool arguments from ToolCallUpdate if ToolCall didn't have them.
|
||||
fn maybe_backfill_args(&mut self, id: &str, fields: &acp::ToolCallUpdateFields) {
|
||||
let Some(raw) = &fields.raw_input else { return };
|
||||
let needs_backfill = self.tool_args.get(id).is_none_or(String::is_empty);
|
||||
if !needs_backfill {
|
||||
return;
|
||||
}
|
||||
|
||||
let args = raw.to_string();
|
||||
self.tool_args.insert(id.to_string(), args.clone());
|
||||
|
||||
if let Some(call) = self
|
||||
.agent_tool_calls
|
||||
.iter_mut()
|
||||
.find(|c| c.id.as_ref() == id)
|
||||
{
|
||||
call.arguments = std::sync::Arc::<str>::from(args);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_completed(fields: &acp::ToolCallUpdateFields) -> bool {
|
||||
matches!(
|
||||
fields.status,
|
||||
Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed)
|
||||
)
|
||||
}
|
||||
|
||||
fn emit_tool_result(
|
||||
&mut self,
|
||||
id: &str,
|
||||
fields: &acp::ToolCallUpdateFields,
|
||||
) -> Vec<ConversationItem> {
|
||||
let mut out = Vec::new();
|
||||
out.extend(self.flush_agent());
|
||||
|
||||
let content = extract_tool_result_text(fields);
|
||||
let item = ConversationItem::tool_result(id.to_string(), content);
|
||||
self.item_count += 1;
|
||||
out.push(item);
|
||||
out
|
||||
}
|
||||
|
||||
fn flush_user(&mut self) -> Option<ConversationItem> {
|
||||
if self.user_parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let item = ConversationItem::user_with_parts(std::mem::take(&mut self.user_parts));
|
||||
self.item_count += 1;
|
||||
Some(item)
|
||||
}
|
||||
|
||||
fn flush_agent(&mut self) -> Option<ConversationItem> {
|
||||
if !self.has_agent_content && self.agent_tool_calls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let item = ConversationItem::Assistant(AssistantItem {
|
||||
content: std::sync::Arc::<str>::from(std::mem::take(&mut self.agent_text)),
|
||||
tool_calls: std::mem::take(&mut self.agent_tool_calls),
|
||||
model_id: None,
|
||||
model_fingerprint: None,
|
||||
reasoning_effort: None,
|
||||
});
|
||||
self.has_agent_content = false;
|
||||
self.item_count += 1;
|
||||
Some(item)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<ConversationItem> {
|
||||
let mut out = Vec::new();
|
||||
out.extend(self.flush_user());
|
||||
out.extend(self.flush_agent());
|
||||
out
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.user_parts.clear();
|
||||
self.agent_text.clear();
|
||||
self.agent_tool_calls.clear();
|
||||
self.tool_args.clear();
|
||||
self.emitted_tool_results.clear();
|
||||
self.in_user_turn = false;
|
||||
self.has_agent_content = false;
|
||||
self.item_count = 0;
|
||||
}
|
||||
|
||||
fn should_truncate(&self) -> bool {
|
||||
self.needs_truncate
|
||||
}
|
||||
|
||||
fn clear_truncate_flag(&mut self) {
|
||||
self.needs_truncate = false;
|
||||
}
|
||||
|
||||
fn count(&self) -> usize {
|
||||
self.item_count
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract displayable text from a completed ToolCallUpdate.
|
||||
fn extract_tool_result_text(fields: &agent_client_protocol::ToolCallUpdateFields) -> String {
|
||||
if let Some(content) = &fields.content {
|
||||
let text: String = content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
agent_client_protocol::ToolCallContent::Content(
|
||||
agent_client_protocol::Content {
|
||||
content: agent_client_protocol::ContentBlock::Text(t),
|
||||
..
|
||||
},
|
||||
) => Some(t.text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if !text.is_empty() {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
if let Some(raw) = &fields.raw_output {
|
||||
return raw.to_string();
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn write_remote_origin_marker(dir: &Path) {
|
||||
let _ = std::fs::write(
|
||||
dir.join(".remote_origin"),
|
||||
format!("pulled_at={}\n", chrono::Utc::now().to_rfc3339()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Replayable JSON-RPC methods (excludes metadata like `prompt_complete`).
|
||||
const REPLAYABLE_METHODS: &[&str] = &["session/update", "_x.ai/session/update"];
|
||||
|
||||
fn is_session_update(json_rpc: &serde_json::Value) -> bool {
|
||||
json_rpc
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|m| REPLAYABLE_METHODS.contains(&m))
|
||||
}
|
||||
|
||||
fn to_envelope_line(json_rpc: &serde_json::Value) -> Option<String> {
|
||||
let method = json_rpc.get("method").and_then(|v| v.as_str())?;
|
||||
let params = json_rpc.get("params").cloned().unwrap_or_default();
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"timestamp": 0u64,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn parse_rfc3339_or_now(s: Option<&str>) -> chrono::DateTime<chrono::Utc> {
|
||||
s.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.unwrap_or_else(chrono::Utc::now)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, data: &[u8]) -> Result<(), BackendError> {
|
||||
std::fs::write(path, data).map_err(|e| io_err(path, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::remote::client::LoadedMessage;
|
||||
|
||||
#[test]
|
||||
fn hydrate_writes_valid_updates_jsonl() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let messages = vec![
|
||||
LoadedMessage {
|
||||
id: "1".into(),
|
||||
content: r#"{"method":"session/update","params":{"update":"hello"}}"#.into(),
|
||||
timestamp: None,
|
||||
},
|
||||
LoadedMessage {
|
||||
id: "2".into(),
|
||||
content: r#"{"method":"session/update","params":{"update":"world"}}"#.into(),
|
||||
timestamp: None,
|
||||
},
|
||||
];
|
||||
|
||||
super::hydrate::write_updates(tmp.path(), &messages).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(tmp.path().join("updates.jsonl")).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
|
||||
for line in &lines {
|
||||
let v: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
assert_eq!(v["timestamp"], 0);
|
||||
assert_eq!(v["method"], "session/update");
|
||||
assert!(v["params"].is_object());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_chat_history_merges_chunks() {
|
||||
use crate::session::export::ExportedMessage;
|
||||
use agent_client_protocol::{ContentBlock, ContentChunk, SessionUpdate, TextContent};
|
||||
use std::sync::Arc;
|
||||
|
||||
// Build ACP notifications matching the RemoteSync path
|
||||
let sid = agent_client_protocol::SessionId::new(Arc::from("test"));
|
||||
let notifications = [
|
||||
agent_client_protocol::SessionNotification::new(
|
||||
sid.clone(),
|
||||
SessionUpdate::UserMessageChunk(ContentChunk::new(ContentBlock::Text(
|
||||
TextContent::new("hello "),
|
||||
))),
|
||||
),
|
||||
agent_client_protocol::SessionNotification::new(
|
||||
sid.clone(),
|
||||
SessionUpdate::UserMessageChunk(ContentChunk::new(ContentBlock::Text(
|
||||
TextContent::new("world"),
|
||||
))),
|
||||
),
|
||||
agent_client_protocol::SessionNotification::new(
|
||||
sid.clone(),
|
||||
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
|
||||
TextContent::new("hi back"),
|
||||
))),
|
||||
),
|
||||
];
|
||||
|
||||
// Serialize through ExportedMessage (writeback path)
|
||||
let messages: Vec<LoadedMessage> = notifications
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let exported = ExportedMessage::from_notification(n);
|
||||
LoadedMessage {
|
||||
id: "x".into(),
|
||||
content: exported.content,
|
||||
timestamp: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let data = crate::remote::client::LoadDataResponse {
|
||||
messages: Some(messages),
|
||||
session: Some(crate::remote::client::SessionInfo {
|
||||
session_id: "test".into(),
|
||||
title: None,
|
||||
cwd: Some("/tmp".into()),
|
||||
status: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
metadata: None,
|
||||
}),
|
||||
};
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
super::hydrate::write_to_dir(tmp.path(), &data).unwrap();
|
||||
|
||||
let chat = std::fs::read_to_string(tmp.path().join("chat_history.jsonl")).unwrap();
|
||||
let items: Vec<crate::sampling::ConversationItem> = chat
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.filter_map(|l| serde_json::from_str(l).ok())
|
||||
.collect();
|
||||
|
||||
assert_eq!(items.len(), 2, "should have 1 user + 1 agent item");
|
||||
assert!(matches!(
|
||||
&items[0],
|
||||
crate::sampling::ConversationItem::User(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
&items[1],
|
||||
crate::sampling::ConversationItem::Assistant(_)
|
||||
));
|
||||
if let crate::sampling::ConversationItem::User(u) = &items[0] {
|
||||
let text: String = u
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
crate::sampling::ContentPart::Text { text } => Some(text.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(text, "hello world");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_chat_history_preserves_user_images() {
|
||||
use crate::session::export::ExportedMessage;
|
||||
use agent_client_protocol::{
|
||||
ContentBlock, ContentChunk, ImageContent, SessionUpdate, TextContent,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
let sid = agent_client_protocol::SessionId::new(Arc::from("test"));
|
||||
let notifications = [
|
||||
agent_client_protocol::SessionNotification::new(
|
||||
sid.clone(),
|
||||
SessionUpdate::UserMessageChunk(ContentChunk::new(ContentBlock::Text(
|
||||
TextContent::new("look at this"),
|
||||
))),
|
||||
),
|
||||
agent_client_protocol::SessionNotification::new(
|
||||
sid.clone(),
|
||||
SessionUpdate::UserMessageChunk(ContentChunk::new(ContentBlock::Image(
|
||||
ImageContent::new(String::new(), String::new())
|
||||
.uri(Some("data:image/png;base64,abc".into())),
|
||||
))),
|
||||
),
|
||||
agent_client_protocol::SessionNotification::new(
|
||||
sid.clone(),
|
||||
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
|
||||
TextContent::new("I see an image"),
|
||||
))),
|
||||
),
|
||||
];
|
||||
|
||||
let messages: Vec<LoadedMessage> = notifications
|
||||
.iter()
|
||||
.map(|n| LoadedMessage {
|
||||
id: "x".into(),
|
||||
content: ExportedMessage::from_notification(n).content,
|
||||
timestamp: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let data = crate::remote::client::LoadDataResponse {
|
||||
messages: Some(messages),
|
||||
session: Some(crate::remote::client::SessionInfo {
|
||||
session_id: "test".into(),
|
||||
title: None,
|
||||
cwd: Some("/tmp".into()),
|
||||
status: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
metadata: None,
|
||||
}),
|
||||
};
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
super::hydrate::write_to_dir(tmp.path(), &data).unwrap();
|
||||
|
||||
let chat = std::fs::read_to_string(tmp.path().join("chat_history.jsonl")).unwrap();
|
||||
let items: Vec<crate::sampling::ConversationItem> = chat
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.filter_map(|l| serde_json::from_str(l).ok())
|
||||
.collect();
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
if let crate::sampling::ConversationItem::User(u) = &items[0] {
|
||||
assert_eq!(u.content.len(), 2, "should have text + image parts");
|
||||
assert!(matches!(
|
||||
&u.content[0],
|
||||
crate::sampling::ContentPart::Text { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
&u.content[1],
|
||||
crate::sampling::ContentPart::Image { .. }
|
||||
));
|
||||
} else {
|
||||
panic!("expected User item");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hydrate_skips_invalid_messages() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let messages = vec![
|
||||
LoadedMessage {
|
||||
id: "1".into(),
|
||||
content: r#"{"method":"session/update","params":{}}"#.into(),
|
||||
timestamp: None,
|
||||
},
|
||||
LoadedMessage {
|
||||
id: "bad".into(),
|
||||
content: "not valid json".into(),
|
||||
timestamp: None,
|
||||
},
|
||||
LoadedMessage {
|
||||
id: "3".into(),
|
||||
content: r#"{"method":"session/update","params":{"x":1}}"#.into(),
|
||||
timestamp: None,
|
||||
},
|
||||
];
|
||||
|
||||
super::hydrate::write_updates(tmp.path(), &messages).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(tmp.path().join("updates.jsonl")).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "invalid message should be skipped");
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//! Push → pull round-trip smoke test against the live backend.
|
||||
//!
|
||||
//! Run with: `cargo test -p kigi-shell -- pull_smoke --ignored --nocapture`
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::auth::KimiAuth;
|
||||
use crate::remote::client::BackendClient;
|
||||
use crate::session::storage::{JsonlStorageAdapter, StorageAdapter};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn load_prod_auth() -> Option<KimiAuth> {
|
||||
let path = crate::util::kigi_home::kigi_home().join("auth.json");
|
||||
let contents = std::fs::read_to_string(&path).ok()?;
|
||||
let store: BTreeMap<String, KimiAuth> = serde_json::from_str(&contents).ok()?;
|
||||
let scope = crate::auth::KimiCodeConfig::default().auth_scope();
|
||||
crate::auth::lookup_auth(&store, &scope)
|
||||
}
|
||||
|
||||
/// Full round-trip using the real RemoteSync production code path:
|
||||
/// create RemoteSync → queue ACP notifications → flush → verify on
|
||||
/// backend → pull back → verify local hydration + storage adapter load.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn smoke_push_pull_round_trip() {
|
||||
use crate::remote::sync::RemoteSync;
|
||||
use crate::session::export::ExportedMetadata;
|
||||
use agent_client_protocol::{
|
||||
ContentBlock, ContentChunk, SessionNotification, SessionUpdate, TextContent,
|
||||
};
|
||||
|
||||
let auth = load_prod_auth().expect("No auth.json — run `grok login`");
|
||||
let am = Arc::new(crate::auth::AuthManager::new(
|
||||
&crate::util::kigi_home::kigi_home(),
|
||||
crate::auth::KimiCodeConfig::default(),
|
||||
));
|
||||
am.hot_swap(auth);
|
||||
let client = BackendClient::new().with_auth_manager(am.clone());
|
||||
|
||||
let session_id = format!("test-rt-{}", uuid::Uuid::new_v4());
|
||||
let test_cwd = "/tmp/smoke-test".to_string();
|
||||
let test_title = "Push-Pull Round Trip Test";
|
||||
|
||||
// PUSH via RemoteSync (real production path)
|
||||
let metadata = ExportedMetadata {
|
||||
title: Some(test_title.into()),
|
||||
cwd: test_cwd.clone(),
|
||||
model_id: Some("grok-3".into()),
|
||||
created_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
updated_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
total_messages: None,
|
||||
parent_session_id: None,
|
||||
session_kind: None,
|
||||
subagent_type: None,
|
||||
subagent_persona: None,
|
||||
subagent_role: None,
|
||||
fork_context_source: None,
|
||||
subagent_depth: None,
|
||||
};
|
||||
|
||||
let sync = RemoteSync::new(
|
||||
session_id.clone(),
|
||||
metadata,
|
||||
BackendClient::new().with_auth_manager(am.clone()),
|
||||
);
|
||||
|
||||
let sid = agent_client_protocol::SessionId::new(Arc::from(session_id.as_str()));
|
||||
sync.queue(SessionNotification::new(
|
||||
sid.clone(),
|
||||
SessionUpdate::UserMessageChunk(ContentChunk::new(ContentBlock::Text(
|
||||
TextContent::new("Hello from smoke test — user".to_string()),
|
||||
))),
|
||||
));
|
||||
sync.queue(SessionNotification::new(
|
||||
sid.clone(),
|
||||
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
|
||||
TextContent::new("Hello from smoke test — agent".to_string()),
|
||||
))),
|
||||
));
|
||||
sync.flush();
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
|
||||
// Verify backend has cwd, title, messages
|
||||
let loaded = client
|
||||
.load_session_data(&session_id)
|
||||
.await
|
||||
.expect("load after push failed");
|
||||
let remote = loaded.session.as_ref().expect("no session row");
|
||||
assert_eq!(remote.cwd.as_deref(), Some(test_cwd.as_str()));
|
||||
assert_eq!(remote.title.as_deref(), Some(test_title));
|
||||
assert!(loaded.messages.as_ref().map_or(0, |m| m.len()) >= 2);
|
||||
|
||||
// PULL back to local
|
||||
let result = crate::remote::pull_session_to_local(&session_id, &client)
|
||||
.await
|
||||
.expect("pull failed");
|
||||
let pulled = match result {
|
||||
crate::remote::PullResult::Hydrated(info) => info,
|
||||
crate::remote::PullResult::NotFound => panic!("pull returned NotFound"),
|
||||
};
|
||||
assert_eq!(pulled.cwd, test_cwd);
|
||||
|
||||
// Verify local storage loads
|
||||
let local_dir = crate::session::persistence::session_dir(&pulled);
|
||||
assert!(local_dir.join("summary.json").exists());
|
||||
assert!(local_dir.join("updates.jsonl").exists());
|
||||
|
||||
let storage = JsonlStorageAdapter::default();
|
||||
let data = storage
|
||||
.load_session_without_updates(&pulled)
|
||||
.await
|
||||
.expect("storage load failed");
|
||||
assert_eq!(data.summary.session_summary, test_title);
|
||||
|
||||
// Verify chat_history has both turns
|
||||
let chat =
|
||||
std::fs::read_to_string(local_dir.join("chat_history.jsonl")).unwrap_or_default();
|
||||
assert!(chat.contains("user"), "chat_history missing user turn");
|
||||
assert!(chat.contains("agent"), "chat_history missing agent turn");
|
||||
|
||||
// Cleanup
|
||||
drop(sync);
|
||||
let _ = client.delete_session_data(&session_id).await;
|
||||
let _ = std::fs::remove_dir_all(&local_dir);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
//! Writeback push: async queue that flushes session updates to the backend.
|
||||
//!
|
||||
//! `RemoteSync` runs a background tokio task that buffers ACP notifications
|
||||
//! and flushes them to the backend via [`BackendClient::save_session_data()`].
|
||||
//!
|
||||
//! ## Backpressure
|
||||
//!
|
||||
//! When the buffer exceeds [`MAX_PENDING`], the task attempts an emergency
|
||||
//! flush. If that also fails (network down), the oldest messages are dropped
|
||||
//! to prevent unbounded memory growth.
|
||||
//!
|
||||
//! ## Drop behavior
|
||||
//!
|
||||
//! When `RemoteSync` is dropped, the sender half of the channel closes and
|
||||
//! the background task exits. **Pending buffered messages are lost.** This
|
||||
//! is acceptable because the local JSONL files are the source of truth —
|
||||
//! writeback is best-effort.
|
||||
|
||||
use crate::remote::BackendClient;
|
||||
use crate::session::export::{ExportedMessage, ExportedMetadata};
|
||||
use agent_client_protocol as acp;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Max buffered notifications before triggering an emergency flush.
|
||||
/// Sized to keep memory under ~50MB even with large notifications.
|
||||
const MAX_PENDING: usize = 512;
|
||||
|
||||
/// How many oldest messages to drop when an emergency flush fails.
|
||||
/// Dropping a batch (not one-by-one) avoids repeated failed flushes.
|
||||
const DROP_BATCH_SIZE: usize = 64;
|
||||
|
||||
enum SyncMsg {
|
||||
Queue(Box<acp::SessionNotification>),
|
||||
Flush,
|
||||
SetTitle(String),
|
||||
SetModelId(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteSync {
|
||||
tx: mpsc::UnboundedSender<SyncMsg>,
|
||||
}
|
||||
|
||||
impl RemoteSync {
|
||||
/// Metadata is included on every flush to keep the backend session row current.
|
||||
pub(crate) fn new(
|
||||
session_id: String,
|
||||
metadata: ExportedMetadata,
|
||||
client: BackendClient,
|
||||
) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(sync_task(session_id, metadata, client, rx));
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub fn queue(&self, notification: acp::SessionNotification) {
|
||||
let _ = self.tx.send(SyncMsg::Queue(Box::new(notification)));
|
||||
}
|
||||
|
||||
pub fn flush(&self) {
|
||||
let _ = self.tx.send(SyncMsg::Flush);
|
||||
}
|
||||
|
||||
pub fn set_title(&self, title: String) {
|
||||
let _ = self.tx.send(SyncMsg::SetTitle(title));
|
||||
}
|
||||
|
||||
pub fn set_model_id(&self, model_id: String) {
|
||||
let _ = self.tx.send(SyncMsg::SetModelId(model_id));
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_flush(
|
||||
client: &BackendClient,
|
||||
session_id: &str,
|
||||
metadata: &ExportedMetadata,
|
||||
pending: &mut Vec<acp::SessionNotification>,
|
||||
) -> bool {
|
||||
if pending.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let messages: Vec<ExportedMessage> = pending
|
||||
.iter()
|
||||
.map(ExportedMessage::from_notification)
|
||||
.collect();
|
||||
|
||||
match client
|
||||
.save_session_data(session_id, &messages, Some(metadata))
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::debug!(count = pending.len(), "Writeback: synced");
|
||||
pending.clear();
|
||||
|
||||
// Link session to agent so the relay can route requests to it.
|
||||
if let Err(e) = client
|
||||
.upsert_session(session_id, metadata, &crate::util::agent_id::agent_id())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "Writeback: failed to upsert session");
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, pending = pending.len(), "Writeback: flush failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_task(
|
||||
session_id: String,
|
||||
mut metadata: ExportedMetadata,
|
||||
client: BackendClient,
|
||||
mut rx: mpsc::UnboundedReceiver<SyncMsg>,
|
||||
) {
|
||||
let mut pending: Vec<acp::SessionNotification> = Vec::new();
|
||||
|
||||
while let Some(msg) = rx.recv().await {
|
||||
match msg {
|
||||
SyncMsg::Queue(n) => {
|
||||
if pending.len() >= MAX_PENDING {
|
||||
tracing::warn!(
|
||||
pending = pending.len(),
|
||||
"Writeback: buffer full, attempting emergency flush"
|
||||
);
|
||||
|
||||
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
if !do_flush(&client, &session_id, &metadata, &mut pending).await {
|
||||
let dropped = pending.drain(0..DROP_BATCH_SIZE.min(pending.len())).count();
|
||||
tracing::error!(
|
||||
dropped = dropped,
|
||||
"Writeback: emergency flush failed, dropping oldest messages"
|
||||
);
|
||||
}
|
||||
}
|
||||
pending.push(*n);
|
||||
}
|
||||
SyncMsg::Flush => {
|
||||
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
do_flush(&client, &session_id, &metadata, &mut pending).await;
|
||||
}
|
||||
SyncMsg::SetTitle(title) => {
|
||||
metadata.title = Some(title);
|
||||
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
if let Err(e) = client
|
||||
.save_session_data(&session_id, &[], Some(&metadata))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "Writeback: failed to sync title to backend");
|
||||
}
|
||||
}
|
||||
SyncMsg::SetModelId(id) => {
|
||||
metadata.model_id = Some(id);
|
||||
metadata.updated_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
if let Err(e) = client
|
||||
.save_session_data(&session_id, &[], Some(&metadata))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "Writeback: failed to sync model_id to backend");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::AuthManager;
|
||||
|
||||
const KIGI_WEB_URL: &str = "https://grok.com";
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Workspace {
|
||||
#[serde(default)]
|
||||
pub workspace_id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub create_time: Option<String>,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WsQuery {
|
||||
pub page_size: i64,
|
||||
pub page_token: Option<String>,
|
||||
pub query: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ListWorkspacesPage {
|
||||
pub workspaces: Vec<Workspace>,
|
||||
pub next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WsError {
|
||||
#[error("no OAuth credentials for workspaces:read")]
|
||||
NoOauth,
|
||||
#[error("network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("request failed: {status}")]
|
||||
Http { status: u16 },
|
||||
#[error("parse error: {0}")]
|
||||
Parse(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ListWorkspacesResponseWire {
|
||||
#[serde(default)]
|
||||
workspaces: Vec<Workspace>,
|
||||
#[serde(default)]
|
||||
next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
pub struct WorkspacesClient {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
auth: Arc<AuthManager>,
|
||||
}
|
||||
|
||||
impl WorkspacesClient {
|
||||
pub fn new(auth: Arc<AuthManager>) -> Self {
|
||||
let base_url = first_nonempty_env(&[
|
||||
"KIGI_WORKSPACES_BASE_URL",
|
||||
"KIGI_CONVERSATIONS_BASE_URL",
|
||||
"KIGI_CODE_WEB_URL",
|
||||
])
|
||||
.unwrap_or_else(|| KIGI_WEB_URL.to_string());
|
||||
Self {
|
||||
http: crate::http::shared_client(),
|
||||
base_url,
|
||||
auth,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_workspaces(&self, q: &WsQuery) -> Result<ListWorkspacesPage, WsError> {
|
||||
let auth = self.auth.auth().await.map_err(|_| WsError::NoOauth)?;
|
||||
if !auth.is_session_auth() {
|
||||
return Err(WsError::NoOauth);
|
||||
}
|
||||
|
||||
let url = format!("{}/rest/workspaces", self.base_url);
|
||||
let mut query: Vec<(&str, String)> = vec![("pageSize", q.page_size.to_string())];
|
||||
if let Some(token) = q.page_token.as_deref().filter(|s| !s.is_empty()) {
|
||||
query.push(("pageToken", token.to_owned()));
|
||||
}
|
||||
if let Some(search) = q.query.as_deref().filter(|s| !s.is_empty()) {
|
||||
query.push(("query", search.to_owned()));
|
||||
}
|
||||
if let Some(kind) = q.kind.as_deref().filter(|s| !s.is_empty()) {
|
||||
query.push(("kind", kind.to_owned()));
|
||||
}
|
||||
|
||||
let mut builder = self
|
||||
.http
|
||||
.get(&url)
|
||||
.query(&query)
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header("x-userid", &auth.user_id)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
"x-grok-client-identifier",
|
||||
crate::http::process_client_identifier(),
|
||||
)
|
||||
.header(
|
||||
crate::http::CLIENT_MODE_HEADER,
|
||||
crate::http::process_client_mode(),
|
||||
)
|
||||
.header(reqwest::header::ACCEPT, "application/json");
|
||||
if let Some(email) = &auth.email {
|
||||
builder = builder.header("x-email", email);
|
||||
}
|
||||
let builder = kigi_file_utils::trace_context::inject_trace_context_into_request(builder);
|
||||
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(WsError::Http {
|
||||
status: status.as_u16(),
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
let wire: ListWorkspacesResponseWire = serde_json::from_slice(&bytes)?;
|
||||
|
||||
Ok(ListWorkspacesPage {
|
||||
workspaces: wire.workspaces,
|
||||
next_page_token: wire.next_page_token.filter(|t| !t.is_empty()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn first_nonempty_env(keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|k| std::env::var(k).ok().filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_parses_camelcase_wire() {
|
||||
let json = serde_json::json!({
|
||||
"workspaces": [{
|
||||
"workspaceId": "ws_9f3a",
|
||||
"name": "GPU vendor research",
|
||||
"createTime": "2026-06-18T17:30:00Z",
|
||||
"kind": "WORKSPACE_KIND_IMAGINE"
|
||||
}],
|
||||
"nextPageToken": "tok2"
|
||||
});
|
||||
let wire: ListWorkspacesResponseWire = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(wire.workspaces.len(), 1);
|
||||
let w = &wire.workspaces[0];
|
||||
assert_eq!(w.workspace_id, "ws_9f3a");
|
||||
assert_eq!(w.name, "GPU vendor research");
|
||||
assert_eq!(w.create_time.as_deref(), Some("2026-06-18T17:30:00Z"));
|
||||
assert_eq!(w.kind.as_deref(), Some("WORKSPACE_KIND_IMAGINE"));
|
||||
assert_eq!(wire.next_page_token.as_deref(), Some("tok2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_default_gracefully() {
|
||||
let json = serde_json::json!({ "workspaces": [{ "workspaceId": "w1" }] });
|
||||
let wire: ListWorkspacesResponseWire = serde_json::from_value(json).unwrap();
|
||||
let w = &wire.workspaces[0];
|
||||
assert_eq!(w.workspace_id, "w1");
|
||||
assert!(w.name.is_empty());
|
||||
assert!(w.create_time.is_none());
|
||||
assert!(w.kind.is_none());
|
||||
assert!(wire.next_page_token.is_none());
|
||||
}
|
||||
}
|
||||
@@ -17,26 +17,36 @@ use agent_client_protocol as acp;
|
||||
/// see this code and show a user-friendly upgrade message instead.
|
||||
pub const RATE_LIMITED_ERROR_CODE: i32 = -32003;
|
||||
|
||||
/// OAuth / session rate-limit copy (personal plan upgrade path).
|
||||
pub const RATE_LIMITED_USER_MESSAGE_OAUTH: &str =
|
||||
"You\u{2019}ve hit the rate limit for your plan. Upgrade your account or try again later.";
|
||||
/// Subscription (OAuth) rate-limit copy. PRD Q3: the official Kimi CLI and
|
||||
/// Kigi draw on the SAME subscription quota, so the message says so — a user
|
||||
/// who also runs `kimi` should understand why the limit arrived early.
|
||||
/// Deliberately promises no reset duration; the quota window is server-side.
|
||||
pub static RATE_LIMITED_USER_MESSAGE_OAUTH: std::sync::LazyLock<String> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
format!(
|
||||
"You\u{2019}ve hit the usage limit of your Kimi subscription. Note that Kigi and the \
|
||||
official Kimi CLI share the same subscription quota. Upgrade your plan at {} or try \
|
||||
again later.",
|
||||
kigi_env::upgrade_page_url()
|
||||
)
|
||||
});
|
||||
|
||||
/// API key / team rate-limit copy. Personal grok.com upgrades do not raise API
|
||||
/// team limits; admins purchase credits or a higher spend-based tier.
|
||||
/// See https://docs.x.ai/developers/rate-limits#rate-limit-tiers
|
||||
pub const RATE_LIMITED_USER_MESSAGE_API_KEY: &str = "You\u{2019}ve hit your team\u{2019}s API rate limit. Ask a team admin to purchase more credits for higher limits, or try again later. See https://docs.x.ai/developers/rate-limits#rate-limit-tiers";
|
||||
/// Moonshot API-key rate-limit copy. Platform keys are tier-limited (RPM/TPM);
|
||||
/// raising the tier happens in the Moonshot Open Platform console, not via a
|
||||
/// Kimi subscription.
|
||||
pub const RATE_LIMITED_USER_MESSAGE_API_KEY: &str = "You\u{2019}ve hit the rate limit for your Moonshot API key. Check your tier\u{2019}s limits in the Moonshot Open Platform console (platform.moonshot.ai or platform.moonshot.cn), or try again later.";
|
||||
|
||||
/// Pick rate-limit copy from the *active* auth method.
|
||||
///
|
||||
/// Pass the real `is_api_key_auth` flag (pager `AppView`, `AuthMethodKind::is_api_key`
|
||||
/// for the selected method). Do **not** decide from `has_xai_api_key_env()` alone:
|
||||
/// for the selected method). Do **not** decide from the key env var alone:
|
||||
/// when both an env key and a cached OAuth session exist, auth prefers the
|
||||
/// cached session over the API key.
|
||||
pub fn rate_limited_user_message(is_api_key_auth: bool) -> &'static str {
|
||||
if is_api_key_auth {
|
||||
RATE_LIMITED_USER_MESSAGE_API_KEY
|
||||
} else {
|
||||
RATE_LIMITED_USER_MESSAGE_OAUTH
|
||||
RATE_LIMITED_USER_MESSAGE_OAUTH.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,20 +328,20 @@ mod tests {
|
||||
fn rate_limited_user_message_oauth_vs_api_key() {
|
||||
assert_eq!(
|
||||
rate_limited_user_message(false),
|
||||
RATE_LIMITED_USER_MESSAGE_OAUTH
|
||||
RATE_LIMITED_USER_MESSAGE_OAUTH.as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limited_user_message(true),
|
||||
RATE_LIMITED_USER_MESSAGE_API_KEY
|
||||
);
|
||||
assert!(RATE_LIMITED_USER_MESSAGE_OAUTH.contains("Upgrade your account"));
|
||||
assert!(RATE_LIMITED_USER_MESSAGE_API_KEY.contains("team"));
|
||||
assert!(RATE_LIMITED_USER_MESSAGE_API_KEY.contains("credits"));
|
||||
assert!(
|
||||
RATE_LIMITED_USER_MESSAGE_API_KEY
|
||||
.contains("https://docs.x.ai/developers/rate-limits#rate-limit-tiers")
|
||||
);
|
||||
assert!(!RATE_LIMITED_USER_MESSAGE_API_KEY.contains("Upgrade your account"));
|
||||
// PRD Q3: the subscription copy must state the shared quota with the
|
||||
// official Kimi CLI and point at the upgrade page.
|
||||
assert!(RATE_LIMITED_USER_MESSAGE_OAUTH.contains("official Kimi CLI"));
|
||||
assert!(RATE_LIMITED_USER_MESSAGE_OAUTH.contains("same subscription quota"));
|
||||
assert!(RATE_LIMITED_USER_MESSAGE_OAUTH.contains(kigi_env::upgrade_page_url()));
|
||||
// API-key copy points at the Moonshot platform, not the subscription.
|
||||
assert!(RATE_LIMITED_USER_MESSAGE_API_KEY.contains("Moonshot"));
|
||||
assert!(!RATE_LIMITED_USER_MESSAGE_API_KEY.contains("subscription quota"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -341,7 +351,6 @@ mod tests {
|
||||
message: "Rate limit exceeded".into(),
|
||||
model_metadata: None,
|
||||
retry_after_secs: None,
|
||||
should_retry: None,
|
||||
};
|
||||
let acp_err = map_sampling_err_to_acp(err);
|
||||
assert_eq!(acp_err.code, acp::ErrorCode::from(RATE_LIMITED_ERROR_CODE));
|
||||
@@ -359,7 +368,6 @@ mod tests {
|
||||
message: "Rate limit exceeded".into(),
|
||||
model_metadata: None,
|
||||
retry_after_secs: Some(60),
|
||||
should_retry: None,
|
||||
};
|
||||
assert_eq!(err.retry_after(), Some(60));
|
||||
let acp_err = map_sampling_err_to_acp(err);
|
||||
@@ -374,14 +382,12 @@ mod tests {
|
||||
message: "limited".into(),
|
||||
model_metadata: None,
|
||||
retry_after_secs: None,
|
||||
should_retry: None,
|
||||
};
|
||||
let server_err = SamplingError::Api {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: "oops".into(),
|
||||
model_metadata: None,
|
||||
retry_after_secs: None,
|
||||
should_retry: None,
|
||||
};
|
||||
let rate_acp = map_sampling_err_to_acp(rate_err);
|
||||
let server_acp = map_sampling_err_to_acp(server_err);
|
||||
@@ -398,7 +404,6 @@ mod tests {
|
||||
message: "bad token".into(),
|
||||
model_metadata: None,
|
||||
retry_after_secs: None,
|
||||
should_retry: None,
|
||||
};
|
||||
let acp_err = map_sampling_err_to_acp(err);
|
||||
assert_eq!(acp_err.code, acp::Error::auth_required().code);
|
||||
@@ -420,7 +425,6 @@ mod tests {
|
||||
.into(),
|
||||
model_metadata: None,
|
||||
retry_after_secs: None,
|
||||
should_retry: None,
|
||||
};
|
||||
let acp_err = map_sampling_err_to_acp(err);
|
||||
assert_ne!(
|
||||
@@ -476,7 +480,6 @@ mod tests {
|
||||
message: "The model 'grok-build' requires a Grok subscription.".into(),
|
||||
model_metadata: None,
|
||||
retry_after_secs: None,
|
||||
should_retry: None,
|
||||
};
|
||||
let acp_err = map_sampling_err_to_acp(err);
|
||||
let data = acp_err.data.unwrap();
|
||||
@@ -501,7 +504,6 @@ mod tests {
|
||||
message: "The model 'grok-build' requires a Grok subscription.".into(),
|
||||
model_metadata: None,
|
||||
retry_after_secs: None,
|
||||
should_retry: None,
|
||||
};
|
||||
let acp_err = map_sampling_err_to_acp(err);
|
||||
let data = acp_err.data.unwrap();
|
||||
@@ -522,7 +524,6 @@ mod tests {
|
||||
message: "Content violates usage guidelines.".into(),
|
||||
model_metadata: None,
|
||||
retry_after_secs: None,
|
||||
should_retry: None,
|
||||
};
|
||||
let acp_err = map_sampling_err_to_acp(err);
|
||||
let data = acp_err.data.unwrap();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4
-8
@@ -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,
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
-1
@@ -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 (0–100).
|
||||
#[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,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,
|
||||
|
||||
@@ -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(¬ification) {
|
||||
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;
|
||||
|
||||
@@ -47,15 +47,11 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
|
||||
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,
|
||||
|
||||
@@ -205,7 +205,7 @@ impl ShellToolsetConfig {
|
||||
pub fn new(base: Option<Self>, sampling_config: Option<SamplerConfig>) -> Self {
|
||||
let default_base = SamplerConfig {
|
||||
api_key: None,
|
||||
base_url: "https://api.x.ai/v1".to_string(),
|
||||
base_url: kigi_env::coding_api_base_url(),
|
||||
model: String::new(),
|
||||
max_completion_tokens: None,
|
||||
temperature: None,
|
||||
@@ -214,15 +214,11 @@ impl ShellToolsetConfig {
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: indexmap::IndexMap::new(),
|
||||
context_window: 256_000,
|
||||
client_version: None,
|
||||
reasoning_effort: None,
|
||||
force_http1: false,
|
||||
max_retries: None,
|
||||
stream_tool_calls: false,
|
||||
idle_timeout_secs: None,
|
||||
client_identifier: None,
|
||||
deployment_id: None,
|
||||
user_id: None,
|
||||
origin_client: None,
|
||||
// Default base for the in-process web-search tool config.
|
||||
// Real `SamplerConfig`s (e.g. from `sampling_config_for_model`)
|
||||
|
||||
@@ -67,8 +67,7 @@ fn resolve_auto_permission_mode_layers(
|
||||
}
|
||||
|
||||
/// Resolve whether the **auto** permission mode feature (`PermissionMode::Auto`,
|
||||
/// the LLM/heuristic classifier) is enabled. Full chain mirroring
|
||||
/// [`resolve_zdr_access_enabled`](super::resolve_zdr_access_enabled):
|
||||
/// the LLM/heuristic classifier) is enabled. Full precedence chain:
|
||||
///
|
||||
/// requirements > env (`KIGI_AUTO_PERMISSION_MODE`) > `[auto_mode] enabled` in
|
||||
/// `config.toml` > managed > remote settings (`auto_mode.enabled`, coerced
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
use crate::util::config::RemoteSettings;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
/// Resolve whether ZDR users are allowed to use the product.
|
||||
///
|
||||
/// Precedence: requirements > env > config.toml > managed > remote settings > default (false).
|
||||
pub fn resolve_zdr_access_enabled(
|
||||
requirements: Option<&TomlValue>,
|
||||
user: Option<&TomlValue>,
|
||||
managed: Option<&TomlValue>,
|
||||
remote: Option<&RemoteSettings>,
|
||||
) -> bool {
|
||||
use crate::agent::config::BoolFlag;
|
||||
fn from_toml(v: Option<&TomlValue>) -> Option<bool> {
|
||||
v?.get("features")?.get("zdr_access_enabled")?.as_bool()
|
||||
}
|
||||
BoolFlag::env("KIGI_ZDR_ACCESS_ENABLED")
|
||||
.requirement(from_toml(requirements))
|
||||
.config(from_toml(user))
|
||||
.managed(from_toml(managed))
|
||||
.feature_flag(remote.and_then(|r| r.zdr_access_enabled))
|
||||
.resolve()
|
||||
.value
|
||||
}
|
||||
|
||||
/// Whether model-catalog (`/v1/models`) and remote-settings (`/v1/settings`)
|
||||
/// fetches from xAI backends are allowed, including the deployment-config sync
|
||||
/// bundled into the startup prefetch (the background managed-config sync has
|
||||
|
||||
@@ -43,15 +43,11 @@ pub fn test_sampler_config(
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect(),
|
||||
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,
|
||||
|
||||
@@ -431,7 +431,7 @@ fn git_rebase_refresh_storm_e2e() {
|
||||
// serve HTTP and never read the process environment.
|
||||
unsafe {
|
||||
std::env::set_var("KIGI_SHARE_DIR", kigi_home.path());
|
||||
std::env::set_var("KIGI_CLI_CHAT_PROXY_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_CODE_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_XAI_API_BASE_URL", server.url());
|
||||
std::env::set_var("XAI_API_KEY", "test-key-for-ci");
|
||||
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
||||
|
||||
@@ -625,7 +625,7 @@ async fn full_session_load_e2e() {
|
||||
std::env::set_var("KIGI_SHARE_DIR", kigi_home.path());
|
||||
std::env::set_var("KIGI_INSTRUMENTATION", "log");
|
||||
std::env::set_var("KIGI_INSTRUMENTATION_LOG", &instr_log);
|
||||
std::env::set_var("KIGI_CLI_CHAT_PROXY_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_CODE_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_XAI_API_BASE_URL", server.url());
|
||||
std::env::set_var("XAI_API_KEY", "test-key-for-ci");
|
||||
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn signed_dk_body(
|
||||
requirements: Option<&str>,
|
||||
) -> String {
|
||||
let payload = SignedPayload {
|
||||
version: prod_mc_cli_chat_proxy_types::SIGNED_PAYLOAD_VERSION,
|
||||
version: kigi_config::signed_policy::SIGNED_PAYLOAD_VERSION,
|
||||
deployment_id: Some(deployment_id.to_owned()),
|
||||
team_id: None,
|
||||
managed_config: managed.map(str::to_owned),
|
||||
|
||||
@@ -38,7 +38,7 @@ async fn sync_fail_closed_policy(home: &std::path::Path, kp: &ring::signature::E
|
||||
/// provisioned key with no config row.
|
||||
fn signed_dk_empty_body(kp: &ring::signature::Ed25519KeyPair, deployment_id: &str) -> String {
|
||||
let payload = SignedPayload {
|
||||
version: prod_mc_cli_chat_proxy_types::SIGNED_PAYLOAD_VERSION,
|
||||
version: kigi_config::signed_policy::SIGNED_PAYLOAD_VERSION,
|
||||
deployment_id: Some(deployment_id.to_owned()),
|
||||
team_id: None,
|
||||
managed_config: None,
|
||||
|
||||
@@ -1267,7 +1267,7 @@ impl ConfigTestHarness {
|
||||
home,
|
||||
workdir: git_workdir(),
|
||||
env: vec![
|
||||
("KIGI_CLI_CHAT_PROXY_BASE_URL".into(), server.url()),
|
||||
("KIGI_CODE_BASE_URL".into(), server.url()),
|
||||
("KIGI_TELEMETRY_ENABLED".into(), "false".into()),
|
||||
("KIGI_FEEDBACK_ENABLED".into(), "false".into()),
|
||||
("KIGI_TRACE_UPLOAD".into(), "false".into()),
|
||||
|
||||
@@ -136,7 +136,7 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
||||
// code reads these process-globals (same pattern as session_load_perf).
|
||||
unsafe {
|
||||
std::env::set_var("KIGI_SHARE_DIR", kigi_home.path());
|
||||
std::env::set_var("KIGI_CLI_CHAT_PROXY_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_CODE_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_XAI_API_BASE_URL", server.url());
|
||||
std::env::set_var("XAI_API_KEY", "test-key-for-ci");
|
||||
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
//! Integration test: MockInferenceServer `/v1/settings` endpoint and
|
||||
//! remote settings settings refresh infrastructure.
|
||||
//!
|
||||
//! Tests the mock endpoint directly (no binary needed) and verifies
|
||||
//! the `fetch_settings_blocking` client round-trips correctly with
|
||||
//! runtime-mutated mock settings.
|
||||
//!
|
||||
//! Run locally:
|
||||
//! ```bash
|
||||
//! cargo test -p kigi-shell --test test_settings_refresh
|
||||
//! ```
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use kigi_shell::util::config::RemoteSettings;
|
||||
use kigi_test_support::*;
|
||||
|
||||
async fn with_local_set<F, Fut>(f: F)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
tokio::task::LocalSet::new().run_until(f()).await;
|
||||
}
|
||||
|
||||
/// Verify the mock `/v1/settings` endpoint returns 404 when no settings
|
||||
/// are configured (the default). This preserves backward compatibility:
|
||||
/// existing tests that never call `set_settings` see a 404, and
|
||||
/// `fetch_settings_blocking` returns `None`.
|
||||
#[tokio::test]
|
||||
async fn test_settings_endpoint_returns_404_when_unconfigured() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
.expect("start mock server");
|
||||
|
||||
let resp = reqwest::get(format!("{}/settings", server.url()))
|
||||
.await
|
||||
.expect("request failed");
|
||||
assert_eq!(resp.status(), 404);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Verify the mock `/v1/settings` endpoint returns configured settings
|
||||
/// and that `set_settings` runtime mutation is reflected immediately.
|
||||
#[tokio::test]
|
||||
async fn test_settings_endpoint_returns_configured_settings() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
.expect("start mock server");
|
||||
|
||||
// Configure initial settings
|
||||
server.set_settings(RemoteSettings {
|
||||
tips: Some(vec!["tip_v1".into()]),
|
||||
leader_mode: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Fetch and verify
|
||||
let resp = reqwest::get(format!("{}/settings", server.url()))
|
||||
.await
|
||||
.expect("request failed");
|
||||
assert_eq!(resp.status(), 200);
|
||||
let settings: RemoteSettings = resp.json().await.expect("parse failed");
|
||||
assert_eq!(settings.tips, Some(vec!["tip_v1".into()]));
|
||||
assert_eq!(settings.leader_mode, Some(false));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Verify that `set_settings` updates are visible to subsequent requests
|
||||
/// (runtime mutation for multi-session test scenarios).
|
||||
#[tokio::test]
|
||||
async fn test_settings_endpoint_reflects_runtime_mutations() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
.expect("start mock server");
|
||||
|
||||
// Initial settings
|
||||
server.set_settings(RemoteSettings {
|
||||
tips: Some(vec!["tip_v1".into()]),
|
||||
..Default::default()
|
||||
});
|
||||
let settings: RemoteSettings = reqwest::get(format!("{}/settings", server.url()))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings.tips, Some(vec!["tip_v1".into()]));
|
||||
|
||||
// Mutate settings (simulating a remote feature flag change)
|
||||
server.set_settings(RemoteSettings {
|
||||
tips: Some(vec!["tip_v2".into()]),
|
||||
leader_mode: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Subsequent request sees the updated values
|
||||
let settings: RemoteSettings = reqwest::get(format!("{}/settings", server.url()))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings.tips, Some(vec!["tip_v2".into()]));
|
||||
assert_eq!(settings.leader_mode, Some(true));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Verify `fetch_settings_blocking` round-trips through the mock server.
|
||||
/// This is the actual client function used by `refresh_remote_settings`.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_settings_blocking_round_trip() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
.expect("start mock server");
|
||||
|
||||
// Without settings configured: returns None (404 from mock)
|
||||
let auth = kigi_shell::auth::KimiAuth {
|
||||
key: "test-key".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let url = server.url().to_string();
|
||||
let auth = auth.clone();
|
||||
move || kigi_shell::remote::fetch_settings_blocking(&url, &auth, None)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Expected None when settings not configured"
|
||||
);
|
||||
|
||||
// With settings configured: returns Some(settings)
|
||||
server.set_settings(RemoteSettings {
|
||||
tips: Some(vec!["fetched_tip".into()]),
|
||||
..Default::default()
|
||||
});
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let url = server.url().to_string();
|
||||
let auth = auth.clone();
|
||||
move || kigi_shell::remote::fetch_settings_blocking(&url, &auth, None)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let settings = result.expect("Expected Some when settings are configured");
|
||||
assert_eq!(settings.tips, Some(vec!["fetched_tip".into()]));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Verify the `doom_loop_recovery` settings object survives the
|
||||
/// `/v1/settings` round-trip, that its absence deserializes to `None` (old
|
||||
/// servers), and that a partial object keeps its unset fields `None`.
|
||||
#[tokio::test]
|
||||
async fn test_doom_loop_recovery_settings_round_trip() {
|
||||
use kigi_shell::util::config::DoomLoopRecoverySettings;
|
||||
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
.expect("start mock server");
|
||||
|
||||
// Absent from the payload ⇒ None on the client.
|
||||
server.set_settings(RemoteSettings::default());
|
||||
let settings: RemoteSettings = reqwest::get(format!("{}/settings", server.url()))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings.doom_loop_recovery, None);
|
||||
|
||||
server.set_settings(RemoteSettings {
|
||||
doom_loop_recovery: Some(DoomLoopRecoverySettings {
|
||||
enabled: Some(true),
|
||||
max_threshold: Some(16),
|
||||
max_retries: Some(1),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let settings: RemoteSettings = reqwest::get(format!("{}/settings", server.url()))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let recovery = settings.doom_loop_recovery.expect("object round-trips");
|
||||
assert_eq!(recovery.enabled, Some(true));
|
||||
assert_eq!(recovery.max_threshold, Some(16));
|
||||
assert_eq!(recovery.max_retries, Some(1));
|
||||
|
||||
// Partial object: only the set field comes through; the rest stay
|
||||
// None so the resolver falls through per-field.
|
||||
server.set_settings(RemoteSettings {
|
||||
doom_loop_recovery: Some(DoomLoopRecoverySettings {
|
||||
max_threshold: Some(32),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let settings: RemoteSettings = reqwest::get(format!("{}/settings", server.url()))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let recovery = settings.doom_loop_recovery.expect("object round-trips");
|
||||
assert_eq!(recovery.enabled, None);
|
||||
assert_eq!(recovery.max_threshold, Some(32));
|
||||
assert_eq!(recovery.max_retries, None);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Verify that the mock server's request log correctly tracks
|
||||
/// GET /v1/settings requests for assertion in multi-session tests.
|
||||
#[tokio::test]
|
||||
async fn test_settings_requests_appear_in_request_log() {
|
||||
with_local_set(|| async {
|
||||
let server = MockInferenceServer::start()
|
||||
.await
|
||||
.expect("start mock server");
|
||||
server.set_settings(RemoteSettings::default());
|
||||
|
||||
assert_eq!(server.request_count(), 0);
|
||||
|
||||
// First request
|
||||
let _ = reqwest::get(format!("{}/settings", server.url()))
|
||||
.await
|
||||
.unwrap();
|
||||
let settings_reqs: Vec<_> = server
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|r| r.method == "GET" && r.path.contains("/settings"))
|
||||
.collect();
|
||||
assert_eq!(settings_reqs.len(), 1, "Expected 1 settings request");
|
||||
|
||||
// Second request (simulating /new refresh)
|
||||
let _ = reqwest::get(format!("{}/settings", server.url()))
|
||||
.await
|
||||
.unwrap();
|
||||
let settings_reqs: Vec<_> = server
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|r| r.method == "GET" && r.path.contains("/settings"))
|
||||
.collect();
|
||||
assert_eq!(settings_reqs.len(), 2, "Expected 2 settings requests");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Reference in New Issue
Block a user