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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user