adk_core/event.rs
1use crate::context::{ToolConfirmationDecision, ToolConfirmationRequest};
2use crate::model::LlmResponse;
3use crate::types::Content;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use uuid::Uuid;
8
9// State scope prefixes
10/// Key prefix for application-scoped state (persists across sessions).
11pub const KEY_PREFIX_APP: &str = "app:";
12/// Key prefix for temporary state (cleared each turn).
13pub const KEY_PREFIX_TEMP: &str = "temp:";
14/// Key prefix for user-scoped state (persists across sessions).
15pub const KEY_PREFIX_USER: &str = "user:";
16
17/// Event-level `provider_metadata` key marking a tool-progress event and naming
18/// its output stream (e.g. `"stdout"`, `"stderr"`). Present only on events
19/// produced by [`ToolContext::emit_progress`](crate::ToolContext::emit_progress).
20pub const TOOL_PROGRESS_STREAM_KEY: &str = "adk.tool_progress.stream";
21
22/// Event-level `provider_metadata` key carrying the originating tool's
23/// function-call id on a tool-progress event.
24pub const TOOL_PROGRESS_CALL_ID_KEY: &str = "adk.tool_progress.call_id";
25
26/// Event represents a single interaction in a conversation.
27/// This struct embeds LlmResponse to match ADK-Go's design pattern.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Event {
30 /// Unique identifier for this event.
31 pub id: String,
32 /// When this event was created.
33 pub timestamp: DateTime<Utc>,
34 /// The invocation that produced this event.
35 pub invocation_id: String,
36 /// The conversation branch this event belongs to.
37 pub branch: String,
38 /// The agent or role that authored this event.
39 pub author: String,
40 /// The LLM response containing content and metadata.
41 /// Access content via `event.llm_response.content`.
42 #[serde(flatten)]
43 pub llm_response: LlmResponse,
44 /// Actions to apply (state changes, transfers, confirmations).
45 pub actions: EventActions,
46 /// IDs of long-running tools associated with this event.
47 #[serde(default)]
48 pub long_running_tool_ids: Vec<String>,
49 /// LLM request data for UI display (JSON string)
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub llm_request: Option<String>,
52 /// Provider-specific metadata (e.g., GCP Vertex, Azure OpenAI).
53 /// Keeps the core Event struct provider-agnostic.
54 /// Serialized as `"event_metadata"` to avoid collision with
55 /// [`LlmResponse::provider_metadata`](crate::LlmResponse) when flattened.
56 #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "event_metadata")]
57 pub provider_metadata: HashMap<String, String>,
58}
59
60/// Metadata for a compacted (summarized) event.
61/// When context compaction is enabled, older events are summarized into a single
62/// compacted event containing this metadata.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct EventCompaction {
65 /// Timestamp of the earliest event that was compacted.
66 pub start_timestamp: DateTime<Utc>,
67 /// Timestamp of the latest event that was compacted.
68 pub end_timestamp: DateTime<Utc>,
69 /// The summarized content replacing the original events.
70 pub compacted_content: Content,
71}
72
73/// Actions to apply as side effects of an event.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct EventActions {
76 /// State key-value changes to apply.
77 pub state_delta: HashMap<String, serde_json::Value>,
78 /// Artifact version changes.
79 pub artifact_delta: HashMap<String, i64>,
80 /// Whether to skip summarization for this event.
81 pub skip_summarization: bool,
82 /// Agent name to transfer control to.
83 pub transfer_to_agent: Option<String>,
84 /// Whether to escalate to a human operator.
85 pub escalate: bool,
86 /// Tool confirmation request awaiting human approval.
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub tool_confirmation: Option<ToolConfirmationRequest>,
89 /// Decision for a pending tool confirmation.
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub tool_confirmation_decision: Option<ToolConfirmationDecision>,
92 /// Present when this event is a compaction summary replacing older events.
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub compaction: Option<EventCompaction>,
95 /// Target node names for dynamic route dispatch in graph workflows.
96 /// When non-empty, the graph executor routes to these nodes instead of
97 /// following static edges.
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub route: Option<Vec<String>>,
100}
101
102/// A typed, borrowed view of a single tool call carried by an [`Event`].
103///
104/// Produced by [`Event::tool_calls`]. Lets UI/event consumers render the tool a
105/// model requested without matching on [`Part::FunctionCall`](crate::Part::FunctionCall)
106/// internals.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct ToolCallView<'a> {
109 /// Provider-assigned call id (OpenAI-style). `None` for providers that omit
110 /// it (e.g. Gemini); fall back to [`name`](Self::name) for correlation.
111 pub call_id: Option<&'a str>,
112 /// The tool/function name the model requested.
113 pub name: &'a str,
114 /// The call arguments as raw JSON.
115 pub args: &'a serde_json::Value,
116}
117
118/// A typed, borrowed view of a single tool result carried by an [`Event`].
119///
120/// Produced by [`Event::tool_results`]. Surfaces a completed tool's output
121/// generically so any tool — streaming or not — can be rendered from the event
122/// stream without walking [`Part::FunctionResponse`](crate::Part::FunctionResponse)
123/// internals.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct ToolResultView<'a> {
126 /// Provider-assigned call id (OpenAI-style), correlating this result with
127 /// its originating [`ToolCallView`] and progress chunks. `None` for
128 /// providers that omit it (e.g. Gemini); fall back to [`name`](Self::name).
129 pub call_id: Option<&'a str>,
130 /// The tool/function name that produced this result.
131 pub name: &'a str,
132 /// The tool's JSON response payload.
133 pub response: &'a serde_json::Value,
134}
135
136impl Event {
137 /// Creates a new event with a generated UUID and current timestamp.
138 pub fn new(invocation_id: impl Into<String>) -> Self {
139 Self {
140 id: Uuid::new_v4().to_string(),
141 timestamp: Utc::now(),
142 invocation_id: invocation_id.into(),
143 branch: String::new(),
144 author: String::new(),
145 llm_response: LlmResponse::default(),
146 actions: EventActions::default(),
147 long_running_tool_ids: Vec::new(),
148 llm_request: None,
149 provider_metadata: HashMap::new(),
150 }
151 }
152
153 /// Create an event with a specific ID.
154 /// Use this for streaming events where all chunks should share the same event ID.
155 pub fn with_id(id: impl Into<String>, invocation_id: impl Into<String>) -> Self {
156 Self {
157 id: id.into(),
158 timestamp: Utc::now(),
159 invocation_id: invocation_id.into(),
160 branch: String::new(),
161 author: String::new(),
162 llm_response: LlmResponse::default(),
163 actions: EventActions::default(),
164 long_running_tool_ids: Vec::new(),
165 llm_request: None,
166 provider_metadata: HashMap::new(),
167 }
168 }
169
170 /// Creates a streaming tool-progress event.
171 ///
172 /// Tools emit these via [`ToolContext::emit_progress`](crate::ToolContext::emit_progress)
173 /// to push intermediate stdout/stderr to the client *while the tool is still
174 /// running*. The event carries the chunk as partial text content (role
175 /// `"tool"`) and is tagged with [`TOOL_PROGRESS_STREAM_KEY`] /
176 /// [`TOOL_PROGRESS_CALL_ID_KEY`] so consumers can distinguish it from a
177 /// final tool result and route it to the right terminal widget.
178 ///
179 /// # Example
180 ///
181 /// ```
182 /// use adk_core::Event;
183 ///
184 /// let event = Event::tool_progress("inv-1", "agent", "call-7", "stdout", "compiling...\n");
185 /// assert_eq!(event.tool_progress_stream(), Some("stdout"));
186 /// assert!(event.llm_response.partial);
187 /// ```
188 pub fn tool_progress(
189 invocation_id: impl Into<String>,
190 author: impl Into<String>,
191 function_call_id: impl Into<String>,
192 stream: impl Into<String>,
193 chunk: impl Into<String>,
194 ) -> Self {
195 let mut event = Event::new(invocation_id);
196 event.author = author.into();
197 event.llm_response.content = Some(Content {
198 role: "tool".to_string(),
199 parts: vec![crate::types::Part::Text { text: chunk.into() }],
200 });
201 // Partial so downstream aggregation/persistence treats it as a streaming
202 // chunk, never as the agent's final response.
203 event.llm_response.partial = true;
204 event.provider_metadata.insert(TOOL_PROGRESS_STREAM_KEY.to_string(), stream.into());
205 event
206 .provider_metadata
207 .insert(TOOL_PROGRESS_CALL_ID_KEY.to_string(), function_call_id.into());
208 event
209 }
210
211 /// Returns the progress stream name (`"stdout"`, `"stderr"`, …) if this is a
212 /// tool-progress event produced by [`ToolContext::emit_progress`](crate::ToolContext::emit_progress),
213 /// otherwise `None`.
214 pub fn tool_progress_stream(&self) -> Option<&str> {
215 self.provider_metadata.get(TOOL_PROGRESS_STREAM_KEY).map(String::as_str)
216 }
217
218 /// Returns the tool calls carried by this event, as a typed, render-ready view.
219 ///
220 /// A UI consuming the agent's `EventStream` can call this on every event to
221 /// detect when the model requested one or more tools, without matching on
222 /// [`Part::FunctionCall`](crate::Part::FunctionCall) internals. Pair it with
223 /// [`tool_results`](Self::tool_results) and [`tool_progress_stream`](Self::tool_progress_stream)
224 /// to render a complete tool lifecycle (call → live progress → result).
225 ///
226 /// Returns an empty vector for events that contain no tool calls.
227 ///
228 /// # Correlation
229 ///
230 /// Use [`ToolCallView::call_id`] to correlate a call with its progress chunks
231 /// and final result. For providers that omit call ids (e.g. Gemini), fall
232 /// back to [`ToolCallView::name`].
233 ///
234 /// # Example
235 ///
236 /// ```
237 /// use adk_core::{Content, Event, Part};
238 ///
239 /// let mut event = Event::new("inv-1");
240 /// event.llm_response.content = Some(Content {
241 /// role: "model".to_string(),
242 /// parts: vec![Part::FunctionCall {
243 /// name: "bash".to_string(),
244 /// args: serde_json::json!({ "command": "ls" }),
245 /// id: Some("call_1".to_string()),
246 /// thought_signature: None,
247 /// }],
248 /// });
249 ///
250 /// let calls = event.tool_calls();
251 /// assert_eq!(calls.len(), 1);
252 /// assert_eq!(calls[0].name, "bash");
253 /// assert_eq!(calls[0].call_id, Some("call_1"));
254 /// ```
255 pub fn tool_calls(&self) -> Vec<ToolCallView<'_>> {
256 let Some(content) = &self.llm_response.content else {
257 return Vec::new();
258 };
259 content
260 .parts
261 .iter()
262 .filter_map(|part| match part {
263 crate::types::Part::FunctionCall { name, args, id, .. } => {
264 Some(ToolCallView { call_id: id.as_deref(), name, args })
265 }
266 _ => None,
267 })
268 .collect()
269 }
270
271 /// Returns the tool results carried by this event, as a typed, render-ready view.
272 ///
273 /// After a tool executes, the agent yields its result on the same
274 /// `EventStream` as everything else, as a `function`-role event holding a
275 /// [`Part::FunctionResponse`](crate::Part::FunctionResponse). This accessor
276 /// surfaces those results generically so a UI can render the output of *any*
277 /// tool — streaming or not — without walking part internals.
278 ///
279 /// Returns an empty vector for events that contain no tool results.
280 ///
281 /// # Correlation
282 ///
283 /// Use [`ToolResultView::call_id`] to attach a result to the originating
284 /// [`tool_calls`](Self::tool_calls) entry and its progress chunks. For
285 /// providers that omit call ids, fall back to [`ToolResultView::name`].
286 ///
287 /// # Example
288 ///
289 /// ```
290 /// use adk_core::{Content, Event, FunctionResponseData, Part};
291 ///
292 /// let mut event = Event::new("inv-1");
293 /// event.llm_response.content = Some(Content {
294 /// role: "function".to_string(),
295 /// parts: vec![Part::FunctionResponse {
296 /// function_response: FunctionResponseData::new(
297 /// "bash",
298 /// serde_json::json!({ "stdout": "ok\n", "exit_code": 0 }),
299 /// ),
300 /// id: Some("call_1".to_string()),
301 /// annotations: None,
302 /// }],
303 /// });
304 ///
305 /// let results = event.tool_results();
306 /// assert_eq!(results.len(), 1);
307 /// assert_eq!(results[0].name, "bash");
308 /// assert_eq!(results[0].call_id, Some("call_1"));
309 /// assert_eq!(results[0].response["exit_code"], 0);
310 /// ```
311 pub fn tool_results(&self) -> Vec<ToolResultView<'_>> {
312 let Some(content) = &self.llm_response.content else {
313 return Vec::new();
314 };
315 content
316 .parts
317 .iter()
318 .filter_map(|part| match part {
319 crate::types::Part::FunctionResponse { function_response, id, .. } => {
320 Some(ToolResultView {
321 call_id: id.as_deref(),
322 name: &function_response.name,
323 response: &function_response.response,
324 })
325 }
326 _ => None,
327 })
328 .collect()
329 }
330
331 /// Convenience method to access content directly.
332 pub fn content(&self) -> Option<&Content> {
333 self.llm_response.content.as_ref()
334 }
335
336 /// Convenience method to set content directly.
337 pub fn set_content(&mut self, content: Content) {
338 self.llm_response.content = Some(content);
339 }
340
341 /// Returns the Interactions API interaction id for this event, if present.
342 ///
343 /// Reads the id from the flattened [`LlmResponse`], mirroring ADK-Python's
344 /// `event.interaction_id`. Returns `None` for events produced by the
345 /// generateContent transport and non-Gemini providers.
346 ///
347 /// # Example
348 ///
349 /// ```
350 /// use adk_core::Event;
351 ///
352 /// let mut event = Event::new("inv-123");
353 /// assert_eq!(event.interaction_id(), None);
354 ///
355 /// event.llm_response.interaction_id = Some("v1_abc".to_string());
356 /// assert_eq!(event.interaction_id(), Some("v1_abc"));
357 /// ```
358 pub fn interaction_id(&self) -> Option<&str> {
359 self.llm_response.interaction_id.as_deref()
360 }
361
362 /// Returns whether the event is the final response of an agent.
363 ///
364 /// An event is considered final if:
365 /// - It has skip_summarization set, OR
366 /// - It has long_running_tool_ids (indicating async operations), OR
367 /// - It has no function calls, no function responses, is not partial,
368 /// and has no trailing code execution results.
369 ///
370 /// Note: When multiple agents participate in one invocation, there could be
371 /// multiple events with is_final_response() as true, for each participating agent.
372 pub fn is_final_response(&self) -> bool {
373 // If skip_summarization is set or we have long-running tools, it's final
374 if self.actions.skip_summarization || !self.long_running_tool_ids.is_empty() {
375 return true;
376 }
377
378 // Check content for function calls/responses
379 let has_function_calls = self.has_function_calls();
380 let has_function_responses = self.has_function_responses();
381 let is_partial = self.llm_response.partial;
382 let has_trailing_code_result = self.has_trailing_code_execution_result();
383
384 !has_function_calls && !has_function_responses && !is_partial && !has_trailing_code_result
385 }
386
387 /// Returns true if the event content contains function calls.
388 fn has_function_calls(&self) -> bool {
389 if let Some(content) = &self.llm_response.content {
390 for part in &content.parts {
391 if matches!(part, crate::Part::FunctionCall { .. }) {
392 return true;
393 }
394 }
395 }
396 false
397 }
398
399 /// Returns true if the event content contains function responses.
400 fn has_function_responses(&self) -> bool {
401 if let Some(content) = &self.llm_response.content {
402 for part in &content.parts {
403 if matches!(part, crate::Part::FunctionResponse { .. }) {
404 return true;
405 }
406 }
407 }
408 false
409 }
410
411 /// Returns true if the event has a trailing code execution result.
412 #[allow(clippy::match_like_matches_macro)]
413 fn has_trailing_code_execution_result(&self) -> bool {
414 if let Some(content) = &self.llm_response.content
415 && let Some(last_part) = content.parts.last()
416 {
417 // FunctionResponse as the last part indicates a code execution result
418 // that the model still needs to process.
419 return matches!(last_part, crate::Part::FunctionResponse { .. });
420 }
421 false
422 }
423
424 /// Extracts function call IDs from this event's content.
425 /// Used to identify which function calls are associated with long-running tools.
426 pub fn function_call_ids(&self) -> Vec<String> {
427 let mut ids = Vec::new();
428 if let Some(content) = &self.llm_response.content {
429 for part in &content.parts {
430 if let crate::Part::FunctionCall { name, id, .. } = part {
431 // Use the actual call ID when available (OpenAI-style),
432 // fall back to name for providers that don't emit IDs (Gemini).
433 ids.push(id.as_deref().unwrap_or(name).to_string());
434 }
435 }
436 }
437 ids
438 }
439}
440
441/// Whether an event is visible from a given conversation branch.
442///
443/// An event is visible when its branch equals `invocation_branch` or is an
444/// *ancestor* of it. Siblings and descendants are excluded, which is what keeps
445/// concurrent `ParallelAgent` branches from seeing each other's output while
446/// still letting each one see the conversation that led to the fan-out.
447///
448/// An empty branch on either side means "unscoped" and matches everything, so
449/// events written without a branch stay globally visible and callers that never
450/// set a branch are unaffected.
451///
452/// Branch segments are delimited by `.`, and the prefix test requires that
453/// delimiter explicitly: without it `agent_0` would match `agent_00`. This
454/// mirrors ADK Python's `_is_event_belongs_to_branch` and ADK Go's
455/// `eventBelongsToBranch`.
456///
457/// # Example
458///
459/// ```
460/// use adk_core::event_belongs_to_branch;
461///
462/// // Own branch and ancestors are visible.
463/// assert!(event_belongs_to_branch("parent.parallel.a", "parent.parallel.a"));
464/// assert!(event_belongs_to_branch("parent.parallel.a", "parent"));
465///
466/// // A sibling branch is not.
467/// assert!(!event_belongs_to_branch("parent.parallel.a", "parent.parallel.b"));
468///
469/// // Unscoped events remain visible.
470/// assert!(event_belongs_to_branch("parent.parallel.a", ""));
471/// ```
472pub fn event_belongs_to_branch(invocation_branch: &str, event_branch: &str) -> bool {
473 if invocation_branch.is_empty() || event_branch.is_empty() {
474 return true;
475 }
476 if event_branch == invocation_branch {
477 return true;
478 }
479 // Require the delimiter so `agent_0` does not match `agent_00`.
480 invocation_branch.starts_with(event_branch)
481 && invocation_branch.as_bytes().get(event_branch.len()) == Some(&b'.')
482}
483
484#[cfg(test)]
485mod branch_visibility_tests {
486 use super::event_belongs_to_branch;
487
488 #[test]
489 fn own_branch_is_visible() {
490 assert!(event_belongs_to_branch("root.parallel.a", "root.parallel.a"));
491 }
492
493 #[test]
494 fn ancestor_branches_are_visible() {
495 assert!(event_belongs_to_branch("root.parallel.a", "root"));
496 assert!(event_belongs_to_branch("root.parallel.a", "root.parallel"));
497 }
498
499 #[test]
500 fn sibling_branches_are_hidden() {
501 assert!(!event_belongs_to_branch("root.parallel.a", "root.parallel.b"));
502 assert!(!event_belongs_to_branch("root.parallel.b", "root.parallel.a"));
503 }
504
505 #[test]
506 fn descendant_branches_are_hidden() {
507 // A parent must not see what a nested child produced in its own branch.
508 assert!(!event_belongs_to_branch("root", "root.parallel.a"));
509 }
510
511 #[test]
512 fn empty_branch_on_either_side_matches() {
513 assert!(event_belongs_to_branch("", "root.parallel.a"));
514 assert!(event_belongs_to_branch("root.parallel.a", ""));
515 assert!(event_belongs_to_branch("", ""));
516 }
517
518 #[test]
519 fn prefix_match_requires_the_delimiter() {
520 // The bug the delimiter guards against: `agent_0` vs `agent_00`.
521 assert!(!event_belongs_to_branch("root.agent_00", "root.agent_0"));
522 assert!(event_belongs_to_branch("root.agent_0.child", "root.agent_0"));
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use crate::Part;
530
531 #[test]
532 fn test_event_creation() {
533 let event = Event::new("inv-123");
534 assert_eq!(event.invocation_id, "inv-123");
535 assert!(!event.id.is_empty());
536 }
537
538 #[test]
539 fn test_event_actions_default() {
540 let actions = EventActions::default();
541 assert!(actions.state_delta.is_empty());
542 assert!(!actions.skip_summarization);
543 assert!(actions.tool_confirmation.is_none());
544 assert!(actions.tool_confirmation_decision.is_none());
545 }
546
547 #[test]
548 fn test_state_prefixes() {
549 assert_eq!(KEY_PREFIX_APP, "app:");
550 assert_eq!(KEY_PREFIX_TEMP, "temp:");
551 assert_eq!(KEY_PREFIX_USER, "user:");
552 }
553
554 #[test]
555 fn test_is_final_response_no_content() {
556 let event = Event::new("inv-123");
557 // No content, no function calls -> final
558 assert!(event.is_final_response());
559 }
560
561 #[test]
562 fn test_is_final_response_text_only() {
563 let mut event = Event::new("inv-123");
564 event.llm_response.content = Some(Content {
565 role: "model".to_string(),
566 parts: vec![Part::Text { text: "Hello!".to_string() }],
567 });
568 // Text only, no function calls -> final
569 assert!(event.is_final_response());
570 }
571
572 #[test]
573 fn test_is_final_response_with_function_call() {
574 let mut event = Event::new("inv-123");
575 event.llm_response.content = Some(Content {
576 role: "model".to_string(),
577 parts: vec![Part::FunctionCall {
578 name: "get_weather".to_string(),
579 args: serde_json::json!({"city": "NYC"}),
580 id: Some("call_123".to_string()),
581 thought_signature: None,
582 }],
583 });
584 // Has function call -> NOT final (need to execute it)
585 assert!(!event.is_final_response());
586 }
587
588 #[test]
589 fn test_is_final_response_with_function_response() {
590 let mut event = Event::new("inv-123");
591 event.llm_response.content = Some(Content {
592 role: "function".to_string(),
593 parts: vec![Part::FunctionResponse {
594 function_response: crate::FunctionResponseData::new(
595 "get_weather",
596 serde_json::json!({"temp": 72}),
597 ),
598 id: Some("call_123".to_string()),
599 annotations: None,
600 }],
601 });
602 // Has function response -> NOT final (model needs to respond)
603 assert!(!event.is_final_response());
604 }
605
606 #[test]
607 fn test_is_final_response_partial() {
608 let mut event = Event::new("inv-123");
609 event.llm_response.partial = true;
610 event.llm_response.content = Some(Content {
611 role: "model".to_string(),
612 parts: vec![Part::Text { text: "Hello...".to_string() }],
613 });
614 // Partial response -> NOT final
615 assert!(!event.is_final_response());
616 }
617
618 #[test]
619 fn test_is_final_response_skip_summarization() {
620 let mut event = Event::new("inv-123");
621 event.actions.skip_summarization = true;
622 event.llm_response.content = Some(Content {
623 role: "function".to_string(),
624 parts: vec![Part::FunctionResponse {
625 function_response: crate::FunctionResponseData::new(
626 "tool",
627 serde_json::json!({"result": "done"}),
628 ),
629 id: Some("call_tool".to_string()),
630 annotations: None,
631 }],
632 });
633 // Even with function response, skip_summarization makes it final
634 assert!(event.is_final_response());
635 }
636
637 #[test]
638 fn test_is_final_response_long_running_tool_ids() {
639 let mut event = Event::new("inv-123");
640 event.long_running_tool_ids = vec!["process_video".to_string()];
641 event.llm_response.content = Some(Content {
642 role: "model".to_string(),
643 parts: vec![Part::FunctionCall {
644 name: "process_video".to_string(),
645 args: serde_json::json!({"file": "video.mp4"}),
646 id: Some("call_process".to_string()),
647 thought_signature: None,
648 }],
649 });
650 // Has long_running_tool_ids -> final (async operation started)
651 assert!(event.is_final_response());
652 }
653
654 #[test]
655 fn test_function_call_ids() {
656 let mut event = Event::new("inv-123");
657 event.llm_response.content = Some(Content {
658 role: "model".to_string(),
659 parts: vec![
660 Part::FunctionCall {
661 name: "get_weather".to_string(),
662 args: serde_json::json!({}),
663 id: Some("call_1".to_string()),
664 thought_signature: None,
665 },
666 Part::Text { text: "I'll check the weather".to_string() },
667 Part::FunctionCall {
668 name: "get_time".to_string(),
669 args: serde_json::json!({}),
670 id: Some("call_2".to_string()),
671 thought_signature: None,
672 },
673 ],
674 });
675
676 let ids = event.function_call_ids();
677 assert_eq!(ids.len(), 2);
678 // Should use actual call IDs, not function names
679 assert!(ids.contains(&"call_1".to_string()));
680 assert!(ids.contains(&"call_2".to_string()));
681 }
682
683 #[test]
684 fn test_function_call_ids_falls_back_to_name() {
685 let mut event = Event::new("inv-123");
686 event.llm_response.content = Some(Content {
687 role: "model".to_string(),
688 parts: vec![Part::FunctionCall {
689 name: "get_weather".to_string(),
690 args: serde_json::json!({}),
691 id: None, // Gemini-style: no explicit ID
692 thought_signature: None,
693 }],
694 });
695
696 let ids = event.function_call_ids();
697 assert_eq!(ids, vec!["get_weather".to_string()]);
698 }
699
700 #[test]
701 fn test_function_call_ids_empty() {
702 let event = Event::new("inv-123");
703 let ids = event.function_call_ids();
704 assert!(ids.is_empty());
705 }
706
707 #[test]
708 fn test_is_final_response_trailing_function_response() {
709 // Text followed by a function response as the last part —
710 // has_trailing_code_execution_result should catch this even though
711 // has_function_responses also catches it.
712 let mut event = Event::new("inv-123");
713 event.llm_response.content = Some(Content {
714 role: "model".to_string(),
715 parts: vec![
716 Part::Text { text: "Running code...".to_string() },
717 Part::FunctionResponse {
718 function_response: crate::FunctionResponseData::new(
719 "code_exec",
720 serde_json::json!({"output": "42"}),
721 ),
722 id: Some("call_exec".to_string()),
723 annotations: None,
724 },
725 ],
726 });
727 // Trailing function response -> NOT final
728 assert!(!event.is_final_response());
729 }
730
731 #[test]
732 fn test_event_roundtrip_with_both_provider_metadata() {
733 let mut event = Event::new("inv-1");
734 event.provider_metadata.insert("adk.tool_progress.stream".into(), "stdout".into());
735 event.provider_metadata.insert("adk.tool_progress.call_id".into(), "call-7".into());
736 event.llm_response.provider_metadata = Some(serde_json::json!({"response_id": "resp-xyz"}));
737
738 let json = serde_json::to_string(&event).expect("serialize");
739 // Round-trip must succeed — regression test for the duplicate
740 // `provider_metadata` flatten collision with LlmResponse.
741 let back: Event = serde_json::from_str(&json)
742 .expect("round-trip must succeed without duplicate field error");
743
744 assert_eq!(
745 back.provider_metadata.get("adk.tool_progress.stream").map(String::as_str),
746 Some("stdout"),
747 );
748 assert_eq!(
749 back.provider_metadata.get("adk.tool_progress.call_id").map(String::as_str),
750 Some("call-7"),
751 );
752 assert_eq!(
753 back.llm_response.provider_metadata,
754 Some(serde_json::json!({"response_id": "resp-xyz"})),
755 );
756 }
757
758 #[test]
759 fn test_is_final_response_text_after_function_response() {
760 // Function response followed by text — the trailing part is text,
761 // so has_trailing_code_execution_result is false, but
762 // has_function_responses is still true.
763 let mut event = Event::new("inv-123");
764 event.llm_response.content = Some(Content {
765 role: "model".to_string(),
766 parts: vec![
767 Part::FunctionResponse {
768 function_response: crate::FunctionResponseData::new(
769 "tool",
770 serde_json::json!({}),
771 ),
772 id: Some("call_1".to_string()),
773 annotations: None,
774 },
775 Part::Text { text: "Done".to_string() },
776 ],
777 });
778 // Still has function responses -> NOT final
779 assert!(!event.is_final_response());
780 }
781}