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).
1070 lines
37 KiB
Rust
1070 lines
37 KiB
Rust
use agent_client_protocol::{self as acp};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json;
|
|
|
|
use crate::session::replay_events::SessionNotification;
|
|
|
|
/// Controls how sampling/output chunks are buffered before being delivered to the client.
|
|
/// Parsed from `InitializeRequest.meta.bufferingSettings` and preserved for later use.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct BufferingSettings {
|
|
/// Maximum number of items to accumulate before flushing
|
|
#[serde(default = "default_max_items")]
|
|
pub max_items: u64,
|
|
/// Maximum total bytes to accumulate before flushing
|
|
#[serde(default = "default_max_bytes")]
|
|
pub max_bytes: u64,
|
|
/// Maximum time in milliseconds to wait before flushing buffered items
|
|
#[serde(default = "default_max_duration_ms")]
|
|
pub max_duration_ms: u64,
|
|
}
|
|
|
|
fn default_max_items() -> u64 {
|
|
100
|
|
}
|
|
|
|
fn default_max_bytes() -> u64 {
|
|
1024 * 2 // 2 KB
|
|
}
|
|
|
|
fn default_max_duration_ms() -> u64 {
|
|
10 // 10 ms
|
|
}
|
|
|
|
/// Low-level buffer for ACP text chunks (agent message/thought chunks).
|
|
///
|
|
/// API:
|
|
/// - `consume_chunk(...) -> Option<SessionNotification>` returns a notification that should be sent now
|
|
/// (typically the previously buffered one), or `None` if we keep buffering.
|
|
/// - `flush() -> Option<SessionNotification>` returns any pending buffered notification to send.
|
|
///
|
|
/// Example of session notification:
|
|
/// ```json
|
|
/// {
|
|
/// "sessionId":"019e0000-0000-7000-8000-000000000001",
|
|
/// "update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".g"}},
|
|
/// "_meta":{
|
|
/// "totalTokens":100,
|
|
/// "eventId":"019e0000-0000-7000-8000-000000000001-0001",
|
|
/// "agentTimestampMs":1700000000000,
|
|
/// "updateType":"AgentMessageChunk",
|
|
/// "updateParams":{"textPreview":".g"}
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
/// ```json
|
|
/// {
|
|
/// "sessionId":"019e0000-0000-7000-8000-000000000001",
|
|
/// "update":{
|
|
/// "sessionUpdate":"agent_message_chunk",
|
|
/// "content":{"type":"text","text":".,"}
|
|
/// },
|
|
/// "_meta":{
|
|
/// "totalTokens":100,
|
|
/// "eventId":"019e0000-0000-7000-8000-000000000001-0002",
|
|
/// "agentTimestampMs":1700000000000,
|
|
/// "updateType":"AgentMessageChunk",
|
|
/// "updateParams":{"textPreview":".,"}
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
pub(crate) struct ReplayBuffer {
|
|
settings: Option<BufferingSettings>,
|
|
pending: Option<SessionNotification>,
|
|
pending_count: u64,
|
|
pending_bytes: u64,
|
|
}
|
|
|
|
impl ReplayBuffer {
|
|
pub(crate) fn new(settings: Option<BufferingSettings>) -> Self {
|
|
tracing::info!("ReplayBuffer::new: settings = {:?}", settings);
|
|
Self {
|
|
settings,
|
|
pending: None,
|
|
pending_count: 0,
|
|
pending_bytes: 0,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn max_wait_duration_ms(&self) -> Option<u64> {
|
|
self.settings.as_ref().map(|s| s.max_duration_ms)
|
|
}
|
|
|
|
pub(crate) fn consume_chunk(
|
|
&mut self,
|
|
incoming: impl Into<SessionNotification>,
|
|
) -> Option<(SessionNotification, Option<SessionNotification>)> {
|
|
let incoming = incoming.into();
|
|
match self.settings.as_ref() {
|
|
Some(settings) => self.consume_chunk_with_settings(
|
|
incoming,
|
|
settings.max_bytes,
|
|
settings.max_items,
|
|
settings.max_duration_ms,
|
|
),
|
|
None => {
|
|
// Buffering disabled: always send immediately.
|
|
Some((incoming, None))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn consume_chunk_with_settings(
|
|
&mut self,
|
|
incoming: SessionNotification,
|
|
max_bytes: u64,
|
|
max_items: u64,
|
|
max_duration_ms: u64,
|
|
) -> Option<(SessionNotification, Option<SessionNotification>)> {
|
|
let session_id_matches = self
|
|
.pending
|
|
.as_ref()
|
|
.map(|n| n.session_id() == incoming.session_id())
|
|
.unwrap_or(true);
|
|
|
|
let incoming_notification_timestamp_in_range = self
|
|
.pending
|
|
.as_ref()
|
|
.map(|prev| incoming.is_in_timestamp_window(prev, max_duration_ms))
|
|
.unwrap_or(true);
|
|
|
|
if !session_id_matches {
|
|
// can't merge, we need to send both chunks immediately to preserve current chunk order.
|
|
match self.pending.take() {
|
|
Some(pending) => {
|
|
// No buffered item after this call.
|
|
self.pending_count = 0;
|
|
self.pending_bytes = 0;
|
|
return Some((pending, Some(incoming)));
|
|
}
|
|
None => {
|
|
return Some((incoming, None));
|
|
}
|
|
}
|
|
}
|
|
|
|
if !incoming_notification_timestamp_in_range {
|
|
// need to pop previously pending notification and send it immediately
|
|
let prev = self.pending.replace(incoming);
|
|
{
|
|
let prev = prev?;
|
|
return Some((prev, None));
|
|
}
|
|
}
|
|
|
|
let pending = self.pending.take();
|
|
let had_prev = pending.is_some();
|
|
let prev_count = self.pending_count;
|
|
// pending is now empty; we'll either refill it (and set counts) or send immediately.
|
|
self.pending_count = 0;
|
|
self.pending_bytes = 0;
|
|
|
|
let (force_send, first, second) = self.merge(pending, incoming);
|
|
|
|
match (force_send, first, second) {
|
|
(_, pending, Some(next)) => {
|
|
// Merge wasn't allowed; send both immediately to preserve current chunk order.
|
|
// (Nothing remains buffered.)
|
|
self.pending_count = 0;
|
|
self.pending_bytes = 0;
|
|
Some((pending, Some(next)))
|
|
}
|
|
(force_send, pending, None) => {
|
|
// Decide whether to keep buffering this (possibly merged) pending update.
|
|
let next_count = if had_prev {
|
|
prev_count.saturating_add(1)
|
|
} else {
|
|
1
|
|
};
|
|
let next_bytes = estimate_payload_bytes(&pending);
|
|
|
|
if force_send || next_count >= max_items || next_bytes >= max_bytes {
|
|
// Threshold exceeded: send now, leaving buffer empty.
|
|
self.pending_count = 0;
|
|
self.pending_bytes = 0;
|
|
Some((pending, None))
|
|
} else {
|
|
self.pending = Some(pending);
|
|
self.pending_count = next_count;
|
|
self.pending_bytes = next_bytes;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Two-by-two dispatch on protocol kind:
|
|
/// - Same kind on both sides → delegate to per-kind merge function.
|
|
/// - Different kinds → can't merge, force-flush prev and pass incoming through.
|
|
/// - No prev → buffer the incoming chunk if it's of a bufferable kind,
|
|
/// force-send otherwise.
|
|
fn merge(
|
|
&mut self,
|
|
prev: Option<SessionNotification>,
|
|
new: SessionNotification,
|
|
) -> (bool, SessionNotification, Option<SessionNotification>) {
|
|
match (prev, new) {
|
|
(Some(SessionNotification::Acp(prev)), SessionNotification::Acp(new)) => {
|
|
let (force, first, second) = merge_acp_chunks(*new, *prev);
|
|
(
|
|
force,
|
|
SessionNotification::Acp(Box::new(first)),
|
|
second.map(|s| SessionNotification::Acp(Box::new(s))),
|
|
)
|
|
}
|
|
(Some(SessionNotification::Xai(prev)), SessionNotification::Xai(new)) => {
|
|
let (force, first, second) = merge_xai_chunks(*prev, *new);
|
|
(
|
|
force,
|
|
SessionNotification::Xai(Box::new(first)),
|
|
second.map(|s| SessionNotification::Xai(Box::new(s))),
|
|
)
|
|
}
|
|
// Different kinds: can't merge. Force-flush prev, return incoming as second.
|
|
(Some(prev), incoming) => (true, prev, Some(incoming)),
|
|
// No pending: buffer if the new chunk is a streaming kind,
|
|
// force-send otherwise (e.g. ToolCall, Plan, etc.).
|
|
(None, incoming) => {
|
|
let bufferable = incoming.is_streaming_chunk();
|
|
(!bufferable, incoming, None)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn flush(&mut self) -> Option<SessionNotification> {
|
|
let out = self.pending.take();
|
|
self.pending_count = 0;
|
|
self.pending_bytes = 0;
|
|
out
|
|
}
|
|
}
|
|
|
|
macro_rules! merge_text_chunks {
|
|
(
|
|
$variant:ident,
|
|
$prev_session_id:expr,
|
|
$prev_meta:expr,
|
|
$prev_text:ident,
|
|
$prev_chunk_meta:ident,
|
|
$new_session_id:expr,
|
|
$new_meta:expr,
|
|
$new_text:ident,
|
|
$new_chunk_meta:ident $(,)?
|
|
) => {{
|
|
let no_annotations = $prev_text.annotations.is_none() && $new_text.annotations.is_none();
|
|
if no_annotations {
|
|
(
|
|
false,
|
|
acp::SessionNotification::new(
|
|
$prev_session_id,
|
|
acp::SessionUpdate::$variant(
|
|
acp::ContentChunk::new(acp::ContentBlock::Text(
|
|
acp::TextContent::new(format!("{}{}", $prev_text.text, $new_text.text))
|
|
.meta($prev_text.meta),
|
|
))
|
|
.meta($prev_chunk_meta),
|
|
),
|
|
)
|
|
.meta(merge_meta($prev_meta, $new_meta)),
|
|
None,
|
|
)
|
|
} else {
|
|
(
|
|
true,
|
|
acp::SessionNotification::new(
|
|
$prev_session_id,
|
|
acp::SessionUpdate::$variant(
|
|
acp::ContentChunk::new(acp::ContentBlock::Text(
|
|
acp::TextContent::new($prev_text.text)
|
|
.annotations($prev_text.annotations)
|
|
.meta($prev_text.meta),
|
|
))
|
|
.meta($prev_chunk_meta),
|
|
),
|
|
)
|
|
.meta($prev_meta),
|
|
Some(
|
|
acp::SessionNotification::new(
|
|
$new_session_id,
|
|
acp::SessionUpdate::$variant(
|
|
acp::ContentChunk::new(acp::ContentBlock::Text(
|
|
acp::TextContent::new($new_text.text)
|
|
.annotations($new_text.annotations)
|
|
.meta($new_text.meta),
|
|
))
|
|
.meta($new_chunk_meta),
|
|
),
|
|
)
|
|
.meta($new_meta),
|
|
),
|
|
)
|
|
}
|
|
}};
|
|
}
|
|
|
|
fn chunk_id_as_u64(value: &serde_json::Value) -> Option<u64> {
|
|
value
|
|
.as_u64()
|
|
.or_else(|| {
|
|
value
|
|
.as_i64()
|
|
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
|
|
})
|
|
.or_else(|| value.as_str().and_then(|s| s.parse::<u64>().ok()))
|
|
}
|
|
|
|
fn append_chunk_id_range(range_arr: &mut Vec<serde_json::Value>, new_chunk_id: u64) {
|
|
let Some(last_value) = range_arr.last_mut() else {
|
|
range_arr.push(serde_json::json!(new_chunk_id));
|
|
return;
|
|
};
|
|
|
|
if let Some(last_id) = chunk_id_as_u64(last_value) {
|
|
if new_chunk_id == last_id + 1 {
|
|
*last_value = serde_json::json!([last_id, new_chunk_id]);
|
|
} else {
|
|
range_arr.push(serde_json::json!(new_chunk_id));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if let Some(range_values) = last_value.as_array_mut() {
|
|
if range_values.len() == 2 {
|
|
if let Some(range_end) = chunk_id_as_u64(&range_values[1])
|
|
&& new_chunk_id == range_end + 1
|
|
{
|
|
range_values[1] = serde_json::json!(new_chunk_id);
|
|
return;
|
|
}
|
|
} else if range_values.len() == 1
|
|
&& let Some(range_end) = chunk_id_as_u64(&range_values[0])
|
|
&& new_chunk_id == range_end + 1
|
|
{
|
|
*last_value = serde_json::json!([range_end, new_chunk_id]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
range_arr.push(serde_json::json!(new_chunk_id));
|
|
}
|
|
|
|
fn merge_meta(prev: Option<acp::Meta>, new: Option<acp::Meta>) -> Option<acp::Meta> {
|
|
match (prev, new) {
|
|
(Some(mut prev_val), Some(new_val)) => {
|
|
let (prev_obj, new_obj) = (&mut prev_val, &new_val);
|
|
if let Some(new_chunk_id_value) = new_obj.get("chunkId")
|
|
&& let Some(new_chunk_id) = chunk_id_as_u64(new_chunk_id_value)
|
|
{
|
|
if let Some(chunk_id_range) = prev_obj.get_mut("chunkIdRange") {
|
|
if let Some(range_arr) = chunk_id_range.as_array_mut() {
|
|
append_chunk_id_range(range_arr, new_chunk_id);
|
|
}
|
|
} else if let Some(prev_chunk_id_value) = prev_obj.get("chunkId") {
|
|
if let Some(prev_chunk_id) = chunk_id_as_u64(prev_chunk_id_value) {
|
|
let mut range_arr = Vec::new();
|
|
if new_chunk_id == prev_chunk_id + 1 {
|
|
range_arr.push(serde_json::json!([prev_chunk_id, new_chunk_id]));
|
|
} else {
|
|
range_arr.push(serde_json::json!(prev_chunk_id));
|
|
range_arr.push(serde_json::json!(new_chunk_id));
|
|
}
|
|
prev_obj.insert("chunkIdRange".to_string(), range_arr.into());
|
|
prev_obj.remove("chunkId");
|
|
} else {
|
|
prev_obj.insert("chunkId".to_string(), new_chunk_id_value.clone());
|
|
}
|
|
} else {
|
|
prev_obj.insert("chunkId".to_string(), new_chunk_id_value.clone());
|
|
}
|
|
}
|
|
Some(prev_val)
|
|
}
|
|
(Some(prev), None) => Some(prev),
|
|
(None, Some(new)) => Some(new),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
|
|
/// Merge two consecutive ACP notifications.
|
|
fn merge_acp_chunks(
|
|
new: acp::SessionNotification,
|
|
prev: acp::SessionNotification,
|
|
) -> (
|
|
bool,
|
|
acp::SessionNotification,
|
|
Option<acp::SessionNotification>,
|
|
) {
|
|
let (new_session_id, new_update, new_meta) = (new.session_id, new.update, new.meta);
|
|
let (prev_session_id, prev_update, prev_meta) = (prev.session_id, prev.update, prev.meta);
|
|
match (prev_update, new_update) {
|
|
(
|
|
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk {
|
|
content: acp::ContentBlock::Text(prev_text),
|
|
meta: prev_chunk_meta,
|
|
..
|
|
}),
|
|
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk {
|
|
content: acp::ContentBlock::Text(incoming_text),
|
|
meta: n_chunk_meta,
|
|
..
|
|
}),
|
|
) => merge_text_chunks!(
|
|
AgentMessageChunk,
|
|
prev_session_id,
|
|
prev_meta,
|
|
prev_text,
|
|
prev_chunk_meta,
|
|
new_session_id,
|
|
new_meta,
|
|
incoming_text,
|
|
n_chunk_meta,
|
|
),
|
|
(
|
|
acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk {
|
|
content: acp::ContentBlock::Text(prev_text),
|
|
meta: prev_chunk_meta,
|
|
..
|
|
}),
|
|
acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk {
|
|
content: acp::ContentBlock::Text(new_text),
|
|
meta: new_chunk_meta,
|
|
..
|
|
}),
|
|
) => merge_text_chunks!(
|
|
AgentThoughtChunk,
|
|
prev_session_id,
|
|
prev_meta,
|
|
prev_text,
|
|
prev_chunk_meta,
|
|
new_session_id,
|
|
new_meta,
|
|
new_text,
|
|
new_chunk_meta,
|
|
),
|
|
(prev, new) => (
|
|
true,
|
|
acp::SessionNotification::new(prev_session_id, prev).meta(prev_meta),
|
|
Some(acp::SessionNotification::new(new_session_id, new).meta(new_meta)),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Merge two consecutive xAI notifications.
|
|
fn merge_xai_chunks(
|
|
prev: crate::extensions::notification::SessionNotification,
|
|
new: crate::extensions::notification::SessionNotification,
|
|
) -> (
|
|
bool,
|
|
crate::extensions::notification::SessionNotification,
|
|
Option<crate::extensions::notification::SessionNotification>,
|
|
) {
|
|
use crate::extensions::notification::SessionUpdate as XUpdate;
|
|
|
|
if prev.session_id != new.session_id {
|
|
return (true, prev, Some(new));
|
|
}
|
|
|
|
let prev_session_id = prev.session_id.clone();
|
|
match (prev.update, new.update) {
|
|
(
|
|
XUpdate::ToolCallDeltaChunk {
|
|
tool_call_id: prev_id,
|
|
tool_index: prev_idx,
|
|
name: prev_name,
|
|
arguments_delta: prev_args,
|
|
},
|
|
XUpdate::ToolCallDeltaChunk {
|
|
tool_call_id: new_id,
|
|
tool_index: new_idx,
|
|
name: new_name,
|
|
arguments_delta: new_args,
|
|
},
|
|
) if same_tool_call(&prev_id, &new_id, prev_idx, new_idx) => {
|
|
// Merge: concat arguments_delta, prefer earlier id+name.
|
|
let merged_args = match (prev_args, new_args) {
|
|
(Some(mut a), Some(b)) => {
|
|
a.push_str(&b);
|
|
Some(a)
|
|
}
|
|
(Some(a), None) => Some(a),
|
|
(None, b) => b,
|
|
};
|
|
let merged = crate::extensions::notification::SessionNotification {
|
|
session_id: prev_session_id,
|
|
update: XUpdate::ToolCallDeltaChunk {
|
|
tool_call_id: prev_id.or(new_id),
|
|
tool_index: prev_idx,
|
|
name: prev_name.or(new_name),
|
|
arguments_delta: merged_args,
|
|
},
|
|
meta: None,
|
|
};
|
|
(false, merged, None)
|
|
}
|
|
(prev_update, new_update) => {
|
|
let prev_notif = crate::extensions::notification::SessionNotification {
|
|
session_id: prev_session_id.clone(),
|
|
update: prev_update,
|
|
meta: prev.meta,
|
|
};
|
|
let new_notif = crate::extensions::notification::SessionNotification {
|
|
session_id: prev_session_id,
|
|
update: new_update,
|
|
meta: new.meta,
|
|
};
|
|
(true, prev_notif, Some(new_notif))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Two `ToolCallDeltaChunk`s belong to the same tool call if their ids
|
|
/// match (when both present), otherwise if their `tool_index`es match.
|
|
/// Continuation chunks omit the id, so the index fallback is what
|
|
/// stitches them to the initial id+name chunk.
|
|
fn same_tool_call(
|
|
prev_id: &Option<String>,
|
|
new_id: &Option<String>,
|
|
prev_idx: u32,
|
|
new_idx: u32,
|
|
) -> bool {
|
|
match (prev_id, new_id) {
|
|
(Some(a), Some(b)) => a == b,
|
|
_ => prev_idx == new_idx,
|
|
}
|
|
}
|
|
|
|
fn estimate_payload_bytes(n: &SessionNotification) -> u64 {
|
|
match n {
|
|
SessionNotification::Acp(n) => match &n.update {
|
|
acp::SessionUpdate::AgentMessageChunk(chunk)
|
|
| acp::SessionUpdate::AgentThoughtChunk(chunk) => match &chunk.content {
|
|
acp::ContentBlock::Text(t) => t.text.len() as u64,
|
|
_ => 0,
|
|
},
|
|
_ => 0,
|
|
},
|
|
SessionNotification::Xai(n) => match &n.update {
|
|
crate::extensions::notification::SessionUpdate::ToolCallDeltaChunk {
|
|
arguments_delta,
|
|
name,
|
|
..
|
|
} => {
|
|
arguments_delta.as_ref().map(|s| s.len()).unwrap_or(0) as u64
|
|
+ name.as_ref().map(|s| s.len()).unwrap_or(0) as u64
|
|
}
|
|
_ => 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use agent_client_protocol as acp;
|
|
use serde_json::json;
|
|
|
|
fn settings(max_items: u64, max_bytes: u64) -> BufferingSettings {
|
|
BufferingSettings {
|
|
max_items,
|
|
max_bytes,
|
|
max_duration_ms: 50,
|
|
}
|
|
}
|
|
|
|
fn msg_chunk(session: &str, agent_ts_ms: u64, text: &str) -> acp::SessionNotification {
|
|
acp::SessionNotification::new(
|
|
acp::SessionId::new(session),
|
|
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
|
acp::TextContent::new(text.to_string()),
|
|
))),
|
|
)
|
|
.meta(
|
|
json!({
|
|
"agentTimestampMs": agent_ts_ms,
|
|
})
|
|
.as_object()
|
|
.cloned(),
|
|
)
|
|
}
|
|
|
|
fn pending_text(buf: &ReplayBuffer) -> Option<String> {
|
|
let pending = buf.pending.as_ref()?;
|
|
match pending {
|
|
SessionNotification::Acp(n) => match &n.update {
|
|
acp::SessionUpdate::AgentMessageChunk(chunk)
|
|
| acp::SessionUpdate::AgentThoughtChunk(chunk) => match &chunk.content {
|
|
acp::ContentBlock::Text(t) => Some(t.text.clone()),
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
},
|
|
SessionNotification::Xai(_) => None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn buffers_first_chunk_and_updates_count_and_bytes() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
let n = msg_chunk("s", 1, "hello");
|
|
|
|
let out = buf.consume_chunk(n);
|
|
assert!(out.is_none(), "first chunk should be buffered");
|
|
assert_eq!(buf.pending_count, 1);
|
|
assert_eq!(buf.pending_bytes, 5);
|
|
assert_eq!(pending_text(&buf).as_deref(), Some("hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn merges_second_chunk_and_increments_count_and_updates_bytes() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
|
|
assert!(buf.consume_chunk(msg_chunk("s", 1, "he")).is_none());
|
|
assert_eq!(buf.pending_count, 1);
|
|
assert_eq!(buf.pending_bytes, 2);
|
|
|
|
assert!(buf.consume_chunk(msg_chunk("s", 1, "llo")).is_none());
|
|
assert_eq!(buf.pending_count, 2);
|
|
assert_eq!(buf.pending_bytes, 5);
|
|
assert_eq!(pending_text(&buf).as_deref(), Some("hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn hits_max_items_threshold_and_flushes_instead_of_buffering() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(2, 1_000_000)));
|
|
|
|
assert!(buf.consume_chunk(msg_chunk("s", 1, "he")).is_none());
|
|
let out = buf.consume_chunk(msg_chunk("s", 1, "llo"));
|
|
|
|
let Some((first, second)) = out else {
|
|
panic!("expected flush when reaching max_items");
|
|
};
|
|
assert!(second.is_none(), "should flush single merged notification");
|
|
match &first.expect_acp().update {
|
|
acp::SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
|
acp::ContentBlock::Text(t) => assert_eq!(t.text, "hello"),
|
|
_ => panic!("expected text content"),
|
|
},
|
|
_ => panic!("expected AgentMessageChunk"),
|
|
}
|
|
assert!(buf.pending.is_none());
|
|
assert_eq!(buf.pending_count, 0);
|
|
assert_eq!(buf.pending_bytes, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn timestamp_mismatch_flushes_pending_and_does_not_buffer() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
|
|
assert!(buf.consume_chunk(msg_chunk("s", 1, "hello")).is_none());
|
|
assert_eq!(buf.pending_count, 1);
|
|
|
|
let out = buf.consume_chunk(msg_chunk("s", 52, "world"));
|
|
let Some((first, second)) = out else {
|
|
panic!("expected immediate send on timestamp mismatch");
|
|
};
|
|
assert_eq!(
|
|
match &first.expect_acp().update {
|
|
acp::SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
|
acp::ContentBlock::Text(t) => t.text.as_str(),
|
|
_ => "<non-text>",
|
|
},
|
|
_ => "<non-msg>",
|
|
},
|
|
"hello"
|
|
);
|
|
assert!(second.is_none());
|
|
|
|
assert!(buf.pending.is_some());
|
|
assert_eq!(buf.pending_count, 1);
|
|
assert_eq!(buf.pending_bytes, 5);
|
|
|
|
let rest = buf.flush();
|
|
assert!(rest.is_some());
|
|
assert_eq!(
|
|
rest.as_ref().map(|n| &n.expect_acp().update),
|
|
Some(&acp::SessionUpdate::AgentMessageChunk(
|
|
acp::ContentChunk::new(acp::ContentBlock::Text(acp::TextContent::new(
|
|
"world".to_string()
|
|
)))
|
|
))
|
|
);
|
|
assert!(buf.pending.is_none());
|
|
assert_eq!(buf.pending_count, 0);
|
|
assert_eq!(buf.pending_bytes, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn merges_chunk_ids_into_range() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
|
|
let mut n1 = msg_chunk("s", 1, "hello");
|
|
n1.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(1));
|
|
|
|
let mut n2 = msg_chunk("s", 1, " world");
|
|
n2.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(2));
|
|
|
|
assert!(buf.consume_chunk(n1).is_none());
|
|
assert!(buf.consume_chunk(n2).is_none());
|
|
|
|
let out = buf.flush().expect("should have pending");
|
|
let meta = out.into_acp().meta.expect("should have meta");
|
|
let chunk_id_range = meta.get("chunkIdRange").expect("should have chunkIdRange");
|
|
assert_eq!(chunk_id_range, &json!([[1, 2]]));
|
|
assert!(meta.get("chunkId").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn appends_to_existing_chunk_id_range() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
|
|
let mut n1 = msg_chunk("s", 1, "a");
|
|
n1.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkIdRange".to_string(), json!([[1, 2]]));
|
|
|
|
let mut n2 = msg_chunk("s", 1, "b");
|
|
n2.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(3));
|
|
|
|
assert!(buf.consume_chunk(n1).is_none());
|
|
assert!(buf.consume_chunk(n2).is_none());
|
|
|
|
let out = buf.flush().expect("should have pending");
|
|
let meta = out.into_acp().meta.expect("should have meta");
|
|
let chunk_id_range = meta.get("chunkIdRange").expect("should have chunkIdRange");
|
|
assert_eq!(chunk_id_range, &json!([[1, 3]]));
|
|
}
|
|
|
|
#[test]
|
|
fn squashes_consecutive_chunk_ids_into_single_range() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
|
|
let mut n1 = msg_chunk("s", 1, "a");
|
|
n1.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(1));
|
|
|
|
let mut n2 = msg_chunk("s", 1, "b");
|
|
n2.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(2));
|
|
|
|
let mut n3 = msg_chunk("s", 1, "c");
|
|
n3.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(3));
|
|
|
|
let mut n4 = msg_chunk("s", 1, "d");
|
|
n4.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(4));
|
|
|
|
assert!(buf.consume_chunk(n1).is_none());
|
|
assert!(buf.consume_chunk(n2).is_none());
|
|
assert!(buf.consume_chunk(n3).is_none());
|
|
assert!(buf.consume_chunk(n4).is_none());
|
|
|
|
let out = buf.flush().expect("should have pending");
|
|
let meta = out.into_acp().meta.expect("should have meta");
|
|
let chunk_id_range = meta.get("chunkIdRange").expect("should have chunkIdRange");
|
|
assert_eq!(chunk_id_range, &json!([[1, 4]]));
|
|
assert!(meta.get("chunkId").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_gapped_chunk_ids_as_separate_segments() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
|
|
let mut n1 = msg_chunk("s", 1, "a");
|
|
n1.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(1));
|
|
|
|
let mut n2 = msg_chunk("s", 1, "b");
|
|
n2.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(2));
|
|
|
|
let mut n3 = msg_chunk("s", 1, "c");
|
|
n3.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(4));
|
|
|
|
assert!(buf.consume_chunk(n1).is_none());
|
|
assert!(buf.consume_chunk(n2).is_none());
|
|
assert!(buf.consume_chunk(n3).is_none());
|
|
|
|
let out = buf.flush().expect("should have pending");
|
|
let meta = out.into_acp().meta.expect("should have meta");
|
|
let chunk_id_range = meta.get("chunkIdRange").expect("should have chunkIdRange");
|
|
assert_eq!(chunk_id_range, &json!([[1, 2], 4]));
|
|
assert!(meta.get("chunkId").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extends_trailing_singleton_when_next_is_consecutive() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
|
|
let mut n1 = msg_chunk("s", 1, "a");
|
|
n1.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkIdRange".to_string(), json!([[1, 2], 4]));
|
|
|
|
let mut n2 = msg_chunk("s", 1, "b");
|
|
n2.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(5));
|
|
|
|
assert!(buf.consume_chunk(n1).is_none());
|
|
assert!(buf.consume_chunk(n2).is_none());
|
|
|
|
let out = buf.flush().expect("should have pending");
|
|
let meta = out.into_acp().meta.expect("should have meta");
|
|
let chunk_id_range = meta.get("chunkIdRange").expect("should have chunkIdRange");
|
|
assert_eq!(chunk_id_range, &json!([[1, 2], [4, 5]]));
|
|
assert!(meta.get("chunkId").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn appends_non_consecutive_after_trailing_singleton() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
|
|
let mut n1 = msg_chunk("s", 1, "a");
|
|
n1.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkIdRange".to_string(), json!([[1, 2], 4]));
|
|
|
|
let mut n2 = msg_chunk("s", 1, "b");
|
|
n2.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(6));
|
|
|
|
assert!(buf.consume_chunk(n1).is_none());
|
|
assert!(buf.consume_chunk(n2).is_none());
|
|
|
|
let out = buf.flush().expect("should have pending");
|
|
let meta = out.into_acp().meta.expect("should have meta");
|
|
let chunk_id_range = meta.get("chunkIdRange").expect("should have chunkIdRange");
|
|
assert_eq!(chunk_id_range, &json!([[1, 2], 4, 6]));
|
|
assert!(meta.get("chunkId").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_out_of_order_chunk_ids_in_arrival_order() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
|
|
let mut n1 = msg_chunk("s", 1, "a");
|
|
n1.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(1));
|
|
|
|
let mut n2 = msg_chunk("s", 1, "b");
|
|
n2.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(2));
|
|
|
|
let mut n3 = msg_chunk("s", 1, "c");
|
|
n3.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(4));
|
|
|
|
let mut n4 = msg_chunk("s", 1, "d");
|
|
n4.meta
|
|
.as_mut()
|
|
.unwrap()
|
|
.insert("chunkId".to_string(), json!(3));
|
|
|
|
assert!(buf.consume_chunk(n1).is_none());
|
|
assert!(buf.consume_chunk(n2).is_none());
|
|
assert!(buf.consume_chunk(n3).is_none());
|
|
assert!(buf.consume_chunk(n4).is_none());
|
|
|
|
let out = buf.flush().expect("should have pending");
|
|
let meta = out.into_acp().meta.expect("should have meta");
|
|
let chunk_id_range = meta.get("chunkIdRange").expect("should have chunkIdRange");
|
|
assert_eq!(chunk_id_range, &json!([[1, 2], 4, 3]));
|
|
assert!(meta.get("chunkId").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn flush_clears_pending_and_resets_counters() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
|
|
assert!(buf.consume_chunk(msg_chunk("s", 1, "hello")).is_none());
|
|
assert_eq!(buf.pending_count, 1);
|
|
assert_eq!(buf.pending_bytes, 5);
|
|
|
|
let out = buf.flush();
|
|
assert!(out.is_some());
|
|
assert!(buf.pending.is_none());
|
|
assert_eq!(buf.pending_count, 0);
|
|
assert_eq!(buf.pending_bytes, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn flush_merges_streaming_chunks() {
|
|
use agent_client_protocol as acp;
|
|
|
|
let session_id = acp::SessionId::new("test-session");
|
|
let mut replay_buffer = ReplayBuffer::new(Some(BufferingSettings {
|
|
max_items: 100,
|
|
max_bytes: 1_000_000,
|
|
max_duration_ms: 50,
|
|
}));
|
|
|
|
let first = acp::SessionNotification::new(
|
|
session_id.clone(),
|
|
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
|
acp::TextContent::new("he".to_string()),
|
|
))),
|
|
)
|
|
.meta(
|
|
serde_json::json!({ "agentTimestampMs": 1000 })
|
|
.as_object()
|
|
.cloned(),
|
|
);
|
|
let second = acp::SessionNotification::new(
|
|
session_id,
|
|
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
|
acp::TextContent::new("llo".to_string()),
|
|
))),
|
|
)
|
|
.meta(
|
|
serde_json::json!({ "agentTimestampMs": 1000 })
|
|
.as_object()
|
|
.cloned(),
|
|
);
|
|
|
|
assert!(replay_buffer.consume_chunk(first).is_none());
|
|
assert!(replay_buffer.consume_chunk(second).is_none());
|
|
|
|
let flushed = replay_buffer.flush().expect("expected merged notification");
|
|
let text = match &flushed.expect_acp().update {
|
|
acp::SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
|
acp::ContentBlock::Text(t) => Some(t.text.clone()),
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
};
|
|
assert_eq!(text.as_deref(), Some("hello"));
|
|
assert!(replay_buffer.flush().is_none());
|
|
}
|
|
|
|
// ── Xai / ToolCallDeltaChunk tests ──────────────────────────────
|
|
|
|
fn delta_chunk(
|
|
session: &str,
|
|
tool_call_id: Option<&str>,
|
|
tool_index: u32,
|
|
name: Option<&str>,
|
|
arguments_delta: Option<&str>,
|
|
) -> crate::extensions::notification::SessionNotification {
|
|
crate::extensions::notification::SessionNotification {
|
|
session_id: acp::SessionId::new(session),
|
|
update: crate::extensions::notification::SessionUpdate::ToolCallDeltaChunk {
|
|
tool_call_id: tool_call_id.map(Into::into),
|
|
tool_index,
|
|
name: name.map(Into::into),
|
|
arguments_delta: arguments_delta.map(Into::into),
|
|
},
|
|
meta: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn xai_same_id_deltas_merge_args_and_preserve_name() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
let init = delta_chunk("s", Some("call_1"), 0, Some("read_file"), None);
|
|
let d1 = delta_chunk("s", None, 0, None, Some("{\"path\":"));
|
|
let d2 = delta_chunk("s", None, 0, None, Some("\"src\"}"));
|
|
|
|
assert!(buf.consume_chunk(init).is_none());
|
|
assert!(buf.consume_chunk(d1).is_none());
|
|
assert!(buf.consume_chunk(d2).is_none());
|
|
|
|
let flushed = buf.flush().expect("should have pending");
|
|
let n = match flushed {
|
|
SessionNotification::Xai(n) => *n,
|
|
_ => panic!("expected Xai"),
|
|
};
|
|
match n.update {
|
|
crate::extensions::notification::SessionUpdate::ToolCallDeltaChunk {
|
|
tool_call_id,
|
|
name,
|
|
arguments_delta,
|
|
..
|
|
} => {
|
|
assert_eq!(tool_call_id.as_deref(), Some("call_1"));
|
|
assert_eq!(name.as_deref(), Some("read_file"));
|
|
assert_eq!(arguments_delta.as_deref(), Some("{\"path\":\"src\"}"));
|
|
}
|
|
other => panic!("expected ToolCallDeltaChunk, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn xai_different_tool_call_id_forces_flush() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
let first = delta_chunk("s", Some("call_a"), 0, Some("grep"), Some("a-args"));
|
|
let second = delta_chunk("s", Some("call_b"), 1, Some("read_file"), Some("b-args"));
|
|
|
|
assert!(buf.consume_chunk(first).is_none());
|
|
let (flushed, rest) = buf.consume_chunk(second).expect("should force-flush");
|
|
|
|
// Both emitted immediately to preserve ordering.
|
|
assert!(matches!(flushed, SessionNotification::Xai(_)));
|
|
assert!(rest.is_some());
|
|
assert!(buf.pending.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn xai_tool_call_delta_is_bufferable() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
let chunk = delta_chunk("s", Some("call_1"), 0, Some("bash"), Some("{\"cmd\":"));
|
|
|
|
let out = buf.consume_chunk(chunk);
|
|
assert!(out.is_none(), "streaming chunk should be buffered");
|
|
assert!(buf.pending.is_some());
|
|
assert_eq!(buf.pending_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn xai_after_acp_forces_flush() {
|
|
let mut buf = ReplayBuffer::new(Some(settings(100, 1_000_000)));
|
|
let acp_chunk = msg_chunk("s", 1, "thinking...");
|
|
let xai_chunk = delta_chunk("s", Some("call_1"), 0, Some("bash"), Some("args"));
|
|
|
|
assert!(buf.consume_chunk(acp_chunk).is_none());
|
|
let (flushed, rest) = buf.consume_chunk(xai_chunk).expect("should force-flush");
|
|
|
|
// Both emitted immediately — different kinds can't merge.
|
|
assert!(matches!(flushed, SessionNotification::Acp(_)));
|
|
assert!(matches!(rest, Some(SessionNotification::Xai(_))));
|
|
assert!(buf.pending.is_none());
|
|
}
|
|
}
|