1use crate::{Event, Part};
2use std::{borrow::Cow, collections::HashMap};
3
4#[derive(Debug, Default)]
27pub struct EventTextDeltas {
28 pending: HashMap<(String, String), EmittedText>,
29}
30
31#[derive(Debug, Default)]
32struct EmittedText {
33 text: String,
34 thinking: String,
35}
36
37impl EventTextDeltas {
38 pub fn push<'a>(&mut self, event: &'a Event) -> Cow<'a, Event> {
42 if event.tool_progress_stream().is_some() {
43 return Cow::Borrowed(event);
44 }
45 let key = (event.invocation_id.clone(), event.id.clone());
46 if event.llm_response.partial {
47 if let Some(content) = event.content() {
48 for part in &content.parts {
49 match part {
50 Part::Text { text } if !text.is_empty() => {
51 self.pending.entry(key.clone()).or_default().text.push_str(text);
52 }
53 Part::Thinking { thinking, .. } if !thinking.is_empty() => {
54 self.pending
55 .entry(key.clone())
56 .or_default()
57 .thinking
58 .push_str(thinking);
59 }
60 _ => {}
61 }
62 }
63 }
64 return Cow::Borrowed(event);
65 }
66 let Some(emitted) = self.pending.remove(&key) else {
67 return Cow::Borrowed(event);
68 };
69 let complete = event
70 .llm_response
71 .provider_metadata
72 .as_ref()
73 .and_then(|metadata| metadata.get("content_complete"))
74 .and_then(serde_json::Value::as_bool)
75 == Some(true);
76 let Some(content) = event.content().filter(|_| complete) else {
77 return Cow::Borrowed(event);
78 };
79 let mut full = EmittedText::default();
80 for part in &content.parts {
81 match part {
82 Part::Text { text } => full.text.push_str(text),
83 Part::Thinking { thinking, .. } => full.thinking.push_str(thinking),
84 _ => {}
85 }
86 }
87 let mut text_prefix =
88 if full.text.starts_with(&emitted.text) { emitted.text.len() } else { 0 };
89 let mut thinking_prefix =
90 if full.thinking.starts_with(&emitted.thinking) { emitted.thinking.len() } else { 0 };
91 if text_prefix == 0 && thinking_prefix == 0 {
92 return Cow::Borrowed(event);
93 }
94 let mut delta = event.clone();
95 if let Some(content) = &mut delta.llm_response.content {
96 for part in &mut content.parts {
97 match part {
98 Part::Text { text } => strip_prefix(text, &mut text_prefix),
99 Part::Thinking { thinking, .. } => strip_prefix(thinking, &mut thinking_prefix),
100 _ => {}
101 }
102 }
103 }
104 Cow::Owned(delta)
105 }
106}
107
108fn strip_prefix(value: &mut String, remaining: &mut usize) {
109 let consumed = (*remaining).min(value.len());
110 value.drain(..consumed);
111 *remaining -= consumed;
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::Content;
118
119 fn response(id: &str, text: &str, partial: bool) -> Event {
120 let mut event = Event::with_id(id, "invocation");
121 event.llm_response.content = Some(Content::new("model").with_text(text));
122 event.llm_response.partial = partial;
123 if !partial {
124 event.llm_response.provider_metadata =
125 Some(serde_json::json!({"content_complete":true}));
126 }
127 event
128 }
129
130 #[test]
131 fn preserves_live_chunks_and_emits_only_the_terminal_suffix() {
132 let mut deltas = EventTextDeltas::default();
133 let first = response("first", "你好", true);
134 assert!(matches!(deltas.push(&first), Cow::Borrowed(_)));
135 let sibling = response("second", "Other", true);
136 deltas.push(&sibling);
137 let mut terminal = response("first", "你好,world", false);
138 terminal.llm_response.content.as_mut().unwrap().parts.push(Part::FunctionCall {
139 name: "search".into(),
140 args: serde_json::json!({}),
141 id: Some("call".into()),
142 thought_signature: None,
143 });
144 let output = deltas.push(&terminal);
145 assert_eq!(output.content().unwrap().parts[0], Part::Text { text: ",world".into() });
146 assert_eq!(output.content().unwrap().parts[1], terminal.content().unwrap().parts[1]);
147 assert_eq!(terminal.content().unwrap().parts[0], Part::Text { text: "你好,world".into() });
148 let sibling = response("second", "Other result", false);
149 assert_eq!(
150 deltas.push(&sibling).content().unwrap().parts[0],
151 Part::Text { text: " result".into() }
152 );
153 assert!(deltas.pending.is_empty());
154 }
155
156 #[test]
157 fn retains_unmarked_deltas_and_corrected_snapshots() {
158 for marked in [true, false] {
159 let mut deltas = EventTextDeltas::default();
160 deltas.push(&response("response", "Earlier", true));
161 let mut terminal = response("response", "Correction", false);
162 if !marked {
163 terminal.llm_response.provider_metadata = None;
164 }
165 assert!(matches!(deltas.push(&terminal), Cow::Borrowed(_)));
166 assert!(deltas.pending.is_empty());
167 }
168 }
169
170 #[test]
171 fn preserves_thinking_signatures_and_ignores_tool_progress() {
172 let mut deltas = EventTextDeltas::default();
173 let mut partial = response("response", "", true);
174 partial.llm_response.content = Some(Content::new("model").with_thinking("思考"));
175 deltas.push(&partial);
176 let mut progress = Event::tool_progress("invocation", "agent", "call", "stdout", "log");
177 progress.id = partial.id.clone();
178 assert!(matches!(deltas.push(&progress), Cow::Borrowed(_)));
179 let mut terminal = response("response", "Answer", false);
180 terminal.llm_response.content.as_mut().unwrap().parts.insert(
181 0,
182 Part::Thinking { thinking: "思考完成".into(), signature: Some("signed".into()) },
183 );
184 assert_eq!(
185 deltas.push(&terminal).content().unwrap().parts[0],
186 Part::Thinking { thinking: "完成".into(), signature: Some("signed".into()) }
187 );
188 assert_eq!(
189 terminal.content().unwrap().parts[0],
190 Part::Thinking { thinking: "思考完成".into(), signature: Some("signed".into()) }
191 );
192 assert!(deltas.pending.is_empty());
193 }
194}