tirea_protocol_ai_sdk_v6/
input_adapter.rs

1use serde::Deserialize;
2use serde_json::Value;
3use std::collections::HashMap;
4use tirea_contract::io::decision_translation::suspension_response_to_decision;
5use tirea_contract::{Message, RunOrigin, RunRequest, SuspensionResponse, ToolCallDecision};
6
7use crate::message::{ToolState, ToolUIPart};
8
9#[derive(Debug, Clone, Deserialize)]
10#[serde(try_from = "AiSdkV6MessagesRunRequest")]
11pub struct AiSdkV6RunRequest {
12    pub thread_id: String,
13    pub input: String,
14    pub parent_thread_id: Option<String>,
15    pub trigger: Option<AiSdkTrigger>,
16    pub message_id: Option<String>,
17    interaction_responses: Vec<SuspensionResponse>,
18}
19
20#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
21#[serde(rename_all = "kebab-case")]
22pub enum AiSdkTrigger {
23    SubmitMessage,
24    RegenerateMessage,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28struct AiSdkV6MessagesRunRequest {
29    #[serde(default)]
30    id: Option<String>,
31    // Legacy fields are rejected for AI SDK v6 UI transport.
32    #[serde(rename = "sessionId", default)]
33    legacy_session_id: Option<String>,
34    #[serde(rename = "input", default)]
35    legacy_input: Option<String>,
36    #[serde(default)]
37    messages: Vec<Value>,
38    #[serde(rename = "parentThreadId", alias = "parent_thread_id", default)]
39    parent_thread_id: Option<String>,
40    #[serde(default)]
41    trigger: Option<AiSdkTrigger>,
42    #[serde(default)]
43    #[serde(rename = "messageId")]
44    message_id: Option<String>,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48struct ToolApprovalResponsePart {
49    #[serde(rename = "approvalId")]
50    approval_id: String,
51    #[serde(default)]
52    approved: Option<bool>,
53    #[serde(default)]
54    reason: Option<String>,
55    #[serde(default)]
56    remember: Option<bool>,
57}
58
59impl TryFrom<AiSdkV6MessagesRunRequest> for AiSdkV6RunRequest {
60    type Error = String;
61
62    fn try_from(req: AiSdkV6MessagesRunRequest) -> Result<Self, Self::Error> {
63        if req.legacy_session_id.is_some() || req.legacy_input.is_some() {
64            return Err(
65                "legacy AI SDK payload shape is no longer supported; use id/messages".to_string(),
66            );
67        }
68
69        let thread_id = req.id.unwrap_or_default();
70        let input = extract_last_user_text(&req.messages).unwrap_or_default();
71        let interaction_responses = extract_interaction_responses(&req.messages);
72        Ok(Self {
73            thread_id,
74            input,
75            parent_thread_id: req.parent_thread_id,
76            trigger: req.trigger,
77            message_id: req.message_id,
78            interaction_responses,
79        })
80    }
81}
82
83impl AiSdkV6RunRequest {
84    /// Build a request from explicit thread/input values (non-UI transport path).
85    pub fn from_thread_input(thread_id: impl Into<String>, input: impl Into<String>) -> Self {
86        Self {
87            thread_id: thread_id.into(),
88            input: input.into(),
89            parent_thread_id: None,
90            trigger: Some(AiSdkTrigger::SubmitMessage),
91            message_id: None,
92            interaction_responses: Vec::new(),
93        }
94    }
95
96    /// Validate the request before processing.
97    ///
98    /// Checks that:
99    /// - `thread_id` is non-empty
100    /// - `messageId` is present and non-empty for `regenerate-message`
101    /// - At least one of user input, suspension decisions, or regenerate trigger is present
102    pub fn validate(&self) -> Result<(), String> {
103        if self.thread_id.trim().is_empty() {
104            return Err("id cannot be empty".into());
105        }
106        if self.trigger == Some(AiSdkTrigger::RegenerateMessage) {
107            match self.message_id.as_deref() {
108                None => {
109                    return Err("messageId is required for regenerate-message".into());
110                }
111                Some(id) if id.trim().is_empty() => {
112                    return Err("messageId cannot be empty for regenerate-message".into());
113                }
114                _ => {}
115            }
116        }
117        let is_regenerate = self.trigger == Some(AiSdkTrigger::RegenerateMessage);
118        if !self.has_user_input() && !self.has_suspension_decisions() && !is_regenerate {
119            return Err("request must include user input or suspension decisions".into());
120        }
121        Ok(())
122    }
123
124    /// Whether the incoming request includes a non-empty user input message.
125    pub fn has_user_input(&self) -> bool {
126        !self.input.trim().is_empty()
127    }
128
129    /// Whether the incoming request includes any interaction responses.
130    pub fn has_interaction_responses(&self) -> bool {
131        !self.interaction_responses.is_empty()
132    }
133
134    /// Whether the incoming request includes any suspension decisions.
135    pub fn has_suspension_decisions(&self) -> bool {
136        !self.suspension_decisions().is_empty()
137    }
138
139    /// Suspension responses extracted from incoming UI messages.
140    pub fn interaction_responses(&self) -> Vec<SuspensionResponse> {
141        self.interaction_responses.clone()
142    }
143
144    /// Suspension decisions extracted from incoming UI messages.
145    pub fn suspension_decisions(&self) -> Vec<ToolCallDecision> {
146        self.interaction_responses()
147            .into_iter()
148            .map(suspension_response_to_decision)
149            .collect()
150    }
151
152    /// Convert this AI SDK request to the internal runtime request.
153    ///
154    /// Mapping rules:
155    /// - `thread_id` is treated as optional when blank/whitespace.
156    /// - `run_id` is always server-assigned (not client-supplied).
157    /// - Only extracted user input text is appended as runtime user message.
158    /// - `state`, `parent_run_id`, and `resource_id` are not supplied by AI SDK v6 input.
159    pub fn into_runtime_run_request(self, agent_id: String) -> RunRequest {
160        let initial_decisions = self.suspension_decisions();
161        let mut messages = Vec::new();
162        if self.has_user_input() {
163            messages.push(Message::user(self.input));
164        }
165        RunRequest {
166            agent_id,
167            thread_id: if self.thread_id.trim().is_empty() {
168                None
169            } else {
170                Some(self.thread_id)
171            },
172            run_id: None,
173            parent_run_id: None,
174            parent_thread_id: self.parent_thread_id,
175            resource_id: None,
176            origin: RunOrigin::AiSdk,
177            state: None,
178            messages,
179            initial_decisions,
180            source_mailbox_entry_id: None,
181        }
182    }
183}
184
185fn extract_last_user_text(messages: &[Value]) -> Option<String> {
186    for message in messages.iter().rev() {
187        let Some(role) = message_role(message) else {
188            continue;
189        };
190        if !role.eq_ignore_ascii_case("user") {
191            continue;
192        }
193
194        if let Some(content) = message_content_string(message) {
195            return Some(content.to_string());
196        }
197
198        let text = extract_text_from_parts(&message_parts(message));
199        if !text.is_empty() {
200            return Some(text);
201        }
202    }
203
204    None
205}
206
207fn extract_interaction_responses(messages: &[Value]) -> Vec<SuspensionResponse> {
208    let mut latest_by_id: HashMap<String, (usize, Value)> = HashMap::new();
209    let mut ordinal = 0usize;
210
211    for message in messages {
212        let Some(role) = message_role(message) else {
213            continue;
214        };
215        if !role.eq_ignore_ascii_case("assistant") {
216            continue;
217        }
218
219        for part in message_parts(message) {
220            if let Some((target_id, result)) = parse_interaction_response_part(&part) {
221                latest_by_id.insert(target_id, (ordinal, result));
222                ordinal += 1;
223            }
224        }
225    }
226
227    let mut responses: Vec<(usize, SuspensionResponse)> = latest_by_id
228        .into_iter()
229        .map(|(target_id, (idx, result))| (idx, SuspensionResponse::new(target_id, result)))
230        .collect();
231    responses.sort_by_key(|(idx, _)| *idx);
232    responses
233        .into_iter()
234        .map(|(_, response)| response)
235        .collect()
236}
237
238fn message_role(message: &Value) -> Option<&str> {
239    message.get("role").and_then(Value::as_str)
240}
241
242fn message_content_string(message: &Value) -> Option<&str> {
243    message.get("content").and_then(Value::as_str)
244}
245
246fn message_parts(message: &Value) -> Vec<Value> {
247    if let Some(parts) = message.get("parts").and_then(Value::as_array) {
248        return parts.clone();
249    }
250    if let Some(parts) = message.get("content").and_then(Value::as_array) {
251        return parts.clone();
252    }
253    Vec::new()
254}
255
256fn parse_interaction_response_part(part: &Value) -> Option<(String, Value)> {
257    if part.get("type").and_then(Value::as_str) == Some("tool-approval-response") {
258        return parse_tool_approval_response_part(part);
259    }
260    if part.get("state").and_then(Value::as_str) == Some("approval-responded") {
261        return parse_approval_responded_part(part);
262    }
263
264    let tool_part = parse_tool_ui_part(part)?;
265    let tool_call_id = tool_part.tool_call_id.clone();
266
267    match tool_part.state {
268        ToolState::ApprovalResponded => None,
269        ToolState::OutputAvailable => Some((tool_call_id, tool_part.output.unwrap_or(Value::Null))),
270        ToolState::OutputDenied => Some((tool_call_id, Value::Bool(false))),
271        ToolState::OutputError => {
272            let error = tool_part
273                .error_text
274                .as_deref()
275                .filter(|value| !value.is_empty())
276                .unwrap_or("tool output error");
277            Some((
278                tool_call_id,
279                serde_json::json!({
280                    "approved": false,
281                    "error": error,
282                }),
283            ))
284        }
285        _ => None,
286    }
287}
288
289fn parse_tool_approval_response_part(part: &Value) -> Option<(String, Value)> {
290    let payload: ToolApprovalResponsePart = serde_json::from_value(part.clone()).ok()?;
291    Some((
292        payload.approval_id,
293        approval_response_value(
294            payload.approved.unwrap_or(false),
295            payload.reason,
296            payload.remember,
297        ),
298    ))
299}
300
301fn parse_approval_responded_part(part: &Value) -> Option<(String, Value)> {
302    let tool_call_id = part
303        .get("toolCallId")
304        .or_else(|| part.get("tool_call_id"))
305        .and_then(Value::as_str)
306        .map(str::to_string);
307    let approval = part.get("approval");
308    let target_id = approval
309        .and_then(|v| v.get("id"))
310        .and_then(Value::as_str)
311        .map(str::to_string)
312        .or(tool_call_id)?;
313    let approved = approval
314        .and_then(|v| v.get("approved"))
315        .and_then(Value::as_bool)
316        .unwrap_or(false);
317    let reason = approval
318        .and_then(|v| v.get("reason"))
319        .and_then(Value::as_str)
320        .map(str::to_string);
321    Some((
322        target_id,
323        approval_response_value(
324            approved,
325            reason,
326            approval
327                .and_then(|v| v.get("remember"))
328                .and_then(Value::as_bool),
329        ),
330    ))
331}
332
333fn parse_tool_ui_part(part: &Value) -> Option<ToolUIPart> {
334    let mut normalized = part.clone();
335    let map = normalized.as_object_mut()?;
336    if !map.contains_key("toolCallId") {
337        if let Some(tool_call_id) = map.get("tool_call_id").cloned() {
338            map.insert("toolCallId".to_string(), tool_call_id);
339        }
340    }
341    serde_json::from_value(normalized).ok()
342}
343
344fn approval_response_value(
345    approved: bool,
346    reason: Option<String>,
347    remember: Option<bool>,
348) -> Value {
349    let mut result = serde_json::Map::new();
350    result.insert("approved".to_string(), Value::Bool(approved));
351    if let Some(reason) = reason {
352        result.insert("reason".to_string(), Value::String(reason));
353    }
354    if let Some(remember) = remember {
355        result.insert("remember".to_string(), Value::Bool(remember));
356    }
357    Value::Object(result)
358}
359
360fn extract_text_from_parts(parts: &[Value]) -> String {
361    let mut text = String::new();
362    for part in parts {
363        let Some(part_type) = part.get("type").and_then(Value::as_str) else {
364            continue;
365        };
366        if part_type != "text" {
367            continue;
368        }
369        if let Some(segment) = part.get("text").and_then(Value::as_str) {
370            text.push_str(segment);
371        }
372    }
373    text
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use serde_json::json;
380
381    #[test]
382    fn rejects_legacy_request_shape() {
383        let err = serde_json::from_value::<AiSdkV6RunRequest>(json!({
384            "sessionId": "thread-1",
385            "input": "hello",
386            "runId": "run-1"
387        }))
388        .expect_err("legacy payload must be rejected");
389        assert!(
390            err.to_string()
391                .contains("legacy AI SDK payload shape is no longer supported"),
392            "unexpected error: {err}"
393        );
394    }
395
396    #[test]
397    fn deserializes_messages_request_shape_using_last_user_text() {
398        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
399            "id": "thread-from-id",
400            "trigger": "submit-message",
401            "messageId": "msg_user_2",
402            "parentThreadId": "parent-thread-1",
403            "messages": [
404                { "id": "msg_user_1", "role": "user", "parts": [{ "type": "text", "text": "first" }] },
405                { "role": "assistant", "parts": [{ "type": "text", "text": "ignored" }] },
406                { "id": "msg_user_2", "role": "user", "parts": [{ "type": "text", "text": "final" }, { "type": "file", "url": "u" }] }
407            ],
408        }))
409        .expect("messages payload should deserialize");
410
411        assert_eq!(req.thread_id, "thread-from-id");
412        assert_eq!(req.input, "final");
413        assert_eq!(req.parent_thread_id.as_deref(), Some("parent-thread-1"));
414        assert_eq!(req.trigger, Some(AiSdkTrigger::SubmitMessage));
415        assert_eq!(req.message_id.as_deref(), Some("msg_user_2"));
416    }
417
418    #[test]
419    fn into_runtime_run_request_forwards_parent_thread_id() {
420        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
421            "id": "thread-forward-parent",
422            "parentThreadId": "p-thread",
423            "messages": [{ "role": "user", "content": "hello" }]
424        }))
425        .expect("messages payload should deserialize");
426
427        let run_request = req.into_runtime_run_request("agent".to_string());
428        assert_eq!(run_request.parent_thread_id.as_deref(), Some("p-thread"));
429    }
430
431    #[test]
432    fn id_is_used_as_thread_id_in_messages_shape() {
433        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
434            "id": "thread-id",
435            "messages": [{ "role": "user", "content": "hello" }]
436        }))
437        .expect("messages payload should deserialize");
438
439        assert_eq!(req.thread_id, "thread-id");
440        assert_eq!(req.input, "hello");
441    }
442
443    #[test]
444    fn missing_user_text_in_messages_shape_defaults_to_empty_input() {
445        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
446            "id": "thread-1",
447            "messages": [{ "role": "assistant", "content": "no-user" }]
448        }))
449        .expect("messages payload should deserialize");
450
451        assert_eq!(req.thread_id, "thread-1");
452        assert_eq!(req.input, "");
453    }
454
455    #[test]
456    fn extracts_approval_responded_parts_as_interaction_responses() {
457        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
458            "id": "t1",
459            "messages": [
460                {
461                    "role": "assistant",
462                    "parts": [{
463                        "type": "tool-echo",
464                        "toolCallId": "call_echo_1",
465                        "state": "approval-responded",
466                        "approval": {
467                            "id": "fc_perm_1",
468                            "approved": true,
469                            "reason": "looks safe"
470                        }
471                    }]
472                }
473            ]
474        }))
475        .expect("messages payload should deserialize");
476
477        let responses = req.interaction_responses();
478        assert_eq!(responses.len(), 1);
479        assert_eq!(responses[0].target_id, "fc_perm_1");
480        assert_eq!(responses[0].result["approved"], true);
481        assert_eq!(responses[0].result["reason"], "looks safe");
482    }
483
484    #[test]
485    fn extracts_approval_responded_remember_flag_as_interaction_response() {
486        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
487            "id": "t1c",
488            "messages": [
489                {
490                    "role": "assistant",
491                    "parts": [{
492                        "type": "tool-echo",
493                        "toolCallId": "call_echo_2",
494                        "state": "approval-responded",
495                        "approval": {
496                            "id": "fc_perm_2",
497                            "approved": true,
498                            "remember": true
499                        }
500                    }]
501                }
502            ]
503        }))
504        .expect("messages payload should deserialize");
505
506        let responses = req.interaction_responses();
507        assert_eq!(responses.len(), 1);
508        assert_eq!(responses[0].target_id, "fc_perm_2");
509        assert_eq!(responses[0].result["approved"], true);
510        assert_eq!(responses[0].result["remember"], true);
511    }
512
513    #[test]
514    fn extracts_tool_approval_response_parts_as_interaction_responses() {
515        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
516            "id": "t1b",
517            "messages": [
518                {
519                    "role": "assistant",
520                    "parts": [{
521                        "type": "tool-approval-response",
522                        "approvalId": "fc_perm_7",
523                        "approved": false,
524                        "reason": "denied by user"
525                    }]
526                }
527            ]
528        }))
529        .expect("messages payload should deserialize");
530
531        let responses = req.interaction_responses();
532        assert_eq!(responses.len(), 1);
533        assert_eq!(responses[0].target_id, "fc_perm_7");
534        assert_eq!(responses[0].result["approved"], false);
535        assert_eq!(responses[0].result["reason"], "denied by user");
536    }
537
538    #[test]
539    fn extracts_output_available_parts_as_interaction_responses() {
540        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
541            "id": "t2",
542            "messages": [
543                {
544                    "role": "assistant",
545                    "parts": [{
546                        "type": "tool-askUserQuestion",
547                        "toolCallId": "ask_call_1",
548                        "state": "output-available",
549                        "output": {"answer":"blue"}
550                    }]
551                }
552            ]
553        }))
554        .expect("messages payload should deserialize");
555
556        let responses = req.interaction_responses();
557        assert_eq!(responses.len(), 1);
558        assert_eq!(responses[0].target_id, "ask_call_1");
559        assert_eq!(responses[0].result["answer"], "blue");
560    }
561
562    #[test]
563    fn output_denied_part_maps_to_denied_response() {
564        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
565            "id": "t3",
566            "messages": [
567                {
568                    "role": "assistant",
569                    "parts": [{
570                        "type": "dynamic-tool",
571                        "toolCallId": "call_1",
572                        "state": "output-denied"
573                    }]
574                }
575            ]
576        }))
577        .expect("messages payload should deserialize");
578
579        let responses = req.interaction_responses();
580        assert_eq!(responses.len(), 1);
581        assert_eq!(responses[0].target_id, "call_1");
582        assert_eq!(responses[0].result, Value::Bool(false));
583    }
584
585    #[test]
586    fn output_error_part_maps_to_error_response() {
587        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
588            "id": "t4",
589            "messages": [
590                {
591                    "role": "assistant",
592                    "parts": [{
593                        "type": "dynamic-tool",
594                        "toolCallId": "call_err_1",
595                        "state": "output-error",
596                        "errorText": "frontend failed"
597                    }]
598                }
599            ]
600        }))
601        .expect("messages payload should deserialize");
602
603        let responses = req.interaction_responses();
604        assert_eq!(responses.len(), 1);
605        assert_eq!(responses[0].target_id, "call_err_1");
606        assert_eq!(responses[0].result["approved"], false);
607        assert_eq!(responses[0].result["error"], "frontend failed");
608    }
609
610    #[test]
611    fn output_error_without_error_text_uses_default_message() {
612        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
613            "id": "t4b",
614            "messages": [
615                {
616                    "role": "assistant",
617                    "parts": [{
618                        "type": "dynamic-tool",
619                        "toolCallId": "call_err_default",
620                        "state": "output-error"
621                    }]
622                }
623            ]
624        }))
625        .expect("messages payload should deserialize");
626
627        let responses = req.interaction_responses();
628        assert_eq!(responses.len(), 1);
629        assert_eq!(responses[0].target_id, "call_err_default");
630        assert_eq!(responses[0].result["approved"], false);
631        assert_eq!(responses[0].result["error"], "tool output error");
632    }
633
634    #[test]
635    fn approval_responded_without_approval_id_falls_back_to_tool_call_id() {
636        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
637            "id": "t5",
638            "messages": [
639                {
640                    "role": "assistant",
641                    "parts": [{
642                        "type": "tool-echo",
643                        "toolCallId": "fc_perm_fallback",
644                        "state": "approval-responded",
645                        "approval": {
646                            "approved": true
647                        }
648                    }]
649                }
650            ]
651        }))
652        .expect("messages payload should deserialize");
653
654        let responses = req.interaction_responses();
655        assert_eq!(responses.len(), 1);
656        assert_eq!(responses[0].target_id, "fc_perm_fallback");
657        assert_eq!(responses[0].result["approved"], true);
658    }
659
660    #[test]
661    fn tool_approval_response_preserves_remember_flag() {
662        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
663            "id": "t5c",
664            "messages": [
665                {
666                    "role": "assistant",
667                    "parts": [{
668                        "type": "tool-approval-response",
669                        "approvalId": "fc_perm_11",
670                        "approved": true,
671                        "remember": true
672                    }]
673                }
674            ]
675        }))
676        .expect("messages payload should deserialize");
677
678        let responses = req.interaction_responses();
679        assert_eq!(responses.len(), 1);
680        assert_eq!(responses[0].target_id, "fc_perm_11");
681        assert_eq!(responses[0].result["approved"], true);
682        assert_eq!(responses[0].result["remember"], true);
683    }
684
685    #[test]
686    fn tool_approval_response_without_reason_only_contains_approved_field() {
687        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
688            "id": "t5b",
689            "messages": [
690                {
691                    "role": "assistant",
692                    "parts": [{
693                        "type": "tool-approval-response",
694                        "approvalId": "fc_perm_10",
695                        "approved": true
696                    }]
697                }
698            ]
699        }))
700        .expect("messages payload should deserialize");
701
702        let responses = req.interaction_responses();
703        assert_eq!(responses.len(), 1);
704        assert_eq!(responses[0].target_id, "fc_perm_10");
705        assert_eq!(responses[0].result["approved"], true);
706        assert!(responses[0].result.get("reason").is_none());
707    }
708
709    #[test]
710    fn latest_interaction_response_wins_for_same_target_id() {
711        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
712            "id": "t6",
713            "messages": [
714                {
715                    "role": "assistant",
716                    "parts": [{
717                        "type": "tool-PermissionConfirm",
718                        "toolCallId": "fc_perm_9",
719                        "state": "approval-responded",
720                        "approval": {
721                            "id": "fc_perm_9",
722                            "approved": true
723                        }
724                    }]
725                },
726                {
727                    "role": "assistant",
728                    "parts": [{
729                        "type": "tool-PermissionConfirm",
730                        "toolCallId": "fc_perm_9",
731                        "state": "approval-responded",
732                        "approval": {
733                            "id": "fc_perm_9",
734                            "approved": false,
735                            "reason": "user changed mind"
736                        }
737                    }]
738                }
739            ]
740        }))
741        .expect("messages payload should deserialize");
742
743        let responses = req.interaction_responses();
744        assert_eq!(responses.len(), 1);
745        assert_eq!(responses[0].target_id, "fc_perm_9");
746        assert_eq!(responses[0].result["approved"], false);
747        assert_eq!(responses[0].result["reason"], "user changed mind");
748    }
749
750    #[test]
751    fn suspension_decisions_preserve_last_write_order() {
752        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
753            "id": "t6b",
754            "messages": [
755                {
756                    "role": "assistant",
757                    "parts": [{
758                        "type": "tool-approval-response",
759                        "approvalId": "perm_1",
760                        "approved": true
761                    }]
762                },
763                {
764                    "role": "assistant",
765                    "parts": [{
766                        "type": "tool-approval-response",
767                        "approvalId": "perm_2",
768                        "approved": true
769                    }]
770                },
771                {
772                    "role": "assistant",
773                    "parts": [{
774                        "type": "tool-approval-response",
775                        "approvalId": "perm_1",
776                        "approved": false
777                    }]
778                }
779            ]
780        }))
781        .expect("messages payload should deserialize");
782
783        let run_request = req.into_runtime_run_request("agent".to_string());
784        let decision_targets: Vec<&str> = run_request
785            .initial_decisions
786            .iter()
787            .map(|decision| decision.target_id.as_str())
788            .collect();
789        assert_eq!(
790            decision_targets,
791            vec!["perm_2", "perm_1"],
792            "last-write ordering should be stable after dedup"
793        );
794    }
795
796    #[test]
797    fn interaction_only_messages_generate_empty_run_messages() {
798        let req: AiSdkV6RunRequest = serde_json::from_value(json!({
799            "id": "thread-int-only",
800            "messages": [
801                {
802                    "role": "assistant",
803                    "parts": [{
804                        "type": "tool-askUserQuestion",
805                        "toolCallId": "ask_1",
806                        "state": "output-available",
807                        "output": {"message":"blue"}
808                    }]
809                }
810            ]
811        }))
812        .expect("messages payload should deserialize");
813
814        assert!(!req.has_user_input());
815        assert!(req.has_interaction_responses());
816        assert!(req.has_suspension_decisions());
817        let decisions = req.suspension_decisions();
818        assert_eq!(decisions.len(), 1);
819        assert_eq!(decisions[0].target_id, "ask_1");
820        let run_request = req.into_runtime_run_request("agent".to_string());
821        assert!(run_request.messages.is_empty());
822        assert_eq!(run_request.initial_decisions.len(), 1);
823        assert_eq!(run_request.initial_decisions[0].target_id, "ask_1");
824    }
825
826    #[test]
827    fn validate_rejects_empty_thread_id() {
828        let req = AiSdkV6RunRequest::from_thread_input("", "hello");
829        let err = req.validate().unwrap_err();
830        assert!(err.contains("id cannot be empty"), "unexpected: {err}");
831    }
832
833    #[test]
834    fn validate_rejects_whitespace_thread_id() {
835        let req = AiSdkV6RunRequest::from_thread_input("  ", "hello");
836        assert!(req.validate().is_err());
837    }
838
839    #[test]
840    fn validate_rejects_regenerate_without_message_id() {
841        let mut req = AiSdkV6RunRequest::from_thread_input("t1", "");
842        req.trigger = Some(AiSdkTrigger::RegenerateMessage);
843        req.message_id = None;
844        let err = req.validate().unwrap_err();
845        assert!(err.contains("messageId is required"), "unexpected: {err}");
846    }
847
848    #[test]
849    fn validate_rejects_regenerate_with_empty_message_id() {
850        let mut req = AiSdkV6RunRequest::from_thread_input("t1", "");
851        req.trigger = Some(AiSdkTrigger::RegenerateMessage);
852        req.message_id = Some("  ".to_string());
853        let err = req.validate().unwrap_err();
854        assert!(
855            err.contains("messageId cannot be empty"),
856            "unexpected: {err}"
857        );
858    }
859
860    #[test]
861    fn validate_rejects_no_input_no_decisions() {
862        let req = AiSdkV6RunRequest::from_thread_input("t1", "");
863        let err = req.validate().unwrap_err();
864        assert!(err.contains("must include user input"), "unexpected: {err}");
865    }
866
867    #[test]
868    fn validate_accepts_regenerate_without_user_input() {
869        let mut req = AiSdkV6RunRequest::from_thread_input("t1", "");
870        req.trigger = Some(AiSdkTrigger::RegenerateMessage);
871        req.message_id = Some("msg_1".to_string());
872        assert!(req.validate().is_ok());
873    }
874
875    #[test]
876    fn validate_accepts_valid_request() {
877        let req = AiSdkV6RunRequest::from_thread_input("t1", "hello");
878        assert!(req.validate().is_ok());
879    }
880
881    #[test]
882    fn decision_action_null_defaults_to_cancel() {
883        use tirea_contract::io::decision_translation::decision_action_from_result;
884        use tirea_contract::io::ResumeDecisionAction;
885        assert_eq!(
886            decision_action_from_result(&Value::Null),
887            ResumeDecisionAction::Cancel
888        );
889    }
890
891    #[test]
892    fn decision_action_array_defaults_to_cancel() {
893        use tirea_contract::io::decision_translation::decision_action_from_result;
894        use tirea_contract::io::ResumeDecisionAction;
895        assert_eq!(
896            decision_action_from_result(&json!([])),
897            ResumeDecisionAction::Cancel
898        );
899    }
900
901    #[test]
902    fn decision_action_number_defaults_to_cancel() {
903        use tirea_contract::io::decision_translation::decision_action_from_result;
904        use tirea_contract::io::ResumeDecisionAction;
905        assert_eq!(
906            decision_action_from_result(&json!(42)),
907            ResumeDecisionAction::Cancel
908        );
909    }
910}