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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -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(&notification) {
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");
}
}