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 #[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 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 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 pub fn has_user_input(&self) -> bool {
126 !self.input.trim().is_empty()
127 }
128
129 pub fn has_interaction_responses(&self) -> bool {
131 !self.interaction_responses.is_empty()
132 }
133
134 pub fn has_suspension_decisions(&self) -> bool {
136 !self.suspension_decisions().is_empty()
137 }
138
139 pub fn interaction_responses(&self) -> Vec<SuspensionResponse> {
141 self.interaction_responses.clone()
142 }
143
144 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 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}