Skip to main content

adk_core/
event_stream.rs

1use crate::{Event, Part};
2use std::{borrow::Cow, collections::HashMap};
3
4/// Converts complete response snapshots to text and thinking deltas.
5///
6/// Create one adapter per live stream for append-only output. Keep the original
7/// events for persistence and consumers that support content replacement.
8///
9/// # Example
10///
11/// ```
12/// use adk_core::{Content, Event, EventTextDeltas};
13///
14/// let mut deltas = EventTextDeltas::default();
15/// let mut chunk = Event::new("invocation");
16/// chunk.llm_response.partial = true;
17/// chunk.set_content(Content::new("model").with_text("Hello"));
18/// deltas.push(&chunk);
19/// let mut terminal = chunk.clone();
20/// terminal.llm_response.partial = false;
21/// terminal.llm_response.provider_metadata = Some(serde_json::json!({"content_complete": true}));
22/// terminal.set_content(Content::new("model").with_text("Hello world"));
23/// let output = deltas.push(&terminal);
24/// assert_eq!(output.content().unwrap().parts[0].text(), Some(" world"));
25/// ```
26#[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    /// Preserves live chunks, tool parts, actions and usage. A complete snapshot
39    /// whose text differs from the emitted prefix is returned in full because
40    /// append-only output cannot retract earlier content.
41    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}