Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/replay_events.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

179 lines
6.0 KiB
Rust

use agent_client_protocol as acp;
use tokio::sync::{mpsc, oneshot};
use crate::extensions::notification::SessionNotification as XaiSessionNotification;
use acp::SessionNotification as AcpSessionNotification;
/// Notification destined for the high-frequency event ReplayBuffer).
/// Variants tag the inner protocol surface because the
/// merge rules and wire envelopes differ, but routing through `event_tx`
/// and `ReplayBuffer` is by design -- anything that goes here gets
/// debounced + merged, and emerges through `emit_buffered` without
/// firing per-chunk hooks or persistence writes.
///
/// One-shot xAI events (RetryState, ImageCompressed, HookExecution,
/// etc.) take the direct `send_xai_notification` path for per-event hooks and persistence.
#[derive(Debug, Clone)]
pub(crate) enum SessionNotification {
Acp(Box<AcpSessionNotification>),
Xai(Box<XaiSessionNotification>),
}
impl SessionNotification {
pub(crate) fn session_id(&self) -> &acp::SessionId {
match self {
Self::Acp(n) => &n.session_id,
Self::Xai(n) => &n.session_id,
}
}
/// Returns true if this notification is a streaming chunk that
/// should be buffered for merging + debouncing.
pub(crate) fn is_streaming_chunk(&self) -> bool {
match self {
Self::Acp(n) => matches!(
n.update,
acp::SessionUpdate::AgentMessageChunk(_) | acp::SessionUpdate::AgentThoughtChunk(_)
),
Self::Xai(n) => matches!(
n.update,
crate::extensions::notification::SessionUpdate::ToolCallDeltaChunk { .. }
),
}
}
/// Extract `agentTimestampMs` from the notification's meta, if set.
pub(crate) fn agent_timestamp_ms(&self) -> Option<u64> {
match self {
Self::Acp(n) => n
.meta
.as_ref()
.and_then(|m| m.get("agentTimestampMs"))
.and_then(|v| v.as_u64()),
Self::Xai(n) => n
.meta
.as_ref()
.and_then(|m| m.get("agentTimestampMs"))
.and_then(|v| v.as_u64()),
}
}
/// Returns true if this notification can be merged with `prev`'s pending slot based on their timestamps.
pub(crate) fn is_in_timestamp_window(&self, prev: &Self, max_duration_ms: u64) -> bool {
match (prev.agent_timestamp_ms(), self.agent_timestamp_ms()) {
// ACP events have timestamps, so we can window-check.
(Some(prev_ts), Some(incoming_ts)) => incoming_ts <= prev_ts + max_duration_ms,
// Either side missing the agentTimestampMs meta means we can't window-check.
_ => true,
}
}
}
impl From<AcpSessionNotification> for SessionNotification {
fn from(n: AcpSessionNotification) -> Self {
Self::Acp(Box::new(n))
}
}
impl From<XaiSessionNotification> for SessionNotification {
fn from(n: XaiSessionNotification) -> Self {
Self::Xai(Box::new(n))
}
}
#[cfg(test)]
impl SessionNotification {
/// Test-only: borrow the inner ACP notification, panicking if this
/// is not the `Acp` variant.
pub(crate) fn expect_acp(&self) -> &AcpSessionNotification {
match self {
Self::Acp(n) => n,
Self::Xai(_) => panic!("expected Acp notification, got Xai"),
}
}
/// Test-only: move the inner ACP notification out, panicking if
/// this is not the `Acp` variant.
pub(crate) fn into_acp(self) -> AcpSessionNotification {
match self {
Self::Acp(n) => *n,
Self::Xai(_) => panic!("expected Acp notification, got Xai"),
}
}
}
#[derive(Debug)]
pub(crate) enum SessionEvent {
Notification(SessionNotification),
FlushReplay {
respond_to: Option<oneshot::Sender<()>>,
},
}
impl SessionEvent {
pub(crate) fn flush_with_ack() -> (Self, oneshot::Receiver<()>) {
let (tx, rx) = oneshot::channel();
(
Self::FlushReplay {
respond_to: Some(tx),
},
rx,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FlushReplayError {
EventChannelClosed,
Timeout,
}
/// Flush replay-buffered notifications through the session actor loop.
///
/// This must only be used from callers that are *outside* `run_session()`.
/// The `FlushComplete` command runs inside the actor loop and therefore
/// flushes `replay_buffer` inline to avoid waiting on a mailbox event that
/// the same loop would need to process.
pub(crate) async fn flush_replay_actor(
event_tx: &mpsc::UnboundedSender<SessionEvent>,
) -> Result<(), FlushReplayError> {
let (event, rx) = SessionEvent::flush_with_ack();
event_tx
.send(event)
.map_err(|_| FlushReplayError::EventChannelClosed)?;
tokio::time::timeout(std::time::Duration::from_secs(5), rx)
.await
.map_err(|_| FlushReplayError::Timeout)?
.map_err(|_| FlushReplayError::EventChannelClosed)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn flush_replay_actor_acknowledges() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<SessionEvent>();
let (event, rx) = SessionEvent::flush_with_ack();
event_tx.send(event).expect("event send should succeed");
let event = event_rx.recv().await.expect("event should arrive");
match event {
SessionEvent::FlushReplay { respond_to } => {
let tx = respond_to.expect("flush replay should carry ack sender");
tx.send(()).expect("ack send should succeed");
}
other => panic!("unexpected event: {other:?}"),
}
rx.await.expect("ack should be received");
})
.await;
}
}