1use crate::core::agent::task::{ContextItem, Task};
4use crate::llm::provider::{ContentPart, Message, MessageContent, MessageRole, ToolCall};
5use crate::llm::providers::gemini::wire::{
6 Content, FunctionCall, FunctionResponse, InlineData, Part,
7};
8use serde_json::{Value, json};
9use std::collections::HashMap;
10use std::fmt::Write;
11
12const GEMINI_PRESERVED_PARTS_PREFIX: &str = "__vtcode_gemini_parts__:";
13
14pub fn build_conversation(task: &Task, contexts: &[ContextItem]) -> Vec<Content> {
16 let mut conversation = Vec::with_capacity(3);
17 let mut task_content = String::with_capacity(task.title.len() + task.description.len() + 20);
18 let _ = write!(
19 task_content,
20 "Task: {}\nDescription: {}",
21 task.title, task.description
22 );
23 conversation.push(Content::user_text(task_content));
24
25 if let Some(instructions) = task.instructions.as_ref() {
26 conversation.push(Content::user_text(instructions.clone()));
27 }
28
29 if !contexts.is_empty() {
30 let mut context_content = String::from("Relevant Context:");
31 for ctx in contexts {
32 let _ = write!(context_content, "\nContext [{}]: {}", ctx.id, ctx.content);
33 }
34 conversation.push(Content::user_text(context_content));
35 }
36
37 conversation
38}
39
40pub fn messages_from_conversation(conversation: &[Content]) -> Vec<Message> {
42 let mut messages = Vec::with_capacity(conversation.len());
43 for content in conversation {
44 let part_count = content.parts.len();
45 let mut content_parts = Vec::with_capacity(part_count);
46 let mut tool_calls = Vec::new();
47 let mut tool_responses = Vec::new();
48
49 for part in &content.parts {
50 match part {
51 Part::Text {
52 text: part_text, ..
53 } => {
54 if let Some(ContentPart::Text { text }) = content_parts.last_mut() {
55 if !text.is_empty() {
56 text.push('\n');
57 }
58 text.push_str(part_text);
59 } else if !part_text.is_empty() {
60 content_parts.push(ContentPart::text(part_text.clone()));
61 }
62 }
63 Part::InlineData { inline_data } => {
64 content_parts.push(ContentPart::image(
65 inline_data.data.clone(),
66 inline_data.mime_type.clone(),
67 ));
68 }
69 Part::FunctionCall {
70 function_call,
71 thought_signature,
72 } => {
73 let mut tool_call = ToolCall::function(
74 function_call.id.clone().unwrap_or_default(),
75 function_call.name.clone(),
76 function_call.args.to_string(),
77 );
78 tool_call.thought_signature = thought_signature.clone();
79 tool_calls.push(tool_call);
80 }
81 Part::FunctionResponse {
82 function_response, ..
83 } => {
84 let id = function_response
85 .id
86 .clone()
87 .unwrap_or_else(|| "unknown".to_string());
88 let response_str = function_response.response.to_string();
89 tool_responses.push(Message::tool_response(id, response_str));
90 }
91 Part::ToolCall { .. }
92 | Part::ToolResponse { .. }
93 | Part::ExecutableCode { .. }
94 | Part::CodeExecutionResult { .. } => {}
95 Part::CacheControl { .. } => {}
96 }
97 }
98
99 if !tool_responses.is_empty() {
100 messages.extend(tool_responses);
101 if !content_parts.is_empty() {
102 messages.push(Message::user_with_parts(content_parts));
103 }
104 continue;
105 }
106
107 let mut message = match content.role.as_str() {
108 "model" => {
109 if content_parts.is_empty() {
110 Message::assistant(String::new())
111 } else {
112 Message::assistant_with_parts(content_parts)
113 }
114 }
115 _ => {
116 if content_parts.is_empty() {
117 Message::user(String::new())
118 } else {
119 Message::user_with_parts(content_parts)
120 }
121 }
122 };
123
124 if !tool_calls.is_empty() {
125 message.tool_calls = Some(tool_calls);
126 }
127
128 if let Some(raw_parts_detail) = preserved_parts_detail(&content.parts) {
129 message = message.with_reasoning_details(Some(vec![json!(raw_parts_detail)]));
130 }
131
132 messages.push(message);
133 }
134
135 messages
136}
137
138pub fn build_messages_from_conversation(conversation: &[Content]) -> Vec<Message> {
142 messages_from_conversation(conversation)
143}
144
145fn parts_from_message_content(content: &MessageContent) -> Vec<Part> {
146 match content {
147 MessageContent::Text(text) => {
148 if text.is_empty() {
149 Vec::new()
150 } else {
151 vec![Part::Text {
152 text: text.clone(),
153 thought_signature: None,
154 }]
155 }
156 }
157 MessageContent::Parts(parts) => {
158 let mut converted = Vec::with_capacity(parts.len());
159 for part in parts {
160 match part {
161 ContentPart::Text { text } => {
162 if !text.is_empty() {
163 converted.push(Part::Text {
164 text: text.clone(),
165 thought_signature: None,
166 });
167 }
168 }
169 ContentPart::Image {
170 data, mime_type, ..
171 } => {
172 converted.push(Part::InlineData {
173 inline_data: InlineData {
174 mime_type: mime_type.clone(),
175 data: data.clone(),
176 },
177 });
178 }
179 ContentPart::File {
180 filename,
181 file_id,
182 file_url,
183 ..
184 } => {
185 let fallback = filename
186 .clone()
187 .or_else(|| file_id.clone())
188 .or_else(|| file_url.clone())
189 .unwrap_or_else(|| "attached file".to_string());
190 converted.push(Part::Text {
191 text: format!("[File input not directly supported: {fallback}]"),
192 thought_signature: None,
193 });
194 }
195 }
196 }
197 converted
198 }
199 }
200}
201
202fn tool_call_arguments(arguments: &str) -> Value {
203 serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
204}
205
206fn tool_response_value(content: &MessageContent) -> Value {
207 let text = content.as_text();
208 serde_json::from_str(text.as_ref()).unwrap_or_else(|_| json!({ "result": text.as_ref() }))
209}
210
211pub fn conversation_from_messages(messages: &[Message]) -> Vec<Content> {
215 let mut conversation = Vec::with_capacity(messages.len());
216 let mut tool_names_by_call_id: HashMap<String, String> = HashMap::with_capacity(messages.len());
217
218 for message in messages {
219 match message.role {
220 MessageRole::System => {}
221 MessageRole::User => {
222 let parts = parts_from_message_content(&message.content);
223 if !parts.is_empty() {
224 conversation.push(Content {
225 role: "user".to_string(),
226 parts,
227 });
228 }
229 }
230 MessageRole::Assistant => {
231 let parts = preserved_parts_from_message(message).unwrap_or_else(|| {
232 let mut rebuilt_parts = parts_from_message_content(&message.content);
233 if let Some(tool_calls) = &message.tool_calls {
234 for tool_call in tool_calls {
235 let Some(function) = &tool_call.function else {
236 continue;
237 };
238
239 let id = (!tool_call.id.is_empty()).then(|| tool_call.id.clone());
240 if let Some(call_id) = id.as_ref() {
241 tool_names_by_call_id
242 .insert(call_id.clone(), function.name.clone());
243 }
244
245 rebuilt_parts.push(Part::FunctionCall {
246 function_call: FunctionCall {
247 name: function.name.clone(),
248 args: tool_call_arguments(&function.arguments),
249 id,
250 },
251 thought_signature: tool_call.thought_signature.clone(),
252 });
253 }
254 }
255 rebuilt_parts
256 });
257
258 for part in &parts {
259 if let Part::FunctionCall { function_call, .. } = part
260 && let Some(call_id) = function_call.id.as_ref()
261 {
262 tool_names_by_call_id.insert(call_id.clone(), function_call.name.clone());
263 }
264 }
265
266 if !parts.is_empty() {
267 conversation.push(Content {
268 role: "model".to_string(),
269 parts,
270 });
271 }
272 }
273 MessageRole::Tool => {
274 let Some(call_id) = message
275 .tool_call_id
276 .as_ref()
277 .filter(|value| !value.is_empty())
278 .cloned()
279 else {
280 let parts = parts_from_message_content(&message.content);
281 if !parts.is_empty() {
282 conversation.push(Content {
283 role: "user".to_string(),
284 parts,
285 });
286 }
287 continue;
288 };
289
290 let tool_name = message
291 .origin_tool
292 .clone()
293 .or_else(|| tool_names_by_call_id.get(&call_id).cloned())
294 .unwrap_or_else(|| "tool".to_string());
295
296 conversation.push(Content {
297 role: "function".to_string(),
298 parts: vec![Part::FunctionResponse {
299 function_response: FunctionResponse {
300 name: tool_name,
301 response: tool_response_value(&message.content),
302 id: Some(call_id),
303 },
304 thought_signature: None,
305 }],
306 });
307 }
308 }
309 }
310
311 conversation
312}
313
314fn preserved_parts_from_message(message: &Message) -> Option<Vec<Part>> {
315 let details = message.reasoning_details.as_ref()?;
316 for detail in details {
317 let Some(text) = detail.as_str() else {
318 continue;
319 };
320 let Some(payload) = text.strip_prefix(GEMINI_PRESERVED_PARTS_PREFIX) else {
321 continue;
322 };
323 if let Ok(parts) = serde_json::from_str::<Vec<Part>>(payload) {
324 return Some(parts);
325 }
326 }
327 None
328}
329
330fn preserved_parts_detail(parts: &[Part]) -> Option<String> {
331 let should_preserve = parts.iter().any(|part| {
332 part.thought_signature().is_some()
333 || matches!(
334 part,
335 Part::ToolCall { .. }
336 | Part::ToolResponse { .. }
337 | Part::ExecutableCode { .. }
338 | Part::CodeExecutionResult { .. }
339 | Part::FunctionResponse { .. }
340 | Part::InlineData { .. }
341 )
342 });
343 if !should_preserve {
344 return None;
345 }
346
347 serde_json::to_string(parts)
348 .ok()
349 .map(|serialized| format!("{GEMINI_PRESERVED_PARTS_PREFIX}{serialized}"))
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::llm::provider::{FunctionCall, ToolCall};
356
357 fn sample_task() -> Task {
358 Task {
359 id: "task-1".to_owned(),
360 title: "Example".to_owned(),
361 description: "Do something".to_owned(),
362 instructions: Some("Follow steps".to_owned()),
363 }
364 }
365
366 #[test]
367 fn conversation_builds_expected_steps() {
368 let task = sample_task();
369 let contexts = vec![ContextItem {
370 id: "ctx1".into(),
371 content: "Data".into(),
372 }];
373 let conversation = build_conversation(&task, &contexts);
374 assert_eq!(conversation.len(), 3);
375 }
376
377 #[test]
378 fn messages_mirror_conversation_without_system_prompt() {
379 let task = sample_task();
380 let conversation = build_conversation(&task, &[]);
381 let messages = build_messages_from_conversation(&conversation);
382 assert_eq!(messages.len(), conversation.len());
383 assert!(
384 messages
385 .iter()
386 .all(|message| message.role != MessageRole::System)
387 );
388 }
389
390 #[test]
391 fn archived_messages_rebuild_function_history() {
392 let history = vec![
393 Message::system("Base".to_string()),
394 Message::user("Inspect src/main.rs".to_string()),
395 Message::assistant_with_tools(
396 "Running read_file".to_string(),
397 vec![ToolCall {
398 id: "call-1".to_string(),
399 call_type: "function".to_string(),
400 function: Some(FunctionCall {
401 namespace: None,
402 name: "read_file".to_string(),
403 arguments: "{\"path\":\"src/main.rs\"}".to_string(),
404 }),
405 text: None,
406 thought_signature: None,
407 }],
408 ),
409 Message::tool_response(
410 "call-1".to_string(),
411 "{\"content\":\"fn main() {}\"}".to_string(),
412 ),
413 Message::assistant("Done".to_string()),
414 ];
415
416 let conversation = conversation_from_messages(&history);
417 let rebuilt = build_messages_from_conversation(&conversation);
418
419 assert_eq!(rebuilt[0].role, MessageRole::User);
420 assert_eq!(rebuilt[0].content.as_text().as_ref(), "Inspect src/main.rs");
421 assert_eq!(rebuilt[1].role, MessageRole::Assistant);
422 assert_eq!(
423 rebuilt[1]
424 .tool_calls
425 .as_ref()
426 .and_then(|calls| calls.first())
427 .and_then(|call| call.function.as_ref())
428 .map(|function| function.name.as_str()),
429 Some("read_file")
430 );
431 assert_eq!(rebuilt[2].role, MessageRole::Tool);
432 assert_eq!(rebuilt[2].tool_call_id.as_deref(), Some("call-1"));
433 assert_eq!(rebuilt[3].role, MessageRole::Assistant);
434 assert_eq!(rebuilt[3].content.as_text().as_ref(), "Done");
435 }
436}