tirea_protocol_ai_sdk_v6/
encoder.rs

1use super::UIStreamEvent;
2use serde_json::Value;
3use std::collections::HashSet;
4use tirea_contract::runtime::tool_call::ToolStatus;
5use tirea_contract::{AgentEvent, TerminationReason, Transcoder};
6
7pub(crate) const DATA_EVENT_STATE_SNAPSHOT: &str = "state-snapshot";
8pub(crate) const DATA_EVENT_STATE_DELTA: &str = "state-delta";
9pub(crate) const DATA_EVENT_MESSAGES_SNAPSHOT: &str = "messages-snapshot";
10pub(crate) const DATA_EVENT_ACTIVITY_SNAPSHOT: &str = "activity-snapshot";
11pub(crate) const DATA_EVENT_ACTIVITY_DELTA: &str = "activity-delta";
12pub(crate) const DATA_EVENT_INFERENCE_COMPLETE: &str = "inference-complete";
13pub(crate) const DATA_EVENT_REASONING_ENCRYPTED: &str = "reasoning-encrypted";
14const RUN_INFO_EVENT_NAME: &str = "run-info";
15
16/// Stateful encoder for AI SDK v6 UI Message Stream protocol.
17///
18/// Tracks text block lifecycle (open/close) across tool calls, ensuring
19/// `text-start` and `text-end` are always properly paired. This mirrors the
20/// pattern used by AG-UI encoders for AG-UI.
21///
22/// # Text lifecycle rules
23///
24/// - `TextDelta` with text closed → prepend `text-start`, open text
25/// - `ToolCallStart` with text open → prepend `text-end`, close text
26/// - `RunFinish` with text open → prepend `text-end` before `finish`
27/// - `Error` → terminal, no `text-end` needed
28#[derive(Debug)]
29pub struct AiSdkEncoder {
30    message_id: String,
31    text_open: bool,
32    text_counter: u32,
33    finished: bool,
34    /// Whether an external message ID has been consumed from a StepStart event.
35    message_id_set: bool,
36    /// Reasoning blocks that are currently open (for delta-style streaming).
37    open_reasoning_ids: HashSet<String>,
38}
39
40impl AiSdkEncoder {
41    /// Create a new encoder.
42    ///
43    /// The encoder is fully initialized when the first `RunStart` event
44    /// arrives, which sets the `message_id` from the run ID.
45    pub fn new() -> Self {
46        Self {
47            message_id: String::new(),
48            text_open: false,
49            text_counter: 0,
50            finished: false,
51            message_id_set: false,
52            open_reasoning_ids: HashSet::new(),
53        }
54    }
55
56    /// Current text block ID (e.g. `txt_0`, `txt_1`, ...).
57    fn text_id(&self) -> String {
58        format!("txt_{}", self.text_counter)
59    }
60
61    /// Emit `text-start` and mark text as open. Returns the new text ID.
62    fn open_text(&mut self) -> UIStreamEvent {
63        self.text_open = true;
64        UIStreamEvent::text_start(self.text_id())
65    }
66
67    /// Emit `text-end` for the current text block and mark text as closed.
68    /// Increments the counter so the next text block gets a fresh ID.
69    fn close_text(&mut self) -> UIStreamEvent {
70        let event = UIStreamEvent::text_end(self.text_id());
71        self.text_open = false;
72        self.text_counter += 1;
73        event
74    }
75
76    fn close_all_reasoning(&mut self) -> Vec<UIStreamEvent> {
77        let mut ids: Vec<String> = self.open_reasoning_ids.drain().collect();
78        ids.sort();
79        ids.into_iter().map(UIStreamEvent::reasoning_end).collect()
80    }
81
82    fn start_reasoning_if_needed(&mut self, id: &str, events: &mut Vec<UIStreamEvent>) {
83        if self.open_reasoning_ids.insert(id.to_string()) {
84            events.push(UIStreamEvent::reasoning_start(id));
85        }
86    }
87
88    fn close_reasoning_if_open(&mut self, id: &str, events: &mut Vec<UIStreamEvent>) {
89        if self.open_reasoning_ids.remove(id) {
90            events.push(UIStreamEvent::reasoning_end(id));
91        }
92    }
93
94    /// Get the message ID.
95    pub fn message_id(&self) -> &str {
96        &self.message_id
97    }
98
99    /// Convert an `AgentEvent` to UI stream events with proper text lifecycle.
100    pub fn on_agent_event(&mut self, ev: &AgentEvent) -> Vec<UIStreamEvent> {
101        if self.finished {
102            return Vec::new();
103        }
104
105        match ev {
106            AgentEvent::TextDelta { delta } => {
107                let mut events = Vec::new();
108                if !self.text_open {
109                    events.push(self.open_text());
110                }
111                events.push(UIStreamEvent::text_delta(self.text_id(), delta));
112                events
113            }
114            AgentEvent::ReasoningDelta { delta } => {
115                let reasoning_id = reasoning_id_for(&self.message_id, None);
116                let mut events = Vec::new();
117                self.start_reasoning_if_needed(&reasoning_id, &mut events);
118                events.push(UIStreamEvent::reasoning_delta(reasoning_id, delta));
119                events
120            }
121            AgentEvent::ReasoningEncryptedValue { encrypted_value } => {
122                vec![UIStreamEvent::data_with_options(
123                    DATA_EVENT_REASONING_ENCRYPTED,
124                    serde_json::json!({ "encryptedValue": encrypted_value }),
125                    Some(reasoning_id_for(&self.message_id, None)),
126                    Some(true),
127                )]
128            }
129
130            AgentEvent::ToolCallStart { id, name } => {
131                let mut events = Vec::new();
132                if self.text_open {
133                    events.push(self.close_text());
134                }
135                events.push(UIStreamEvent::tool_input_start(id, name));
136                events
137            }
138            AgentEvent::ToolCallDelta { id, args_delta } => {
139                vec![UIStreamEvent::tool_input_delta(id, args_delta)]
140            }
141            AgentEvent::ToolCallReady {
142                id,
143                name,
144                arguments,
145            } => {
146                let mut events = vec![UIStreamEvent::tool_input_available(
147                    id,
148                    name,
149                    arguments.clone(),
150                )];
151                if Self::is_permission_confirmation_tool(name) {
152                    events.push(UIStreamEvent::tool_approval_request(id.clone(), id.clone()));
153                }
154                events
155            }
156            AgentEvent::ToolCallDone { id, result, .. } => match result.status {
157                ToolStatus::Success | ToolStatus::Warning | ToolStatus::Pending => {
158                    vec![UIStreamEvent::tool_output_available(id, result.to_json())]
159                }
160                ToolStatus::Error => {
161                    let error_text = result
162                        .message
163                        .clone()
164                        .or_else(|| {
165                            result
166                                .data
167                                .get("error")
168                                .and_then(|v| v.get("message"))
169                                .and_then(Value::as_str)
170                                .map(str::to_string)
171                        })
172                        .unwrap_or_else(|| "tool output error".to_string());
173                    vec![UIStreamEvent::tool_output_error(id, error_text)]
174                }
175            },
176
177            AgentEvent::RunFinish { termination, .. } => {
178                self.finished = true;
179                let mut events = Vec::new();
180                if self.text_open {
181                    events.push(self.close_text());
182                }
183                events.extend(self.close_all_reasoning());
184                match termination {
185                    TerminationReason::Cancelled => {
186                        events.push(UIStreamEvent::abort("cancelled"));
187                    }
188                    TerminationReason::Error(ref msg) => {
189                        events.push(UIStreamEvent::error(msg));
190                        events.push(UIStreamEvent::finish_with_reason("error"));
191                    }
192                    _ => {
193                        let finish_reason = Self::map_termination(termination);
194                        events.push(UIStreamEvent::finish_with_reason(finish_reason));
195                    }
196                }
197                events
198            }
199
200            AgentEvent::Error { message, .. } => {
201                self.finished = true;
202                self.text_open = false;
203                let mut events = self.close_all_reasoning();
204                events.push(UIStreamEvent::error(message));
205                events
206            }
207
208            AgentEvent::StepStart { message_id } => {
209                if !self.message_id_set {
210                    self.message_id = message_id.clone();
211                    self.message_id_set = true;
212                }
213                vec![UIStreamEvent::start_step()]
214            }
215            AgentEvent::StepEnd => {
216                let mut events = Vec::new();
217                if self.text_open {
218                    events.push(self.close_text());
219                }
220                events.extend(self.close_all_reasoning());
221                events.push(UIStreamEvent::finish_step());
222                events
223            }
224            AgentEvent::RunStart { thread_id, .. } => {
225                self.message_id = tirea_contract::gen_message_id();
226                vec![
227                    UIStreamEvent::message_start(&self.message_id),
228                    UIStreamEvent::data(
229                        RUN_INFO_EVENT_NAME,
230                        serde_json::json!({
231                            "protocol": "ai-sdk-ui-message-stream",
232                            "protocolVersion": "v1",
233                            "aiSdkVersion": super::AI_SDK_VERSION,
234                            "threadId": thread_id,
235                        }),
236                    ),
237                ]
238            }
239            AgentEvent::InferenceComplete {
240                model,
241                usage,
242                duration_ms,
243            } => {
244                let payload = serde_json::json!({
245                    "model": model,
246                    "usage": usage,
247                    "duration_ms": duration_ms,
248                });
249                vec![UIStreamEvent::data(DATA_EVENT_INFERENCE_COMPLETE, payload)]
250            }
251
252            AgentEvent::StateSnapshot { snapshot } => {
253                vec![UIStreamEvent::data(
254                    DATA_EVENT_STATE_SNAPSHOT,
255                    snapshot.clone(),
256                )]
257            }
258            AgentEvent::StateDelta { delta } => {
259                vec![UIStreamEvent::data(
260                    DATA_EVENT_STATE_DELTA,
261                    serde_json::Value::Array(delta.clone()),
262                )]
263            }
264            AgentEvent::MessagesSnapshot { messages } => {
265                vec![UIStreamEvent::data(
266                    DATA_EVENT_MESSAGES_SNAPSHOT,
267                    serde_json::Value::Array(messages.clone()),
268                )]
269            }
270            AgentEvent::ActivitySnapshot {
271                message_id,
272                activity_type,
273                content,
274                replace,
275            } => {
276                let mut events = self.map_activity_snapshot(message_id, activity_type, content);
277                let payload = serde_json::json!({
278                    "messageId": message_id,
279                    "activityType": activity_type,
280                    "content": content,
281                    "replace": replace,
282                });
283                events.push(UIStreamEvent::data(DATA_EVENT_ACTIVITY_SNAPSHOT, payload));
284                events
285            }
286            AgentEvent::ActivityDelta {
287                message_id,
288                activity_type,
289                patch,
290            } => {
291                let mut events = self.map_activity_delta(message_id, activity_type, patch);
292                let payload = serde_json::json!({
293                    "messageId": message_id,
294                    "activityType": activity_type,
295                    "patch": patch,
296                });
297                events.push(UIStreamEvent::data(DATA_EVENT_ACTIVITY_DELTA, payload));
298                events
299            }
300            AgentEvent::ToolCallResumed { target_id, result } => {
301                self.map_interaction_resolved(target_id, result)
302            }
303        }
304    }
305
306    fn map_termination(reason: &TerminationReason) -> &'static str {
307        match reason {
308            TerminationReason::NaturalEnd
309            | TerminationReason::BehaviorRequested
310            | TerminationReason::Suspended => "stop",
311            TerminationReason::Cancelled => "other",
312            TerminationReason::Error(_) => "error",
313            TerminationReason::Stopped(stopped) => match stopped.code.as_str() {
314                "max_rounds_reached" | "timeout_reached" | "token_budget_exceeded" => "length",
315                "tool_called" => "tool-calls",
316                "content_matched" => "stop",
317                "consecutive_errors_exceeded" | "loop_detected" => "error",
318                _ => "other",
319            },
320        }
321    }
322
323    fn map_activity_snapshot(
324        &mut self,
325        message_id: &str,
326        activity_type: &str,
327        content: &Value,
328    ) -> Vec<UIStreamEvent> {
329        let activity_type = normalize_activity_type(activity_type);
330        match activity_type.as_str() {
331            "reasoning" => self.map_reasoning_snapshot(message_id, content),
332            "source-url" => map_source_url_snapshot(message_id, content)
333                .into_iter()
334                .collect(),
335            "source-document" => map_source_document_snapshot(message_id, content)
336                .into_iter()
337                .collect(),
338            "file" => map_file_snapshot(content).into_iter().collect(),
339            _ => Vec::new(),
340        }
341    }
342
343    fn map_activity_delta(
344        &mut self,
345        message_id: &str,
346        activity_type: &str,
347        patch: &[Value],
348    ) -> Vec<UIStreamEvent> {
349        let activity_type = normalize_activity_type(activity_type);
350        if activity_type != "reasoning" {
351            return Vec::new();
352        }
353
354        let mut events = Vec::new();
355        let reasoning_id = reasoning_id_for(message_id, None);
356        let mut has_delta = false;
357
358        for op in patch {
359            if !is_patch_write_operation(op) {
360                continue;
361            }
362            if let Some(delta) = extract_reasoning_text(op.get("value")) {
363                if !delta.is_empty() {
364                    self.start_reasoning_if_needed(&reasoning_id, &mut events);
365                    events.push(UIStreamEvent::reasoning_delta(&reasoning_id, delta));
366                    has_delta = true;
367                }
368            }
369            if is_done_marker(op.get("value")) {
370                self.close_reasoning_if_open(&reasoning_id, &mut events);
371            }
372        }
373
374        if !has_delta && patch.iter().any(|op| is_done_marker(op.get("value"))) {
375            self.close_reasoning_if_open(&reasoning_id, &mut events);
376        }
377
378        events
379    }
380
381    fn map_reasoning_snapshot(&mut self, message_id: &str, content: &Value) -> Vec<UIStreamEvent> {
382        let reasoning_id = reasoning_id_for(message_id, content.get("id").and_then(Value::as_str));
383        let mut events = Vec::new();
384
385        let text = extract_reasoning_text(Some(content)).unwrap_or_default();
386        let done = is_done_marker(Some(content));
387
388        if !text.is_empty() {
389            self.start_reasoning_if_needed(&reasoning_id, &mut events);
390            events.push(UIStreamEvent::reasoning_delta(&reasoning_id, text));
391        }
392
393        if done || (matches!(content, Value::String(_)) || content.get("text").is_some()) {
394            self.close_reasoning_if_open(&reasoning_id, &mut events);
395        }
396
397        events
398    }
399
400    fn map_interaction_resolved(&self, target_id: &str, result: &Value) -> Vec<UIStreamEvent> {
401        if let Some(err) = result.get("error").and_then(Value::as_str) {
402            return vec![UIStreamEvent::tool_output_error(target_id, err)];
403        }
404        if tirea_contract::SuspensionResponse::is_denied(result) {
405            return vec![UIStreamEvent::tool_output_denied(target_id)];
406        }
407        vec![UIStreamEvent::tool_output_available(
408            target_id,
409            result.clone(),
410        )]
411    }
412
413    fn is_permission_confirmation_tool(tool_name: &str) -> bool {
414        tool_name.eq_ignore_ascii_case("PermissionConfirm")
415    }
416}
417
418impl Default for AiSdkEncoder {
419    fn default() -> Self {
420        Self::new()
421    }
422}
423
424impl Transcoder for AiSdkEncoder {
425    type Input = AgentEvent;
426    type Output = UIStreamEvent;
427
428    fn transcode(&mut self, item: &AgentEvent) -> Vec<UIStreamEvent> {
429        self.on_agent_event(item)
430    }
431}
432
433fn normalize_activity_type(activity_type: &str) -> String {
434    activity_type.trim().to_ascii_lowercase().replace('_', "-")
435}
436
437fn reasoning_id_for(message_id: &str, explicit: Option<&str>) -> String {
438    explicit
439        .map(ToOwned::to_owned)
440        .unwrap_or_else(|| format!("reasoning_{message_id}"))
441}
442
443fn extract_reasoning_text(value: Option<&Value>) -> Option<String> {
444    match value? {
445        Value::String(text) => Some(text.clone()),
446        Value::Object(map) => map
447            .get("delta")
448            .and_then(Value::as_str)
449            .map(str::to_string)
450            .or_else(|| map.get("text").and_then(Value::as_str).map(str::to_string)),
451        _ => None,
452    }
453}
454
455fn is_done_marker(value: Option<&Value>) -> bool {
456    let Some(value) = value else {
457        return false;
458    };
459    match value {
460        Value::Object(map) => map.get("done").and_then(Value::as_bool).unwrap_or(false),
461        _ => false,
462    }
463}
464
465fn is_patch_write_operation(op: &Value) -> bool {
466    op.get("op")
467        .and_then(Value::as_str)
468        .is_some_and(|name| name == "add" || name == "replace")
469}
470
471fn map_source_url_snapshot(message_id: &str, content: &Value) -> Option<UIStreamEvent> {
472    let url = content.get("url")?.as_str()?;
473    let source_id = content
474        .get("sourceId")
475        .and_then(Value::as_str)
476        .or_else(|| content.get("id").and_then(Value::as_str))
477        .unwrap_or(message_id);
478    let title = content
479        .get("title")
480        .and_then(Value::as_str)
481        .map(str::to_string);
482    let provider_metadata = content.get("providerMetadata").cloned();
483    Some(UIStreamEvent::SourceUrl {
484        source_id: source_id.to_string(),
485        url: url.to_string(),
486        title,
487        provider_metadata,
488    })
489}
490
491fn map_source_document_snapshot(message_id: &str, content: &Value) -> Option<UIStreamEvent> {
492    let media_type = content
493        .get("mediaType")
494        .or_else(|| content.get("media_type"))
495        .and_then(Value::as_str)?;
496    let title = content.get("title")?.as_str()?;
497    let source_id = content
498        .get("sourceId")
499        .and_then(Value::as_str)
500        .or_else(|| content.get("id").and_then(Value::as_str))
501        .unwrap_or(message_id);
502    let filename = content
503        .get("filename")
504        .and_then(Value::as_str)
505        .map(str::to_string);
506    let provider_metadata = content.get("providerMetadata").cloned();
507    Some(UIStreamEvent::SourceDocument {
508        source_id: source_id.to_string(),
509        media_type: media_type.to_string(),
510        title: title.to_string(),
511        filename,
512        provider_metadata,
513    })
514}
515
516fn map_file_snapshot(content: &Value) -> Option<UIStreamEvent> {
517    let url = content.get("url")?.as_str()?;
518    let media_type = content
519        .get("mediaType")
520        .or_else(|| content.get("media_type"))
521        .and_then(Value::as_str)?;
522    let provider_metadata = content.get("providerMetadata").cloned();
523    Some(UIStreamEvent::File {
524        url: url.to_string(),
525        media_type: media_type.to_string(),
526        provider_metadata,
527    })
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use serde_json::json;
534    use tirea_contract::TokenUsage;
535
536    #[test]
537    fn inference_complete_emits_data_event() {
538        let mut enc = AiSdkEncoder::new();
539        let ev = AgentEvent::InferenceComplete {
540            model: "gpt-4o".into(),
541            usage: Some(TokenUsage {
542                prompt_tokens: Some(100),
543                completion_tokens: Some(50),
544                ..Default::default()
545            }),
546            duration_ms: 1234,
547        };
548        let events = enc.on_agent_event(&ev);
549        assert_eq!(events.len(), 1);
550        match &events[0] {
551            UIStreamEvent::Data {
552                data_type, data, ..
553            } => {
554                assert_eq!(data_type, &format!("data-{DATA_EVENT_INFERENCE_COMPLETE}"));
555                assert_eq!(data["model"], "gpt-4o");
556                assert_eq!(data["duration_ms"], 1234);
557                assert!(data["usage"].is_object(), "usage: {:?}", data["usage"]);
558            }
559            other => panic!("expected Data event, got: {:?}", other),
560        }
561    }
562
563    #[test]
564    fn inference_complete_without_usage() {
565        let mut enc = AiSdkEncoder::new();
566        let ev = AgentEvent::InferenceComplete {
567            model: "gpt-4o-mini".into(),
568            usage: None,
569            duration_ms: 500,
570        };
571        let events = enc.on_agent_event(&ev);
572        assert_eq!(events.len(), 1);
573        match &events[0] {
574            UIStreamEvent::Data {
575                data_type, data, ..
576            } => {
577                assert_eq!(data_type, &format!("data-{DATA_EVENT_INFERENCE_COMPLETE}"));
578                assert_eq!(data["model"], "gpt-4o-mini");
579                assert!(data["usage"].is_null());
580            }
581            other => panic!("expected Data event, got: {:?}", other),
582        }
583    }
584
585    #[test]
586    fn reasoning_agent_event_emits_reasoning_stream_events() {
587        let mut enc = AiSdkEncoder::new();
588        let events = enc.on_agent_event(&AgentEvent::ReasoningDelta {
589            delta: "thinking".to_string(),
590        });
591
592        assert!(events
593            .iter()
594            .any(|ev| matches!(ev, UIStreamEvent::ReasoningStart { .. })));
595        assert!(events.iter().any(
596            |ev| matches!(ev, UIStreamEvent::ReasoningDelta { delta, .. } if delta == "thinking")
597        ));
598    }
599
600    #[test]
601    fn run_finish_closes_reasoning_started_from_reasoning_event() {
602        let mut enc = AiSdkEncoder::new();
603        let open_events = enc.on_agent_event(&AgentEvent::ReasoningDelta {
604            delta: "step-1".to_string(),
605        });
606        assert!(open_events
607            .iter()
608            .any(|ev| matches!(ev, UIStreamEvent::ReasoningStart { .. })));
609
610        let finish_events = enc.on_agent_event(&AgentEvent::RunFinish {
611            thread_id: "thread_1".to_string(),
612            run_id: "run_reasoning_finish".to_string(),
613            result: None,
614            termination: TerminationReason::NaturalEnd,
615        });
616        assert!(
617            finish_events
618                .iter()
619                .any(|ev| matches!(ev, UIStreamEvent::ReasoningEnd { .. })),
620            "run finish should close open reasoning block"
621        );
622    }
623
624    #[test]
625    fn reasoning_encrypted_event_emits_transient_data_event() {
626        let mut enc = AiSdkEncoder::new();
627        let events = enc.on_agent_event(&AgentEvent::ReasoningEncryptedValue {
628            encrypted_value: "opaque-token".to_string(),
629        });
630
631        assert_eq!(events.len(), 1);
632        assert!(matches!(
633            &events[0],
634            UIStreamEvent::Data {
635                data_type,
636                data,
637                transient: Some(true),
638                ..
639            } if data_type == "data-reasoning-encrypted"
640                && data["encryptedValue"] == "opaque-token"
641        ));
642    }
643
644    #[test]
645    fn tool_call_done_with_error_status_emits_tool_output_error() {
646        let mut enc = AiSdkEncoder::new();
647        let events = enc.on_agent_event(&AgentEvent::ToolCallDone {
648            id: "call_error_1".to_string(),
649            result: tirea_contract::runtime::tool_call::ToolResult::error(
650                "search",
651                "tool backend failed",
652            ),
653            patch: None,
654            message_id: "msg_tool_error_1".to_string(),
655        });
656
657        assert!(events.iter().any(|ev| matches!(
658            ev,
659            UIStreamEvent::ToolOutputError {
660                tool_call_id,
661                error_text,
662                ..
663            } if tool_call_id == "call_error_1" && error_text == "tool backend failed"
664        )));
665    }
666
667    #[test]
668    fn tool_call_lifecycle_emits_streaming_input_ready_and_output_events() {
669        let mut enc = AiSdkEncoder::new();
670
671        let start = enc.on_agent_event(&AgentEvent::ToolCallStart {
672            id: "call_1".to_string(),
673            name: "search".to_string(),
674        });
675        assert!(
676            start.iter().any(
677                |ev| matches!(ev, UIStreamEvent::ToolInputStart { tool_call_id, tool_name, .. }
678                    if tool_call_id == "call_1" && tool_name == "search")
679            ),
680            "tool start should map to tool-input-start"
681        );
682
683        let delta = enc.on_agent_event(&AgentEvent::ToolCallDelta {
684            id: "call_1".to_string(),
685            args_delta: "{\"q\":\"ru".to_string(),
686        });
687        assert!(
688            delta.iter().any(
689                |ev| matches!(ev, UIStreamEvent::ToolInputDelta { tool_call_id, input_text_delta }
690                    if tool_call_id == "call_1" && input_text_delta == "{\"q\":\"ru")
691            ),
692            "tool delta should map to tool-input-delta"
693        );
694
695        let ready = enc.on_agent_event(&AgentEvent::ToolCallReady {
696            id: "call_1".to_string(),
697            name: "search".to_string(),
698            arguments: json!({ "q": "rust" }),
699        });
700        assert!(
701            ready.iter().any(
702                |ev| matches!(ev, UIStreamEvent::ToolInputAvailable { tool_call_id, tool_name, input, .. }
703                    if tool_call_id == "call_1" && tool_name == "search" && input["q"] == "rust")
704            ),
705            "tool ready should map to tool-input-available"
706        );
707
708        let done = enc.on_agent_event(&AgentEvent::ToolCallDone {
709            id: "call_1".to_string(),
710            result: tirea_contract::runtime::tool_call::ToolResult::success(
711                "search",
712                json!({ "items": [1, 2] }),
713            ),
714            patch: None,
715            message_id: "msg_tool_1".to_string(),
716        });
717        assert!(
718            done.iter().any(
719                |ev| matches!(ev, UIStreamEvent::ToolOutputAvailable { tool_call_id, output, .. }
720                    if tool_call_id == "call_1" && output["data"]["items"][0] == 1)
721            ),
722            "tool done should map to tool-output-available"
723        );
724    }
725
726    #[test]
727    fn permission_tool_ready_emits_tool_approval_request() {
728        let mut enc = AiSdkEncoder::new();
729        let ready = enc.on_agent_event(&AgentEvent::ToolCallReady {
730            id: "fc_perm_1".to_string(),
731            name: "PermissionConfirm".to_string(),
732            arguments: json!({ "tool_name": "echo", "tool_args": { "message": "x" } }),
733        });
734        assert!(
735            ready.iter().any(|ev| matches!(
736                ev,
737                UIStreamEvent::ToolApprovalRequest { approval_id, tool_call_id }
738                if approval_id == "fc_perm_1" && tool_call_id == "fc_perm_1"
739            )),
740            "permission tool should emit tool-approval-request"
741        );
742    }
743
744    #[test]
745    fn interaction_resolved_denied_emits_tool_output_denied() {
746        let mut enc = AiSdkEncoder::new();
747        let events = enc.on_agent_event(&AgentEvent::ToolCallResumed {
748            target_id: "fc_perm_1".to_string(),
749            result: json!({ "approved": false, "reason": "nope" }),
750        });
751        assert!(
752            events.iter().any(|ev| matches!(
753                ev,
754                UIStreamEvent::ToolOutputDenied { tool_call_id }
755                if tool_call_id == "fc_perm_1"
756            )),
757            "denied interaction should emit tool-output-denied"
758        );
759    }
760
761    #[test]
762    fn interaction_resolved_error_emits_tool_output_error() {
763        let mut enc = AiSdkEncoder::new();
764        let events = enc.on_agent_event(&AgentEvent::ToolCallResumed {
765            target_id: "ask_call_2".to_string(),
766            result: json!({ "approved": false, "error": "frontend validation failed" }),
767        });
768        assert!(
769            events.iter().any(|ev| matches!(
770                ev,
771                UIStreamEvent::ToolOutputError { tool_call_id, error_text, .. }
772                if tool_call_id == "ask_call_2" && error_text == "frontend validation failed"
773            )),
774            "errored interaction should emit tool-output-error"
775        );
776    }
777
778    #[test]
779    fn interaction_resolved_output_payload_emits_tool_output_available() {
780        let mut enc = AiSdkEncoder::new();
781        let events = enc.on_agent_event(&AgentEvent::ToolCallResumed {
782            target_id: "ask_call_1".to_string(),
783            result: json!({ "message": "blue" }),
784        });
785        assert!(
786            events.iter().any(|ev| matches!(
787                ev,
788                UIStreamEvent::ToolOutputAvailable { tool_call_id, output, .. }
789                if tool_call_id == "ask_call_1" && output["message"] == "blue"
790            )),
791            "ask interaction resolution should emit tool-output-available"
792        );
793    }
794
795    #[test]
796    fn step_events_emit_start_step_and_finish_step() {
797        let mut enc = AiSdkEncoder::new();
798
799        let step_start = enc.on_agent_event(&AgentEvent::StepStart {
800            message_id: "msg_external".to_string(),
801        });
802        assert!(
803            step_start
804                .iter()
805                .any(|ev| matches!(ev, UIStreamEvent::StartStep)),
806            "step start should map to start-step"
807        );
808
809        let step_end = enc.on_agent_event(&AgentEvent::StepEnd);
810        assert!(
811            step_end
812                .iter()
813                .any(|ev| matches!(ev, UIStreamEvent::FinishStep)),
814            "step end should map to finish-step"
815        );
816    }
817
818    #[test]
819    fn cancelled_run_emits_abort_and_closes_open_blocks() {
820        let mut enc = AiSdkEncoder::new();
821
822        let text_events = enc.on_agent_event(&AgentEvent::TextDelta {
823            delta: "hello".to_string(),
824        });
825        assert_eq!(text_events.len(), 2);
826
827        let reasoning_events = enc.on_agent_event(&AgentEvent::ActivityDelta {
828            message_id: "m1".to_string(),
829            activity_type: "reasoning".to_string(),
830            patch: vec![json!({
831                "op": "add",
832                "path": "/delta",
833                "value": {"delta": "thinking"}
834            })],
835        });
836        assert!(
837            reasoning_events
838                .iter()
839                .any(|ev| matches!(ev, UIStreamEvent::ReasoningStart { .. })),
840            "reasoning block should start from activity delta"
841        );
842
843        let finish_events = enc.on_agent_event(&AgentEvent::RunFinish {
844            thread_id: "thread_1".to_string(),
845            run_id: "run_cancelled".to_string(),
846            result: None,
847            termination: TerminationReason::Cancelled,
848        });
849        assert!(
850            finish_events
851                .iter()
852                .any(|ev| matches!(ev, UIStreamEvent::TextEnd { .. })),
853            "cancel should close open text block"
854        );
855        assert!(
856            finish_events
857                .iter()
858                .any(|ev| matches!(ev, UIStreamEvent::ReasoningEnd { .. })),
859            "cancel should close open reasoning block"
860        );
861        assert!(
862            finish_events
863                .iter()
864                .any(|ev| matches!(ev, UIStreamEvent::Abort { .. })),
865            "cancelled run should emit abort event"
866        );
867    }
868
869    #[test]
870    fn activity_snapshot_reasoning_emits_reasoning_and_data_events() {
871        let mut enc = AiSdkEncoder::new();
872        let events = enc.on_agent_event(&AgentEvent::ActivitySnapshot {
873            message_id: "m2".to_string(),
874            activity_type: "reasoning".to_string(),
875            content: json!({"text":"let me think"}),
876            replace: Some(true),
877        });
878
879        assert!(events
880            .iter()
881            .any(|ev| matches!(ev, UIStreamEvent::ReasoningStart { .. })));
882        assert!(
883            events
884                .iter()
885                .any(|ev| matches!(ev, UIStreamEvent::ReasoningDelta { delta, .. } if delta == "let me think"))
886        );
887        assert!(events
888            .iter()
889            .any(|ev| matches!(ev, UIStreamEvent::ReasoningEnd { .. })));
890        assert!(
891            events.iter().any(
892                |ev| matches!(ev, UIStreamEvent::Data { data_type, .. } if data_type == "data-activity-snapshot")
893            ),
894            "activity snapshot data event should remain for backward compatibility"
895        );
896    }
897
898    #[test]
899    fn activity_snapshot_tool_call_progress_emits_data_event_example() {
900        let mut enc = AiSdkEncoder::new();
901        let events = enc.on_agent_event(&AgentEvent::ActivitySnapshot {
902            message_id: "tool_call:call_1".to_string(),
903            activity_type: "tool-call-progress".to_string(),
904            content: json!({
905                "type": "tool-call-progress",
906                "schema": "tool-call-progress.v1",
907                "node_id": "tool_call:call_1",
908                "parent_call_id": "call_parent_1",
909                "parent_node_id": "tool_call:call_parent_1",
910                "call_id": "call_1",
911                "tool_name": "mcp.search",
912                "status": "running",
913                "progress": 0.4,
914                "total": 10,
915                "message": "searching...",
916                "run_id": "run_1"
917            }),
918            replace: Some(true),
919        });
920
921        assert!(events.iter().any(|event| {
922            matches!(
923                event,
924                UIStreamEvent::Data { data_type, data, .. }
925                    if data_type == "data-activity-snapshot"
926                        && data["activityType"] == json!("tool-call-progress")
927                        && data["content"]["schema"] == json!("tool-call-progress.v1")
928                        && data["content"]["parent_call_id"] == json!("call_parent_1")
929                        && data["content"]["progress"] == json!(0.4)
930            )
931        }));
932    }
933
934    #[test]
935    fn activity_snapshot_source_url_document_and_file_emit_native_events() {
936        let mut enc = AiSdkEncoder::new();
937
938        let url_events = enc.on_agent_event(&AgentEvent::ActivitySnapshot {
939            message_id: "src_1".to_string(),
940            activity_type: "source_url".to_string(),
941            content: json!({
942                "url": "https://example.com",
943                "title": "Example"
944            }),
945            replace: Some(true),
946        });
947        assert!(url_events.iter().any(
948            |ev| matches!(ev, UIStreamEvent::SourceUrl { url, .. } if url == "https://example.com")
949        ));
950
951        let doc_events = enc.on_agent_event(&AgentEvent::ActivitySnapshot {
952            message_id: "src_2".to_string(),
953            activity_type: "source-document".to_string(),
954            content: json!({
955                "sourceId": "doc_1",
956                "mediaType": "application/pdf",
957                "title": "Doc",
958                "filename": "doc.pdf"
959            }),
960            replace: Some(true),
961        });
962        assert!(doc_events.iter().any(|ev| matches!(
963            ev,
964            UIStreamEvent::SourceDocument {
965                source_id,
966                media_type,
967                title,
968                filename,
969                ..
970            } if source_id == "doc_1"
971                && media_type == "application/pdf"
972                && title == "Doc"
973                && filename.as_deref() == Some("doc.pdf")
974        )));
975
976        let file_events = enc.on_agent_event(&AgentEvent::ActivitySnapshot {
977            message_id: "src_3".to_string(),
978            activity_type: "file".to_string(),
979            content: json!({
980                "url": "https://example.com/a.png",
981                "mediaType": "image/png"
982            }),
983            replace: Some(true),
984        });
985        assert!(
986            file_events
987                .iter()
988                .any(|ev| matches!(ev, UIStreamEvent::File { url, media_type, .. } if url == "https://example.com/a.png" && media_type == "image/png"))
989        );
990    }
991
992    #[test]
993    fn activity_delta_reasoning_done_closes_reasoning_block() {
994        let mut enc = AiSdkEncoder::new();
995
996        let first = enc.on_agent_event(&AgentEvent::ActivityDelta {
997            message_id: "m3".to_string(),
998            activity_type: "reasoning".to_string(),
999            patch: vec![json!({
1000                "op": "add",
1001                "path": "/delta",
1002                "value": {"delta":"step-1"}
1003            })],
1004        });
1005        assert!(first
1006            .iter()
1007            .any(|ev| matches!(ev, UIStreamEvent::ReasoningStart { .. })));
1008        assert!(first.iter().any(
1009            |ev| matches!(ev, UIStreamEvent::ReasoningDelta { delta, .. } if delta == "step-1")
1010        ));
1011
1012        let second = enc.on_agent_event(&AgentEvent::ActivityDelta {
1013            message_id: "m3".to_string(),
1014            activity_type: "reasoning".to_string(),
1015            patch: vec![json!({
1016                "op": "replace",
1017                "path": "/status",
1018                "value": {"done": true}
1019            })],
1020        });
1021        assert!(
1022            second
1023                .iter()
1024                .any(|ev| matches!(ev, UIStreamEvent::ReasoningEnd { .. })),
1025            "done marker should close reasoning block"
1026        );
1027    }
1028}