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` returns a notification that should be sent now /// (typically the previously buffered one), or `None` if we keep buffering. /// - `flush() -> Option` 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, pending: Option, pending_count: u64, pending_bytes: u64, } impl ReplayBuffer { pub(crate) fn new(settings: Option) -> 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 { self.settings.as_ref().map(|s| s.max_duration_ms) } pub(crate) fn consume_chunk( &mut self, incoming: impl Into, ) -> Option<(SessionNotification, Option)> { 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)> { 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, new: SessionNotification, ) -> (bool, SessionNotification, Option) { 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 { 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 { 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::().ok())) } fn append_chunk_id_range(range_arr: &mut Vec, 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, new: Option) -> Option { 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, ) { 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, ) { 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, new_id: &Option, 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 { 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(), _ => "", }, _ => "", }, "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()); } }