Files
Kigi-CLI/crates/common/kigi-computer-hub-sdk/src/notification.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

363 lines
13 KiB
Rust

//! Parsed server notification events.
//!
//! [`HubNotification`] is the typed representation of server-pushed
//! notification frames that arrive on a session inbox. The
//! [`HubNotification::parse`] constructor classifies a raw JSON value
//! by its `method` field and deserializes the known shapes; anything
//! unrecognised lands in [`HubNotification::Unknown`] so callers never
//! lose data.
use kigi_tool_protocol::{
SessionId, ToolId, ToolNotificationFrame, ToolServerStatusPayload, ToolsChanged,
};
use serde_json::Value;
use tracing::warn;
/// A typed server notification event parsed from a raw JSON-RPC notification frame.
#[derive(Debug, Clone, PartialEq)]
pub enum HubNotification {
/// The active tool set for a session changed (tools added, removed, or updated).
ToolsChanged {
session_id: SessionId,
added: Vec<ToolId>,
removed: Vec<ToolId>,
updated: Vec<ToolId>,
},
/// A tool notification forwarded by the server to all subscribers.
ToolNotification {
session_id: SessionId,
frame: ToolNotificationFrame,
},
/// Tool server lifecycle status change, extracted from
/// `__tool_server_status` / `status_changed` notification frames.
ToolServerStatusChanged {
session_id: SessionId,
status: ToolServerStatusPayload,
},
/// A notification whose `method` is not recognised by this SDK version.
Unknown { method: String, params: Value },
}
impl HubNotification {
/// Parse a raw JSON-RPC notification into a typed [`HubNotification`].
///
/// Returns `None` when the value lacks a `method` field (i.e. it is
/// not a notification at all).
pub fn parse(value: &Value) -> Option<Self> {
let method = value.get("method")?.as_str()?;
let params = value
.get("params")
.cloned()
.unwrap_or(Value::Object(Default::default()));
match method {
// `ToolsChanged` carries `session_id` inside `params`.
"tools_changed" => match serde_json::from_value::<ToolsChanged>(params.clone()) {
Ok(tc) => Some(HubNotification::ToolsChanged {
session_id: tc.session_id,
added: tc.added,
removed: tc.removed,
updated: tc.updated,
}),
Err(err) => {
warn!(%err, "tools_changed params failed to deserialize; falling back to Unknown");
Some(HubNotification::Unknown {
method: method.to_owned(),
params,
})
}
},
// `ToolNotificationFrame` has no `session_id`; use the envelope field.
"tool.notification" => {
let frame_result = serde_json::from_value::<ToolNotificationFrame>(params.clone());
let session_id = value
.get("session_id")
.and_then(Value::as_str)
.and_then(|s| SessionId::new(s).ok());
match (frame_result, session_id) {
(Ok(frame), Some(session_id)) => {
if frame
.tool_id
.as_ref()
.is_some_and(|id| id.as_str() == "__tool_server_status")
&& let kigi_tool_protocol::notification_wire::WireToolNotification::Custom(ref c) = frame.notification
&& c.kind == "status_changed"
{
match serde_json::from_value::<ToolServerStatusPayload>(
c.payload.clone(),
) {
Ok(status) => {
return Some(HubNotification::ToolServerStatusChanged {
session_id,
status,
});
}
Err(err) => {
warn!(%err, "tool_server status payload failed to deserialize");
}
}
}
Some(HubNotification::ToolNotification { session_id, frame })
}
(Err(err), _) => {
warn!(%err, "tool.notification params failed to deserialize; falling back to Unknown");
Some(HubNotification::Unknown {
method: method.to_owned(),
params,
})
}
(_, None) => {
warn!(
"tool.notification missing or invalid session_id; falling back to Unknown"
);
Some(HubNotification::Unknown {
method: method.to_owned(),
params,
})
}
}
}
_ => Some(HubNotification::Unknown {
method: method.to_owned(),
params,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_tools_changed() {
let value = json!({
"jsonrpc": "2.0",
"session_id": "s1",
"method": "tools_changed",
"params": {
"session_id": "s1",
"added": ["echo", "add"],
"removed": [],
}
});
let notif = HubNotification::parse(&value).expect("should parse");
match notif {
HubNotification::ToolsChanged {
session_id,
added,
removed,
updated,
} => {
assert_eq!(session_id.as_str(), "s1");
assert_eq!(added.len(), 2);
assert!(removed.is_empty());
assert!(updated.is_empty());
}
other => panic!("expected ToolsChanged, got {other:?}"),
}
}
#[test]
fn parse_tools_changed_with_updated() {
let value = json!({
"jsonrpc": "2.0",
"session_id": "s1",
"method": "tools_changed",
"params": {
"session_id": "s1",
"added": ["new_tool"],
"removed": ["old_tool"],
"updated": ["echo", "add"],
}
});
let notif = HubNotification::parse(&value).expect("should parse");
match notif {
HubNotification::ToolsChanged {
session_id,
added,
removed,
updated,
} => {
assert_eq!(session_id.as_str(), "s1");
assert_eq!(added.len(), 1);
assert_eq!(removed.len(), 1);
assert_eq!(updated.len(), 2);
assert_eq!(updated[0].as_str(), "echo");
assert_eq!(updated[1].as_str(), "add");
}
other => panic!("expected ToolsChanged, got {other:?}"),
}
}
#[test]
fn parse_tool_notification_custom() {
let value = json!({
"jsonrpc": "2.0",
"session_id": "s1",
"method": "tool.notification",
"params": {
"tool_id": "echo",
"notification": {
"shape": "custom",
"value": {
"kind": "echo.status",
"payload": { "status": "idle" }
}
}
}
});
let notif = HubNotification::parse(&value).expect("should parse");
match notif {
HubNotification::ToolNotification { session_id, frame } => {
assert_eq!(session_id.as_str(), "s1");
assert_eq!(frame.tool_id.as_ref().unwrap().as_str(), "echo");
}
other => panic!("expected ToolNotification, got {other:?}"),
}
}
#[test]
fn parse_tool_notification_missing_session_id_falls_back_to_unknown() {
let value = json!({
"jsonrpc": "2.0",
"method": "tool.notification",
"params": {
"tool_id": "echo",
"notification": {
"shape": "custom",
"value": { "kind": "test", "payload": {} }
}
}
});
let notif = HubNotification::parse(&value).expect("should parse as Unknown, not None");
assert!(
matches!(notif, HubNotification::Unknown { ref method, .. } if method == "tool.notification"),
"tool.notification without envelope session_id should fall back to Unknown, got {notif:?}"
);
}
#[test]
fn parse_unknown_method() {
let value = json!({
"jsonrpc": "2.0",
"session_id": "s1",
"method": "future.method",
"params": { "key": "value" }
});
let notif = HubNotification::parse(&value).expect("should parse");
match notif {
HubNotification::Unknown { method, params } => {
assert_eq!(method, "future.method");
assert_eq!(params["key"], "value");
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn parse_missing_method_returns_none() {
let value = json!({ "jsonrpc": "2.0", "id": "123", "result": {} });
assert!(HubNotification::parse(&value).is_none());
}
#[test]
fn parse_tools_changed_bad_params_falls_back_to_unknown() {
// `params` has wrong shape (missing required fields) — should fall
// back to Unknown instead of returning None and dropping the event.
let value = json!({
"jsonrpc": "2.0",
"method": "tools_changed",
"params": { "unexpected_field": true }
});
let notif = HubNotification::parse(&value).expect("should parse as Unknown, not None");
assert!(
matches!(notif, HubNotification::Unknown { ref method, .. } if method == "tools_changed"),
"malformed tools_changed should fall back to Unknown, got {notif:?}"
);
}
#[test]
fn parse_tool_server_status_changed() {
let value = json!({
"jsonrpc": "2.0",
"session_id": "s1",
"method": "tool.notification",
"params": {
"tool_id": "__tool_server_status",
"notification": {
"shape": "custom",
"value": {
"kind": "status_changed",
"payload": {
"status": "busy",
"active_tool_calls": 2,
"active_tool_names": ["read_file", "grep"],
"background_tasks": 0,
"pending_tool_calls": 0,
"last_tool_call_started_ms": 100,
"last_tool_call_completed_ms": 0,
"uptime_ms": 5000,
}
}
}
}
});
let notif = HubNotification::parse(&value).expect("should parse");
match notif {
HubNotification::ToolServerStatusChanged { session_id, status } => {
assert_eq!(session_id.as_str(), "s1");
assert_eq!(
status.status,
kigi_tool_protocol::ToolServerLifecycleStatus::Busy
);
assert_eq!(status.active_tool_calls, 2);
}
other => panic!("expected ToolServerStatusChanged, got {other:?}"),
}
}
#[test]
fn parse_tool_server_status_non_status_tool_id_stays_generic() {
// A tool.notification with a different tool_id should remain
// as ToolNotification, not be intercepted.
let value = json!({
"jsonrpc": "2.0",
"session_id": "s1",
"method": "tool.notification",
"params": {
"tool_id": "some_other_tool",
"notification": {
"shape": "custom",
"value": {
"kind": "status_changed",
"payload": { "status": "ready" }
}
}
}
});
let notif = HubNotification::parse(&value).expect("should parse");
assert!(
matches!(notif, HubNotification::ToolNotification { .. }),
"non-__tool_server_status tool_id should stay as ToolNotification, got {notif:?}"
);
}
#[test]
fn parse_tool_notification_bad_params_falls_back_to_unknown() {
// `params` has wrong shape — should fall back to Unknown.
let value = json!({
"jsonrpc": "2.0",
"session_id": "s1",
"method": "tool.notification",
"params": { "not_a_valid_frame": true }
});
let notif = HubNotification::parse(&value).expect("should parse as Unknown, not None");
assert!(
matches!(notif, HubNotification::Unknown { ref method, .. } if method == "tool.notification"),
"malformed tool.notification should fall back to Unknown, got {notif:?}"
);
}
}