tirea_protocol_ai_sdk_v6/
history_encoder.rs1use super::{StreamState, TextUIPart, ToolState, ToolUIPart, UIMessage, UIMessagePart, UIRole};
2use serde_json::Value;
3use tirea_contract::{Message, Role};
4
5pub struct AiSdkV6HistoryEncoder;
6
7impl AiSdkV6HistoryEncoder {
8 pub fn encode_message(msg: &Message) -> UIMessage {
9 let role = match msg.role {
10 Role::System => UIRole::System,
11 Role::User => UIRole::User,
12 Role::Assistant | Role::Tool => UIRole::Assistant,
13 };
14
15 let mut parts = Vec::new();
16
17 if msg.role == Role::Tool {
18 if let Some(tool_call_id) = &msg.tool_call_id {
19 let mut part = ToolUIPart::dynamic_tool(
20 "tool",
21 tool_call_id.clone(),
22 ToolState::OutputAvailable,
23 );
24 part.output = Some(parse_tool_output(&msg.content));
25 parts.push(UIMessagePart::Tool(part));
26 } else if !msg.content.is_empty() {
27 parts.push(UIMessagePart::Text(TextUIPart::new(
28 msg.content.clone(),
29 Some(StreamState::Done),
30 )));
31 }
32 } else {
33 if !msg.content.is_empty() {
34 parts.push(UIMessagePart::Text(TextUIPart::new(
35 msg.content.clone(),
36 Some(StreamState::Done),
37 )));
38 }
39
40 if let Some(ref calls) = msg.tool_calls {
41 for tc in calls {
42 let mut part = ToolUIPart::static_tool(
43 tc.name.clone(),
44 tc.id.clone(),
45 ToolState::InputAvailable,
46 );
47 part.input = Some(tc.arguments.clone());
48 parts.push(UIMessagePart::Tool(part));
49 }
50 }
51 }
52
53 let metadata = msg.metadata.as_ref().and_then(|metadata| {
54 let mut object = serde_json::Map::new();
55 if let Some(step_index) = metadata.step_index {
56 object.insert("step_index".to_string(), Value::from(step_index));
57 }
58 if object.is_empty() {
59 None
60 } else {
61 Some(Value::Object(object))
62 }
63 });
64
65 UIMessage {
66 id: msg.id.clone().unwrap_or_default(),
67 role,
68 metadata,
69 parts,
70 }
71 }
72}
73
74fn parse_tool_output(content: &str) -> Value {
75 if content.is_empty() {
76 Value::Null
77 } else {
78 serde_json::from_str(content).unwrap_or_else(|_| Value::String(content.to_string()))
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use tirea_contract::{Message, MessageMetadata, ToolCall, Visibility};
86
87 #[test]
88 fn test_ai_sdk_history_encoder_user_message() {
89 let msg = Message {
90 id: Some("msg_1".to_string()),
91 role: Role::User,
92 content: "hello".to_string(),
93 tool_calls: None,
94 tool_call_id: None,
95 visibility: Visibility::default(),
96 metadata: None,
97 };
98 let encoded = AiSdkV6HistoryEncoder::encode_message(&msg);
99 assert_eq!(encoded.id, "msg_1");
100 assert_eq!(encoded.role, UIRole::User);
101 assert_eq!(encoded.parts.len(), 1);
102 assert!(matches!(
103 &encoded.parts[0],
104 UIMessagePart::Text(TextUIPart {
105 text,
106 state: Some(StreamState::Done),
107 ..
108 }) if text == "hello"
109 ));
110 }
111
112 #[test]
113 fn test_ai_sdk_history_encoder_assistant_with_tool_calls() {
114 let msg = Message {
115 id: Some("msg_2".to_string()),
116 role: Role::Assistant,
117 content: "Let me search.".to_string(),
118 tool_calls: Some(vec![
119 ToolCall {
120 id: "call_1".to_string(),
121 name: "search".to_string(),
122 arguments: serde_json::json!({"query": "rust"}),
123 },
124 ToolCall {
125 id: "call_2".to_string(),
126 name: "fetch".to_string(),
127 arguments: serde_json::json!({"url": "https://example.com"}),
128 },
129 ]),
130 tool_call_id: None,
131 visibility: Visibility::default(),
132 metadata: None,
133 };
134 let encoded = AiSdkV6HistoryEncoder::encode_message(&msg);
135 assert_eq!(encoded.role, UIRole::Assistant);
136 assert_eq!(encoded.parts.len(), 3);
137 assert!(matches!(
138 &encoded.parts[0],
139 UIMessagePart::Text(TextUIPart { text, .. }) if text == "Let me search."
140 ));
141 assert!(matches!(
142 &encoded.parts[1],
143 UIMessagePart::Tool(ToolUIPart { part_type, tool_call_id, state: ToolState::InputAvailable, .. })
144 if tool_call_id == "call_1" && part_type == "tool-search"
145 ));
146 assert!(matches!(
147 &encoded.parts[2],
148 UIMessagePart::Tool(ToolUIPart { part_type, tool_call_id, .. })
149 if tool_call_id == "call_2" && part_type == "tool-fetch"
150 ));
151 }
152
153 #[test]
154 fn test_ai_sdk_history_encoder_tool_role_maps_to_assistant_with_tool_output_part() {
155 let msg = Message {
156 id: Some("msg_3".to_string()),
157 role: Role::Tool,
158 content: "{\"result\":42}".to_string(),
159 tool_calls: None,
160 tool_call_id: Some("call_1".to_string()),
161 visibility: Visibility::default(),
162 metadata: None,
163 };
164 let encoded = AiSdkV6HistoryEncoder::encode_message(&msg);
165 assert_eq!(encoded.role, UIRole::Assistant);
166 assert_eq!(encoded.parts.len(), 1);
167 assert!(matches!(
168 &encoded.parts[0],
169 UIMessagePart::Tool(ToolUIPart { tool_call_id, state: ToolState::OutputAvailable, output: Some(output), .. })
170 if tool_call_id == "call_1" && output["result"] == 42
171 ));
172 }
173
174 #[test]
175 fn test_ai_sdk_history_encoder_tool_role_without_call_id_falls_back_to_text() {
176 let msg = Message {
177 id: Some("msg_3b".to_string()),
178 role: Role::Tool,
179 content: "plain output".to_string(),
180 tool_calls: None,
181 tool_call_id: None,
182 visibility: Visibility::default(),
183 metadata: None,
184 };
185 let encoded = AiSdkV6HistoryEncoder::encode_message(&msg);
186 assert_eq!(encoded.role, UIRole::Assistant);
187 assert_eq!(encoded.parts.len(), 1);
188 assert!(matches!(
189 &encoded.parts[0],
190 UIMessagePart::Text(TextUIPart { text, .. }) if text == "plain output"
191 ));
192 }
193
194 #[test]
195 fn test_ai_sdk_history_encoder_empty_content_no_text_part() {
196 let msg = Message {
197 id: Some("msg_4".to_string()),
198 role: Role::Assistant,
199 content: String::new(),
200 tool_calls: Some(vec![ToolCall {
201 id: "call_1".to_string(),
202 name: "search".to_string(),
203 arguments: serde_json::json!({}),
204 }]),
205 tool_call_id: None,
206 visibility: Visibility::default(),
207 metadata: None,
208 };
209 let encoded = AiSdkV6HistoryEncoder::encode_message(&msg);
210 assert_eq!(encoded.parts.len(), 1);
211 assert!(matches!(&encoded.parts[0], UIMessagePart::Tool(_)));
212 }
213
214 #[test]
215 fn test_ai_sdk_history_encoder_no_id_defaults_empty() {
216 let msg = Message {
217 id: None,
218 role: Role::User,
219 content: "hello".to_string(),
220 tool_calls: None,
221 tool_call_id: None,
222 visibility: Visibility::default(),
223 metadata: None,
224 };
225 let encoded = AiSdkV6HistoryEncoder::encode_message(&msg);
226 assert_eq!(encoded.id, "");
227 }
228
229 #[test]
230 fn test_ai_sdk_history_encoder_with_metadata() {
231 let msg = Message {
232 id: Some("msg_5".to_string()),
233 role: Role::Assistant,
234 content: "response".to_string(),
235 tool_calls: None,
236 tool_call_id: None,
237 visibility: Visibility::default(),
238 metadata: Some(MessageMetadata {
239 run_id: Some("run_1".to_string()),
240 step_index: Some(2),
241 }),
242 };
243 let encoded = AiSdkV6HistoryEncoder::encode_message(&msg);
244 assert!(encoded.metadata.is_some());
245 let meta = encoded.metadata.unwrap();
246 assert!(meta.get("run_id").is_none(), "run_id must not be exposed");
247 assert_eq!(meta["step_index"], 2);
248 }
249
250 #[test]
251 fn test_ai_sdk_history_encoder_system_message() {
252 let msg = Message {
253 id: Some("msg_sys".to_string()),
254 role: Role::System,
255 content: "You are helpful.".to_string(),
256 tool_calls: None,
257 tool_call_id: None,
258 visibility: Visibility::default(),
259 metadata: None,
260 };
261 let encoded = AiSdkV6HistoryEncoder::encode_message(&msg);
262 assert_eq!(encoded.role, UIRole::System);
263 }
264
265 #[test]
266 fn test_ai_sdk_encode_messages_batch() {
267 let msgs = [Message::user("hello"), Message::assistant("world")];
268 let encoded: Vec<_> = msgs
269 .iter()
270 .map(AiSdkV6HistoryEncoder::encode_message)
271 .collect();
272 assert_eq!(encoded.len(), 2);
273 assert_eq!(encoded[0].role, UIRole::User);
274 assert_eq!(encoded[1].role, UIRole::Assistant);
275 }
276
277 #[test]
278 fn test_ai_sdk_history_encoder_serialization() {
279 let msg = Message {
280 id: Some("msg_1".to_string()),
281 role: Role::Assistant,
282 content: "Hello".to_string(),
283 tool_calls: Some(vec![ToolCall {
284 id: "call_1".to_string(),
285 name: "search".to_string(),
286 arguments: serde_json::json!({"q": "test"}),
287 }]),
288 tool_call_id: None,
289 visibility: Visibility::default(),
290 metadata: None,
291 };
292 let encoded = AiSdkV6HistoryEncoder::encode_message(&msg);
293 let json = serde_json::to_string(&encoded).unwrap();
294 assert!(json.contains("toolCallId"));
295 assert!(json.contains("\"type\":\"tool-search\""));
296 assert!(!json.contains("tool_call_id"));
297 assert!(!json.contains("tool_name"));
298 }
299}