M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
pub(crate) mod model_switch;
|
||||
pub(crate) mod session;
|
||||
pub(crate) mod workspaces;
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Applies a model switch to a session — the ungated path. `set_session_model`
|
||||
//! enforces the `allowed_models` gate before delegating here; internal callers
|
||||
//! (`new_session`, `load_session`) call `apply` directly.
|
||||
use crate::agent::config;
|
||||
use crate::agent::mvp_agent::{
|
||||
MvpAgent, agent_name_after_model_switch, harnesses_are_compatible, resolve_required_agent_type,
|
||||
};
|
||||
use crate::session::SessionCommand;
|
||||
use agent_client_protocol::{self as acp};
|
||||
use kigi_sampling_types::parse_reasoning_effort_meta;
|
||||
use tokio::sync::oneshot;
|
||||
/// Apply a model switch to a session (no gate — `set_session_model` gates first).
|
||||
pub(crate) async fn apply(
|
||||
agent: &MvpAgent,
|
||||
args: acp::SetSessionModelRequest,
|
||||
) -> Result<acp::SetSessionModelResponse, acp::Error> {
|
||||
tracing::info!("Received set session model request {args:?}");
|
||||
kigi_log::unified_log::info(
|
||||
"model changed",
|
||||
Some(args.session_id.0.as_ref()),
|
||||
Some(serde_json::json!({ "model" : args.model_id.0.as_ref() })),
|
||||
);
|
||||
tracing::debug!("session_session_model::mvp_agent: {:?}", &args);
|
||||
let effort_override = parse_reasoning_effort_meta(args.meta.as_ref());
|
||||
let acp::SetSessionModelRequest {
|
||||
session_id,
|
||||
model_id,
|
||||
..
|
||||
} = args;
|
||||
let handle = agent
|
||||
.session_handle_waiting_for_load(&session_id)
|
||||
.await
|
||||
.ok_or_else(|| acp::Error::invalid_params().data("unknown session id"))?;
|
||||
let model = agent.resolve_model_id(&model_id)?;
|
||||
let use_concise = model.info().use_concise;
|
||||
let session_default = handle
|
||||
.session_default_agent_profile
|
||||
.as_deref()
|
||||
.unwrap_or(&handle.agent_name);
|
||||
let required_agent_type =
|
||||
resolve_required_agent_type(Some(model.info().agent_type.as_str()), session_default);
|
||||
let previous_model_id = handle.model_id.0.clone();
|
||||
let mut pending_rebuild_definition: Option<kigi_agent::AgentDefinition> = None;
|
||||
{
|
||||
let required = &required_agent_type;
|
||||
let turn_count = handle
|
||||
.signals_handle
|
||||
.snapshot()
|
||||
.await
|
||||
.map(|s| s.turn_count)
|
||||
.unwrap_or(0);
|
||||
let (agent_tx, agent_rx) = oneshot::channel();
|
||||
let _ = handle.cmd_tx.send(SessionCommand::GetActiveAgent {
|
||||
responds_to: agent_tx,
|
||||
});
|
||||
let active_agent_type = agent_rx.await.ok().flatten();
|
||||
let is_mismatch = active_agent_type
|
||||
.as_ref()
|
||||
.is_some_and(|active| !harnesses_are_compatible(active, required));
|
||||
tracing::info!(
|
||||
session_id = % session_id.0, model_id = % model_id.0, ? required_agent_type,
|
||||
? active_agent_type, turn_count, is_mismatch,
|
||||
"set_session_model: agent type compatibility check"
|
||||
);
|
||||
if is_mismatch && turn_count > 0 {
|
||||
tracing::warn!(
|
||||
session_id = % session_id.0, model_id = % model_id.0, active_agent = ?
|
||||
active_agent_type, required_agent = % required, turn_count,
|
||||
"set_session_model: agent type mismatch rejected"
|
||||
);
|
||||
let err_payload = config::ModelSwitchIncompatibleAgentError {
|
||||
code: config::MODEL_SWITCH_INCOMPATIBLE_AGENT.to_string(),
|
||||
active_agent_type: active_agent_type.unwrap_or_else(|| "unknown".to_owned()),
|
||||
required_agent_type: required.clone(),
|
||||
model_id: model_id.0.to_string(),
|
||||
suggestion: "start_new_session".to_string(),
|
||||
};
|
||||
return Err(err_payload.into_acp_error());
|
||||
}
|
||||
if is_mismatch && turn_count == 0 {
|
||||
let cwd = handle.tool_context.cwd.as_path();
|
||||
let resolved = kigi_agent::discovery::by_name_in_cwd_with_plugins(
|
||||
required,
|
||||
cwd,
|
||||
agent.plugin_registry_handle.snapshot().as_deref(),
|
||||
);
|
||||
match resolved {
|
||||
Some(def) => {
|
||||
tracing::info!(
|
||||
session_id = % session_id.0, model_id = % model_id.0,
|
||||
required_agent_type = % required, agent_def_name = % def.name,
|
||||
"set_session_model: zero-turn harness switch — queued agent rebuild"
|
||||
);
|
||||
pending_rebuild_definition = Some(def);
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
session_id = % session_id.0, model_id = % model_id.0,
|
||||
required_agent_type = % required,
|
||||
"set_session_model: zero-turn harness switch — could not resolve agent definition; proceeding with stale harness"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut model_sampling =
|
||||
agent.prepare_sampling_config_for_model(&model, handle.origin_client.clone());
|
||||
if let Some(eff) = effort_override {
|
||||
if agent
|
||||
.models_manager
|
||||
.model_supports_reasoning_effort(model_id.0.as_ref())
|
||||
{
|
||||
tracing::info!(
|
||||
session_id = % session_id.0, effort = % eff,
|
||||
"set_session_model: applying reasoning_effort override from meta"
|
||||
);
|
||||
model_sampling.reasoning_effort = Some(eff);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
session_id = % session_id.0, model_id = % model_id.0, effort = % eff,
|
||||
"set_session_model: ignoring reasoning_effort override — model does not support it"
|
||||
);
|
||||
}
|
||||
}
|
||||
let applied_effort = model_sampling.reasoning_effort;
|
||||
let gate_closed = !handle
|
||||
.gateway_enabled
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let apply_prompt_override = !gate_closed;
|
||||
if gate_closed {
|
||||
tracing::info!(
|
||||
session_id = % session_id.0, model_id = % model_id.0,
|
||||
"set_session_model: gateway gate closed, prompt override suppressed"
|
||||
);
|
||||
pending_rebuild_definition = None;
|
||||
}
|
||||
let did_rebuild = if let Some(def) = pending_rebuild_definition {
|
||||
let (rebuild_tx, rebuild_rx) = oneshot::channel();
|
||||
let _ = handle
|
||||
.cmd_tx
|
||||
.send(SessionCommand::RebuildAgentForDefinition {
|
||||
definition: def,
|
||||
responds_to: rebuild_tx,
|
||||
});
|
||||
let rebuild_result = rebuild_rx
|
||||
.await
|
||||
.map_err(|_| acp::Error::internal_error().data("rebuild_agent: actor closed"))?;
|
||||
match rebuild_result {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
session_id = % session_id.0, model_id = % model_id.0, error = ? e,
|
||||
"set_session_model: zero-turn harness rebuild failed; aborting model switch"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let model_unchanged = previous_model_id == model_id.0;
|
||||
let new_threshold = {
|
||||
let cfg = agent.cfg.borrow();
|
||||
let models = agent.models_manager.models();
|
||||
let model = config::find_model_by_id(&models, model_sampling.model.as_str());
|
||||
crate::util::config::resolve_auto_compact_threshold_percent(
|
||||
&cfg,
|
||||
model_sampling.model.as_str(),
|
||||
model.map(|e| &e.info),
|
||||
)
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = handle.cmd_tx.send(SessionCommand::SetSessionModel {
|
||||
sampling_config: model_sampling,
|
||||
use_concise,
|
||||
apply_prompt_override,
|
||||
skip_prompt_rewrite: did_rebuild || model_unchanged,
|
||||
auto_compact_threshold_percent: new_threshold,
|
||||
responds_to: tx,
|
||||
});
|
||||
let updated_model = rx
|
||||
.await
|
||||
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))?;
|
||||
if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) {
|
||||
handle.model_id = model_id.clone();
|
||||
handle.reasoning_effort = applied_effort;
|
||||
handle.agent_name =
|
||||
agent_name_after_model_switch(did_rebuild, &required_agent_type, &handle.agent_name);
|
||||
}
|
||||
broadcast_model_changed(
|
||||
agent,
|
||||
&session_id,
|
||||
model_id.0.as_ref(),
|
||||
applied_effort.map(|eff| eff.to_string()),
|
||||
);
|
||||
if agent.cfg.borrow().mode != config::AgentMode::Leader {
|
||||
agent.models_manager.set_current_model_id(model_id);
|
||||
agent
|
||||
.models_manager
|
||||
.set_current_reasoning_effort(applied_effort);
|
||||
}
|
||||
Ok(acp::SetSessionModelResponse::new().meta(
|
||||
serde_json::json!({ "model" : updated_model, })
|
||||
.as_object()
|
||||
.cloned(),
|
||||
))
|
||||
}
|
||||
/// Broadcast a `ModelChanged` to every client subscribed to this session so
|
||||
/// followers mirror the new model. The originating client ignores its own echo
|
||||
/// (gated by `model_switch_pending`). Broadcast-only — no eventId, not persisted.
|
||||
fn broadcast_model_changed(
|
||||
agent: &MvpAgent,
|
||||
session_id: &acp::SessionId,
|
||||
model_id: &str,
|
||||
reasoning_effort: Option<String>,
|
||||
) {
|
||||
let notification = crate::extensions::notification::SessionNotification {
|
||||
session_id: session_id.clone(),
|
||||
update: crate::extensions::notification::SessionUpdate::ModelChanged {
|
||||
model_id: model_id.to_owned(),
|
||||
reasoning_effort,
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
if let Ok(params) = serde_json::value::to_raw_value(¬ification) {
|
||||
agent
|
||||
.gateway
|
||||
.forward_fire_and_forget(acp::ExtNotification::new(
|
||||
"x.ai/session_notification",
|
||||
params.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Session meta-information handlers.
|
||||
//!
|
||||
//! Router pattern: single `handle()` dispatches by method name.
|
||||
//! Business logic delegates to pure functions or MvpAgent methods.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_client_protocol::{self as acp};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::super::mvp_agent::MvpAgent;
|
||||
use crate::session::persistence::{Summary, list_recent_summaries, list_summaries};
|
||||
use crate::session::{
|
||||
AllSessionOverviewRequest, AllSessionOverviewResponse, ContextInfo, ExtMethodResult,
|
||||
SessionCommand, SessionInfoData, SessionInfoResponse, SessionListRequest, SessionListResponse,
|
||||
};
|
||||
|
||||
/// Mirrors the display title (`generated_title`, else `session_summary`) into
|
||||
/// `session_summary` so clients that only read that field show the same title
|
||||
/// as `display_title()` — including after a `/rename` that updated only
|
||||
/// `generated_title`. Mutates the response copy only; never persisted.
|
||||
fn backfill_session_summary(summary: &mut Summary) {
|
||||
let display = summary.display_title().to_owned();
|
||||
if !display.is_empty() && display != summary.session_summary {
|
||||
summary.session_summary = display;
|
||||
}
|
||||
}
|
||||
|
||||
/// Router for x.ai/session/* and x.ai/session_summaries/* methods.
|
||||
pub async fn handle(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/session/info" => handle_session_info(agent, args).await,
|
||||
"x.ai/session/close" => handle_session_close(agent, args).await,
|
||||
"x.ai/session/list" => handle_session_list(agent, args).await,
|
||||
"x.ai/sessions/list" => handle_roster_list(agent, args).await,
|
||||
m if m.starts_with("x.ai/session_summaries/") => {
|
||||
handle_session_summaries(agent, args).await
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `x.ai/sessions/list` — the FleetView roster. Returns every
|
||||
/// resident session plus recently-touched on-disk `Dormant` sessions. Clients
|
||||
/// poll this while the dashboard is open and reconcile against the
|
||||
/// `x.ai/sessions/changed` broadcast.
|
||||
async fn handle_roster_list(
|
||||
agent: &MvpAgent,
|
||||
_args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
let sessions = agent.build_roster().await;
|
||||
ExtMethodResult::success(crate::agent::roster::RosterListResponse { sessions })
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SessionInfoRequest {
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RecentSessionsRequest {
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
async fn handle_session_info(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
let req: SessionInfoRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
|
||||
let session_id = req.session_id.or_else(|| {
|
||||
agent
|
||||
.sessions
|
||||
.borrow()
|
||||
.keys()
|
||||
.next()
|
||||
.map(|id| id.0.to_string())
|
||||
});
|
||||
|
||||
let Some(session_id) = session_id else {
|
||||
return ExtMethodResult::success(serde_json::json!({}))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()));
|
||||
};
|
||||
|
||||
let sid = acp::SessionId::new(session_id.clone());
|
||||
let Some(session) = agent.sessions.borrow().get(&sid).cloned() else {
|
||||
return ExtMethodResult::success(serde_json::json!({}))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()));
|
||||
};
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let _ = session
|
||||
.cmd_tx
|
||||
.send(SessionCommand::GetSessionInfo { responds_to: tx });
|
||||
let info = rx.await.ok();
|
||||
|
||||
// Construct display data for `/session-info`.
|
||||
let mut data = info.unwrap_or_else(|| SessionInfoData {
|
||||
agent_name: None,
|
||||
model: None,
|
||||
model_display_name: None,
|
||||
resolved_model_id: None,
|
||||
model_fingerprint: None,
|
||||
show_model_fingerprint: false,
|
||||
api_backend: None,
|
||||
conversation_id: None,
|
||||
turns: 0,
|
||||
turn_index: 0,
|
||||
context: ContextInfo {
|
||||
auto_compact_threshold_percent:
|
||||
crate::util::config::DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT,
|
||||
..ContextInfo::default()
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate the model's display name.
|
||||
data.model_display_name = agent
|
||||
.models_manager
|
||||
.models()
|
||||
.get(session.model_id.0.as_ref())
|
||||
.and_then(|entry| entry.info.name.clone());
|
||||
|
||||
// Construct `SessionInfoResponse`.
|
||||
let response = SessionInfoResponse {
|
||||
session_id,
|
||||
cwd: session.info.cwd.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
// Wrap `SessionInfoResponse` in `ExtMethodResult` and return it.
|
||||
ExtMethodResult::success(serde_json::to_value(&response).unwrap_or_default())
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
async fn handle_session_close(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CloseRequest {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
let req: CloseRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
|
||||
let sid = acp::SessionId::new(req.session_id.clone());
|
||||
let existed = agent.sessions.borrow().contains_key(&sid);
|
||||
if existed {
|
||||
// Explicit terminal close: shut the actor down and finalize the cloud
|
||||
// replica (genuine session end). Distinct from a mere client disconnect,
|
||||
// which detaches but keeps the session resumable and never finalizes
|
||||
// (see `MvpAgent::handle_evict_sessions` / `close_session_explicit`).
|
||||
agent.request_session_shutdown(&sid);
|
||||
agent.close_session_explicit(&sid);
|
||||
tracing::info!(session_id = %req.session_id, "session closed via x.ai/session/close");
|
||||
} else {
|
||||
tracing::debug!(session_id = %req.session_id, "session/close: session not found (already closed)");
|
||||
}
|
||||
|
||||
ExtMethodResult::success(serde_json::json!({ "success": true }))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
async fn handle_session_summaries(
|
||||
_agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/session_summaries/session_list" => {
|
||||
let req = serde_json::from_str::<SessionListRequest>(args.params.get())?;
|
||||
let cwd = req.workspace_directory.to_string_lossy().to_string();
|
||||
|
||||
let _timer = crate::instrumentation_timer!("session.list_sessions_for_workspace");
|
||||
|
||||
let mut summaries = list_summaries(Some(&cwd)).await.map_err(|e| {
|
||||
acp::Error::internal_error().data(format!("failed to list sessions: {e}"))
|
||||
})?;
|
||||
for s in &mut summaries {
|
||||
backfill_session_summary(s);
|
||||
}
|
||||
|
||||
let value = serde_json::to_value(SessionListResponse {
|
||||
session_summaries: summaries,
|
||||
})
|
||||
.map(|v| serde_json::value::to_raw_value(&v).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
"x.ai/session_summaries/workspace_list" => {
|
||||
tracing::debug!("xai/session_summaries/workspace_list is working");
|
||||
let _req = serde_json::from_str::<AllSessionOverviewRequest>(args.params.get())?;
|
||||
|
||||
let _timer = crate::instrumentation_timer!("session.list_sessions_for_load");
|
||||
|
||||
let summaries = list_summaries(None).await.map_err(|e| {
|
||||
acp::Error::internal_error().data(format!("failed to list workspaces: {e}"))
|
||||
})?;
|
||||
|
||||
summaries_to_overview_response(summaries)
|
||||
}
|
||||
"x.ai/session_summaries/workspace_list_recent" => {
|
||||
let req = serde_json::from_str::<RecentSessionsRequest>(args.params.get())?;
|
||||
|
||||
let _timer = crate::instrumentation_timer!("session.list_sessions_recent");
|
||||
|
||||
let limit = req.limit.min(10_000);
|
||||
let mut summaries = list_recent_summaries(limit).await.map_err(|e| {
|
||||
acp::Error::internal_error().data(format!("failed to list workspaces: {e}"))
|
||||
})?;
|
||||
for s in &mut summaries {
|
||||
backfill_session_summary(s);
|
||||
}
|
||||
|
||||
let value = serde_json::to_value(&summaries)
|
||||
.map(|v| serde_json::value::to_raw_value(&v).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Group summaries by cwd and serialize into an [`AllSessionOverviewResponse`].
|
||||
fn summaries_to_overview_response(summaries: Vec<Summary>) -> Result<acp::ExtResponse, acp::Error> {
|
||||
let mut by_cwd: BTreeMap<String, Vec<Summary>> = Default::default();
|
||||
for mut s in summaries {
|
||||
backfill_session_summary(&mut s);
|
||||
by_cwd.entry(s.info.cwd.clone()).or_default().push(s);
|
||||
}
|
||||
|
||||
let value = serde_json::to_value(AllSessionOverviewResponse {
|
||||
all_sessions: by_cwd
|
||||
.into_iter()
|
||||
.map(|(k, v)| (PathBuf::from(k), v))
|
||||
.collect(),
|
||||
})
|
||||
.map(|v| serde_json::value::to_raw_value(&v).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
// ── Merged session list (local + remote) ─────────────────────────────
|
||||
|
||||
async fn handle_session_list(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
use crate::session::unified_list;
|
||||
|
||||
// Under chat mode `parse_list_req` REPLACES any client-sent `kind` facet
|
||||
// (never union) so every list surface is conversations-only.
|
||||
let req = unified_list::parse_list_req(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
tracing::debug!(
|
||||
chat_mode_forced_kind = crate::agent::chat_modes::process_chat_mode_enabled(),
|
||||
"session/list"
|
||||
);
|
||||
|
||||
let registry_client = agent.session_registry_client();
|
||||
let conversations_client = agent.conversations_client();
|
||||
let result = unified_list::build_unified_list(
|
||||
registry_client.as_ref(),
|
||||
conversations_client.as_ref(),
|
||||
req,
|
||||
)
|
||||
.await;
|
||||
|
||||
ExtMethodResult::success(unified_list::ext_list_response(result))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use agent_client_protocol::{self as acp};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::mvp_agent::MvpAgent;
|
||||
use crate::remote::{ListWorkspacesPage, WsError, WsQuery};
|
||||
use crate::session::ExtMethodResult;
|
||||
|
||||
const DEFAULT_PAGE_SIZE: i64 = 50;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspacesListRequest {
|
||||
#[serde(default)]
|
||||
page_size: Option<i64>,
|
||||
#[serde(default)]
|
||||
page_token: Option<String>,
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspaceRow {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
create_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspacesListResponse {
|
||||
workspaces: Vec<WorkspaceRow>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next_page_token: Option<String>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
meta: Option<WorkspacesMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WorkspacesMeta {
|
||||
#[serde(rename = "x.ai/partial")]
|
||||
partial: PartialInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PartialInfo {
|
||||
workspaces: bool,
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
let req: WorkspacesListRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
|
||||
let q = WsQuery {
|
||||
// Clamp to a sane positive page size: a missing, zero, or negative
|
||||
// `pageSize` falls back to the default rather than being forwarded
|
||||
// verbatim to `/rest/workspaces`.
|
||||
page_size: match req.page_size {
|
||||
Some(n) if n > 0 => n,
|
||||
_ => DEFAULT_PAGE_SIZE,
|
||||
},
|
||||
page_token: req.page_token,
|
||||
query: req.query,
|
||||
kind: req.kind,
|
||||
};
|
||||
|
||||
let response = match agent.workspaces_client().list_workspaces(&q).await {
|
||||
Ok(page) => success_response(page),
|
||||
Err(WsError::NoOauth) => degraded_response("no_oauth"),
|
||||
Err(e) => {
|
||||
// Degrade to a partial result, but don't silently swallow the
|
||||
// cause — log it so field failures are diagnosable.
|
||||
tracing::warn!("workspaces/list fetch failed: {e}");
|
||||
degraded_response("error")
|
||||
}
|
||||
};
|
||||
|
||||
ExtMethodResult::success(response)
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
fn success_response(page: ListWorkspacesPage) -> WorkspacesListResponse {
|
||||
WorkspacesListResponse {
|
||||
workspaces: page
|
||||
.workspaces
|
||||
.into_iter()
|
||||
.map(|w| WorkspaceRow {
|
||||
id: w.workspace_id,
|
||||
name: w.name,
|
||||
kind: w.kind,
|
||||
create_time: w.create_time,
|
||||
})
|
||||
.collect(),
|
||||
next_page_token: page.next_page_token,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn degraded_response(reason: &'static str) -> WorkspacesListResponse {
|
||||
WorkspacesListResponse {
|
||||
workspaces: Vec::new(),
|
||||
next_page_token: None,
|
||||
meta: Some(WorkspacesMeta {
|
||||
partial: PartialInfo {
|
||||
workspaces: true,
|
||||
reason,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::remote::Workspace;
|
||||
|
||||
#[test]
|
||||
fn request_parses_camelcase_and_defaults_page_size() {
|
||||
let req: WorkspacesListRequest =
|
||||
serde_json::from_value(serde_json::json!({})).expect("empty params parse");
|
||||
assert!(req.page_size.is_none());
|
||||
|
||||
let req: WorkspacesListRequest = serde_json::from_value(serde_json::json!({
|
||||
"pageSize": 10,
|
||||
"pageToken": "tok",
|
||||
"query": "gpu",
|
||||
"kind": "WORKSPACE_KIND_IMAGINE"
|
||||
}))
|
||||
.expect("full params parse");
|
||||
assert_eq!(req.page_size, Some(10));
|
||||
assert_eq!(req.page_token.as_deref(), Some("tok"));
|
||||
assert_eq!(req.query.as_deref(), Some("gpu"));
|
||||
assert_eq!(req.kind.as_deref(), Some("WORKSPACE_KIND_IMAGINE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_response_projects_grok_workspace_fields() {
|
||||
let page = ListWorkspacesPage {
|
||||
workspaces: vec![Workspace {
|
||||
workspace_id: "ws_1".into(),
|
||||
name: "Research".into(),
|
||||
create_time: Some("2026-06-18T17:30:00Z".into()),
|
||||
kind: Some("WORKSPACE_KIND_IMAGINE".into()),
|
||||
}],
|
||||
next_page_token: Some("tok2".into()),
|
||||
};
|
||||
let value = serde_json::to_value(success_response(page)).unwrap();
|
||||
assert_eq!(value["workspaces"][0]["id"], "ws_1");
|
||||
assert_eq!(value["workspaces"][0]["name"], "Research");
|
||||
assert_eq!(value["workspaces"][0]["kind"], "WORKSPACE_KIND_IMAGINE");
|
||||
assert_eq!(value["workspaces"][0]["createTime"], "2026-06-18T17:30:00Z");
|
||||
assert_eq!(value["nextPageToken"], "tok2");
|
||||
assert!(value.get("_meta").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_response_carries_partial_reason() {
|
||||
let value = serde_json::to_value(degraded_response("no_oauth")).unwrap();
|
||||
assert_eq!(value["workspaces"].as_array().unwrap().len(), 0);
|
||||
assert!(value.get("nextPageToken").is_none());
|
||||
assert_eq!(value["_meta"]["x.ai/partial"]["workspaces"], true);
|
||||
assert_eq!(value["_meta"]["x.ai/partial"]["reason"], "no_oauth");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user