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#[derive(Debug)]
29pub struct AiSdkEncoder {
30 message_id: String,
31 text_open: bool,
32 text_counter: u32,
33 finished: bool,
34 message_id_set: bool,
36 open_reasoning_ids: HashSet<String>,
38}
39
40impl AiSdkEncoder {
41 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 fn text_id(&self) -> String {
58 format!("txt_{}", self.text_counter)
59 }
60
61 fn open_text(&mut self) -> UIStreamEvent {
63 self.text_open = true;
64 UIStreamEvent::text_start(self.text_id())
65 }
66
67 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 pub fn message_id(&self) -> &str {
96 &self.message_id
97 }
98
99 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}