Files
Kigi-CLI/crates/codegen/kigi-shell/src/agent/roster.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

312 lines
11 KiB
Rust

//! Roster types for the multi-client FleetView dashboard.
//!
//! The roster is a list
//! of dashboard-sized summaries of every session the leader hosts (resident
//! actors) plus recently-touched on-disk (`Dormant`) sessions. Clients read it
//! two ways:
//!
//! - request/response `kigi/sessions/list` → `{ "sessions": [RosterEntry, …] }`
//! - broadcast notification `kigi/sessions/changed` →
//! `{ "upserted": [RosterEntry, …], "removed": ["sess-abc", …] }`
//!
//! The wire shape is intentionally small and current-state only — no event
//! fold or materialized snapshot is required (the snapshot is deferred).
use kigi_sampling_types::ReasoningEffort;
use serde::{Deserialize, Serialize};
use crate::session::persistence::Summary;
/// Coarse activity of a session as rendered in the dashboard's status column.
///
/// Mirrors the design's `SessionActivity` at dashboard granularity. A full
/// background-work breakdown (bg tasks / monitors / scheduler / subagents)
/// lands with a richer `SessionActivity`; the dashboard only needs this
/// coarse signal to pick a status glyph.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RosterActivity {
/// A turn (user-originated or autonomous) is running.
Working,
/// Resident, no turn in flight.
Idle,
/// A permission / question / plan-approval is pending.
NeedsInput,
/// On disk, not resident.
Dormant,
/// Finished and resumable.
Completed,
/// Actor panicked / load failed.
Dead,
}
/// Where the session lives. Only `Local` is produced today; `Remote` is
/// reserved for cross-machine roster aggregation.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum RosterOrigin {
Local,
Remote { host: String },
}
/// One dashboard row.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RosterEntry {
pub session_id: String,
/// Generated/display title, if known. Clients fall back to cwd / id.
#[serde(default)]
pub title: Option<String>,
pub cwd: String,
pub is_worktree: bool,
#[serde(default)]
pub model_id: Option<String>,
/// Per-session reasoning effort for `model_id`. Carried alongside the model
/// so clients can render the session's effort in the roster without a
/// separate `model_state` fetch. `None` means "use the model/global
/// default" (or the session predates per-session effort persistence).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
pub yolo: bool,
pub activity: RosterActivity,
/// `true` while a resident actor hosts the session (vs. read from disk).
pub resident: bool,
/// Best-effort last-change timestamp (unix millis). Used for sort order.
pub last_change_unix_ms: i64,
pub origin: RosterOrigin,
}
/// Response payload for `kigi/sessions/list`.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct RosterListResponse {
pub sessions: Vec<RosterEntry>,
}
/// Params payload for the `kigi/sessions/changed` broadcast notification.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct RosterChanged {
#[serde(default)]
pub upserted: Vec<RosterEntry>,
#[serde(default)]
pub removed: Vec<String>,
}
/// JSON-RPC method names for the roster API.
pub const SESSIONS_LIST_METHOD: &str = "kigi/sessions/list";
pub const SESSIONS_CHANGED_METHOD: &str = "kigi/sessions/changed";
/// Merge live `resident` rows with on-disk `summaries` into the sorted roster.
/// Pure, so it is unit-testable without disk or a live actor.
///
/// Resident rows own the live state but carry no title or last-active time, so
/// each adopts those from its summary — except a `Working` row keeps its "now"
/// timestamp. Summaries with no resident row become `Dormant`; keying by id
/// dedups them. Hidden summaries are excluded.
pub(crate) fn merge_roster(
mut entries: Vec<RosterEntry>,
summaries: Vec<Summary>,
) -> Vec<RosterEntry> {
let mut by_id: std::collections::HashMap<String, Summary> = summaries
.into_iter()
.filter(|s| !s.is_hidden())
.map(|s| (s.info.id.0.to_string(), s))
.collect();
// Backfill resident rows; remove the summary so it isn't re-emitted below.
for entry in &mut entries {
let Some(summary) = by_id.remove(&entry.session_id) else {
continue;
};
if let Some(title) = summary.display_title_opt() {
entry.title = Some(title);
}
if entry.activity != RosterActivity::Working {
entry.last_change_unix_ms = summary.last_change_unix_ms();
}
}
// Remaining summaries have no resident row: emit them as dormant.
entries.extend(by_id.into_values().map(|summary| RosterEntry {
session_id: summary.info.id.0.to_string(),
title: summary.display_title_opt(),
cwd: summary.info.cwd.clone(),
is_worktree: summary.session_kind.as_deref() == Some("worktree")
|| summary.source_workspace_dir.is_some(),
model_id: Some(summary.current_model_id.0.to_string()),
reasoning_effort: summary.reasoning_effort,
yolo: false,
activity: RosterActivity::Dormant,
resident: false,
last_change_unix_ms: summary.last_change_unix_ms(),
origin: RosterOrigin::Local,
}));
// Most-recently-changed first.
entries.sort_by_key(|entry| std::cmp::Reverse(entry.last_change_unix_ms));
entries
}
#[cfg(test)]
mod merge_roster_tests {
use super::*;
use crate::session::info::Info;
use crate::session::persistence::default_model_id;
use agent_client_protocol as acp;
fn summary(id: &str, title: Option<&str>, last_active_ms: i64) -> Summary {
let mut s = Summary::new(
&Info {
id: acp::SessionId::new(id),
cwd: format!("/repo/{id}"),
},
default_model_id(),
)
.expect("summary");
s.generated_title = title.map(String::from);
s.last_active_at = chrono::DateTime::from_timestamp_millis(last_active_ms);
s
}
fn resident(id: &str, activity: RosterActivity, last_change_unix_ms: i64) -> RosterEntry {
RosterEntry {
session_id: id.to_string(),
title: None,
cwd: format!("/live/{id}"),
is_worktree: false,
model_id: Some("kigi-4".into()),
reasoning_effort: None,
yolo: false,
activity,
resident: true,
last_change_unix_ms,
origin: RosterOrigin::Local,
}
}
#[test]
fn idle_resident_adopts_persisted_title_and_last_active() {
let now = 9_000;
let out = merge_roster(
vec![resident("a", RosterActivity::Idle, now)],
vec![summary("a", Some("Fix the roster"), 1_234)],
);
assert_eq!(out.len(), 1, "resident must not be duplicated as dormant");
assert_eq!(out[0].title.as_deref(), Some("Fix the roster"));
assert_eq!(out[0].last_change_unix_ms, 1_234, "idle adopts last-active");
assert!(out[0].resident);
assert_eq!(out[0].cwd, "/live/a", "live cwd is preserved");
assert_eq!(out[0].activity, RosterActivity::Idle);
}
#[test]
fn working_resident_keeps_now_but_adopts_title() {
let now = 9_000;
let out = merge_roster(
vec![resident("a", RosterActivity::Working, now)],
vec![summary("a", Some("Busy turn"), 1_234)],
);
assert_eq!(out[0].title.as_deref(), Some("Busy turn"));
assert_eq!(out[0].last_change_unix_ms, now, "Working stays 'now'");
}
#[test]
fn new_resident_without_summary_stays_titleless_now() {
let now = 9_000;
let out = merge_roster(vec![resident("a", RosterActivity::Idle, now)], vec![]);
assert_eq!(out[0].title, None);
assert_eq!(out[0].last_change_unix_ms, now);
}
#[test]
fn blank_persisted_title_leaves_row_untitled() {
let out = merge_roster(
vec![resident("a", RosterActivity::Idle, 9_000)],
vec![summary("a", Some(" "), 1_234)],
);
assert_eq!(out[0].title, None, "blank title normalizes to None");
assert_eq!(out[0].last_change_unix_ms, 1_234);
}
#[test]
fn dormant_sessions_are_emitted_and_sorted_after_residents() {
let out = merge_roster(
vec![resident("live", RosterActivity::Idle, 5_000)],
vec![
summary("live", Some("Live one"), 4_000),
summary("old", Some("Dormant one"), 1_000),
summary("new", Some("Newer dormant"), 8_000),
],
);
let ids: Vec<&str> = out.iter().map(|e| e.session_id.as_str()).collect();
assert_eq!(
ids,
vec!["new", "live", "old"],
"sorted by last-change desc"
);
let dormant = out.iter().find(|e| e.session_id == "new").unwrap();
assert_eq!(dormant.activity, RosterActivity::Dormant);
assert!(!dormant.resident);
}
#[test]
fn duplicate_summaries_are_deduped() {
let out = merge_roster(
vec![],
vec![
summary("dup", Some("First"), 1_000),
summary("dup", Some("Second"), 2_000),
],
);
assert_eq!(out.len(), 1, "duplicate ids collapse to one row");
}
#[test]
fn hidden_summaries_are_excluded() {
let mut hidden = summary("sub", Some("Subagent"), 5_000);
hidden.session_kind = Some("subagent".into());
let out = merge_roster(vec![], vec![hidden]);
assert!(out.is_empty(), "hidden/subagent summaries are dropped");
}
#[test]
fn dormant_row_carries_persisted_reasoning_effort() {
let mut s = summary("dorm", Some("Dormant"), 1_000);
s.reasoning_effort = Some(ReasoningEffort::Xhigh);
let out = merge_roster(vec![], vec![s]);
assert_eq!(out[0].reasoning_effort, Some(ReasoningEffort::Xhigh));
}
#[test]
fn resident_effort_is_taken_from_the_live_row_not_the_summary() {
// The live handle is authoritative for a resident session, so the
// resident row's effort must survive the summary backfill.
let mut live = resident("a", RosterActivity::Idle, 9_000);
live.reasoning_effort = Some(ReasoningEffort::High);
let mut s = summary("a", Some("Title"), 1_234);
s.reasoning_effort = Some(ReasoningEffort::Low);
let out = merge_roster(vec![live], vec![s]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].reasoning_effort, Some(ReasoningEffort::High));
}
#[test]
fn reasoning_effort_serializes_as_camel_case_and_skips_when_none() {
let with_effort = RosterEntry {
reasoning_effort: Some(ReasoningEffort::Xhigh),
..resident("a", RosterActivity::Idle, 1)
};
let json = serde_json::to_string(&with_effort).unwrap();
assert!(
json.contains("\"reasoningEffort\":\"xhigh\""),
"effort must be camelCase and snake_case-valued: {json}"
);
let without = resident("b", RosterActivity::Idle, 1);
let json = serde_json::to_string(&without).unwrap();
assert!(
!json.contains("reasoningEffort"),
"a None effort must not be serialized: {json}"
);
}
}