Skip to main content

vtcode_core/llm/providers/openai/
responses_api.rs

1use crate::llm::error_display;
2use crate::llm::provider::{
3    AssistantPhase, ContentPart, FinishReason, LLMError, LLMRequest, LLMResponse, MessageContent,
4    MessageRole, ToolCall, Usage,
5};
6use crate::llm::providers::common::append_normalized_reasoning_detail_items;
7use crate::llm::providers::openai::types::OpenAIResponsesPayload;
8use crate::llm::providers::shared::{
9    collect_tool_references_from_tool_search_output, function_output_value_from_message_content,
10    tool_result_content_from_message_content,
11};
12use hashbrown::HashMap;
13use serde_json::{Value, json};
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16enum ResponsesToolCallKind {
17    Function,
18    Custom,
19}
20
21fn responses_tool_call_kind(call: &ToolCall) -> ResponsesToolCallKind {
22    if call.is_custom() {
23        ResponsesToolCallKind::Custom
24    } else {
25        ResponsesToolCallKind::Function
26    }
27}
28
29fn responses_tool_call_output_type(kind: ResponsesToolCallKind) -> &'static str {
30    match kind {
31        ResponsesToolCallKind::Function => "function_call_output",
32        ResponsesToolCallKind::Custom => "custom_tool_call_output",
33    }
34}
35
36fn parse_responses_tool_call(item: &Value) -> Option<ToolCall> {
37    let item_type = item
38        .get("type")
39        .and_then(|value| value.as_str())
40        .unwrap_or("");
41    if item_type == "custom_tool_call" {
42        let call_id = item
43            .get("call_id")
44            .and_then(|v| v.as_str())
45            .or_else(|| item.get("id").and_then(|v| v.as_str()))
46            .unwrap_or("");
47        let name = item.get("name").and_then(|value| value.as_str())?;
48        let input = item
49            .get("input")
50            .and_then(|value| value.as_str())
51            .unwrap_or_default();
52        return Some(ToolCall::custom(
53            call_id.to_string(),
54            name.to_string(),
55            input.to_string(),
56        ));
57    }
58
59    parse_responses_function_tool_call(item)
60}
61
62fn parse_responses_function_tool_call(item: &Value) -> Option<ToolCall> {
63    let call_id = item
64        .get("call_id")
65        .and_then(|v| v.as_str())
66        .or_else(|| item.get("id").and_then(|v| v.as_str()))
67        .unwrap_or("");
68    let function_obj = item.get("function").and_then(|v| v.as_object());
69    let namespace = item
70        .get("namespace")
71        .and_then(|v| v.as_str())
72        .or_else(|| function_obj.and_then(|f| f.get("namespace").and_then(|n| n.as_str())))
73        .map(ToOwned::to_owned);
74    let name = function_obj
75        .and_then(|f| f.get("name").and_then(|n| n.as_str()))
76        .or_else(|| item.get("name").and_then(|n| n.as_str()))?;
77    let arguments = function_obj
78        .and_then(|f| f.get("arguments"))
79        .or_else(|| item.get("arguments"));
80
81    let serialized = arguments.map_or("{}".to_owned(), |args| {
82        if args.is_string() {
83            args.as_str().unwrap_or("{}").to_string()
84        } else {
85            args.to_string()
86        }
87    });
88
89    Some(ToolCall::function_with_namespace(
90        call_id.to_string(),
91        namespace,
92        name.to_string(),
93        serialized,
94    ))
95}
96
97fn append_user_content_parts(content_parts: &mut Vec<Value>, message_content: &MessageContent) {
98    match message_content {
99        MessageContent::Text(text) => {
100            if !text.trim().is_empty() {
101                content_parts.push(json!({
102                    "type": "input_text",
103                    "text": text
104                }));
105            }
106        }
107        MessageContent::Parts(parts) => {
108            for part in parts {
109                match part {
110                    ContentPart::Text { text } => {
111                        if !text.trim().is_empty() {
112                            content_parts.push(json!({
113                                "type": "input_text",
114                                "text": text
115                            }));
116                        }
117                    }
118                    ContentPart::Image {
119                        data, mime_type, ..
120                    } => {
121                        let image_url = {
122                            let mut s = String::with_capacity(13 + mime_type.len() + data.len());
123                            s.push_str("data:");
124                            s.push_str(mime_type);
125                            s.push_str(";base64,");
126                            s.push_str(data);
127                            s
128                        };
129                        content_parts.push(json!({
130                            "type": "input_image",
131                            "image_url": image_url
132                        }));
133                    }
134                    ContentPart::File {
135                        filename,
136                        file_id,
137                        file_data,
138                        file_url,
139                        ..
140                    } => {
141                        if file_id.is_none() && file_data.is_none() && file_url.is_none() {
142                            continue;
143                        }
144
145                        let mut file_part = json!({
146                            "type": "input_file"
147                        });
148                        if let Value::Object(ref mut map) = file_part {
149                            if let Some(name) = filename {
150                                map.insert("filename".to_owned(), json!(name));
151                            }
152                            if let Some(id) = file_id {
153                                map.insert("file_id".to_owned(), json!(id));
154                            }
155                            if let Some(data) = file_data {
156                                map.insert("file_data".to_owned(), json!(data));
157                            }
158                            if let Some(url) = file_url {
159                                map.insert("file_url".to_owned(), json!(url));
160                            }
161                        }
162                        content_parts.push(file_part);
163                    }
164                }
165            }
166        }
167    }
168}
169
170fn assistant_input_item(content_parts: Vec<Value>, phase: Option<AssistantPhase>) -> Value {
171    let mut item = json!({
172        "role": "assistant",
173        "content": content_parts
174    });
175
176    if let Some(phase) = phase
177        && let Value::Object(ref mut map) = item
178    {
179        map.insert("phase".to_string(), json!(phase.as_str()));
180    }
181
182    item
183}
184
185fn append_assistant_text_to_instructions(instructions_segments: &mut Vec<String>, text: &str) {
186    let trimmed = text.trim();
187    if trimmed.is_empty() {
188        return;
189    }
190
191    let mut s = String::with_capacity(30 + trimmed.len());
192    s.push_str("Previous assistant response:\n");
193    s.push_str(trimmed);
194    instructions_segments.push(s);
195}
196
197fn append_output_item_text(value: &Value, text: &mut String) {
198    if let Some(part_text) = value.get("text").and_then(|value| value.as_str()) {
199        text.push_str(part_text);
200    }
201    if let Some(part_output) = value.get("output").and_then(|value| value.as_str()) {
202        text.push_str(part_output);
203    }
204    if let Some(refusal) = value.get("refusal").and_then(|value| value.as_str()) {
205        text.push_str(refusal);
206    }
207
208    match value {
209        Value::String(value) => text.push_str(value),
210        Value::Array(parts) => {
211            for part in parts {
212                append_output_item_text(part, text);
213            }
214        }
215        Value::Object(_) => {
216            if let Some(content) = value.get("content") {
217                append_output_item_text(content, text);
218            }
219        }
220        _ => {}
221    }
222}
223
224fn tool_result_history_text(message_content: &MessageContent) -> String {
225    let tool_content = tool_result_content_from_message_content(message_content);
226    if tool_content.is_empty() {
227        return String::new();
228    }
229
230    let mut text = String::new();
231    for item in &tool_content {
232        append_output_item_text(item, &mut text);
233    }
234
235    let trimmed = text.trim();
236    if !trimmed.is_empty() {
237        return trimmed.to_string();
238    }
239
240    Value::Array(tool_content).to_string()
241}
242
243fn append_tool_result_to_instructions(
244    instructions_segments: &mut Vec<String>,
245    tool_call_id: Option<&str>,
246    message_content: &MessageContent,
247) {
248    let text = tool_result_history_text(message_content);
249    if text.is_empty() {
250        return;
251    }
252
253    let (heading_str, heading_cap) = match tool_call_id {
254        Some(id) if !id.is_empty() => (None, 26 + id.len()),
255        _ => (Some("Previous tool result:"), 0),
256    };
257    let mut s =
258        String::with_capacity(heading_str.map_or(heading_cap, |h| h.len()) + 1 + text.len());
259    match heading_str {
260        Some(h) => s.push_str(h),
261        None => {
262            s.push_str("Previous tool result (");
263            s.push_str(tool_call_id.unwrap());
264            s.push_str("):");
265        }
266    }
267    s.push('\n');
268    s.push_str(&text);
269    instructions_segments.push(s);
270}
271
272pub fn parse_responses_payload(
273    response_json: Value,
274    model: String,
275    include_cached_prompt_metrics: bool,
276) -> Result<LLMResponse, LLMError> {
277    let output = response_json
278        .get("output")
279        .and_then(|value| value.as_array())
280        .ok_or_else(|| {
281            let formatted_error = error_display::format_llm_error(
282                "OpenAI",
283                "Invalid Responses API format: missing output array",
284            );
285            LLMError::Provider {
286                message: formatted_error,
287                metadata: None,
288            }
289        })?;
290
291    if output.is_empty() {
292        let formatted_error = error_display::format_llm_error("OpenAI", "No output in response");
293        return Err(LLMError::Provider {
294            message: formatted_error,
295            metadata: None,
296        });
297    }
298
299    let mut content_fragments: Vec<String> = Vec::new();
300    let mut reasoning_text_fragments: Vec<String> = Vec::new();
301    let mut reasoning_items: Vec<Value> = Vec::new();
302    let mut tool_calls_vec: Vec<ToolCall> = Vec::new();
303    let mut tool_references: Vec<String> = Vec::new();
304
305    for item in output {
306        let item_type = item
307            .get("type")
308            .and_then(|value| value.as_str())
309            .unwrap_or("");
310
311        match item_type {
312            "message" => {
313                if let Some(content_array) = item.get("content").and_then(|value| value.as_array())
314                {
315                    for entry in content_array {
316                        let entry_type = entry
317                            .get("type")
318                            .and_then(|value| value.as_str())
319                            .unwrap_or("");
320
321                        match entry_type {
322                            "text" | "output_text" => {
323                                if let Some(text) =
324                                    entry.get("text").and_then(|value| value.as_str())
325                                    && !text.is_empty()
326                                {
327                                    content_fragments.push(text.to_string());
328                                }
329                            }
330                            "reasoning" => {
331                                if let Some(text) =
332                                    entry.get("text").and_then(|value| value.as_str())
333                                    && !text.is_empty()
334                                {
335                                    reasoning_text_fragments.push(text.to_string());
336                                }
337                            }
338                            "function_call" | "tool_call" | "custom_tool_call" => {
339                                if let Some(call) = parse_responses_tool_call(entry) {
340                                    tool_calls_vec.push(call);
341                                }
342                            }
343                            "refusal" => {
344                                if let Some(refusal_text) =
345                                    entry.get("refusal").and_then(|value| value.as_str())
346                                    && !refusal_text.is_empty()
347                                {
348                                    content_fragments.push(format!("[Refusal: {}]", refusal_text));
349                                }
350                            }
351                            _ => {}
352                        }
353                    }
354                }
355            }
356            "function_call" | "tool_call" | "custom_tool_call" => {
357                if let Some(call) = parse_responses_tool_call(item) {
358                    tool_calls_vec.push(call);
359                }
360            }
361            "tool_search_output" => {
362                collect_tool_references_from_tool_search_output(item, &mut tool_references);
363            }
364            "web_search" | "file_search" => {
365                if let Some(results) = item.get("results").and_then(|r| r.as_array()) {
366                    let citations: Vec<String> = results
367                        .iter()
368                        .filter_map(|r| {
369                            let title = r
370                                .get("title")
371                                .and_then(|v| v.as_str())
372                                .unwrap_or("Untitled");
373                            let url = r.get("url").and_then(|v| v.as_str()).unwrap_or("");
374                            if !url.is_empty() {
375                                Some(format!("[{}]({})", title, url))
376                            } else {
377                                None
378                            }
379                        })
380                        .collect();
381                    if !citations.is_empty() {
382                        content_fragments.push(format!("\n\nSources:\n{}", citations.join("\n")));
383                    }
384                }
385            }
386            "reasoning" => {
387                reasoning_items.push(item.clone());
388
389                if let Some(summary_array) = item.get("summary").and_then(|v| v.as_array()) {
390                    for summary_part in summary_array {
391                        if let Some(text) = summary_part.get("text").and_then(|v| v.as_str())
392                            && !text.is_empty()
393                        {
394                            reasoning_text_fragments.push(text.to_string());
395                        }
396                    }
397                }
398            }
399            _ => {}
400        }
401    }
402
403    let content = if content_fragments.is_empty() {
404        None
405    } else {
406        Some(content_fragments.join(""))
407    };
408
409    let reasoning = if reasoning_text_fragments.is_empty() {
410        None
411    } else {
412        Some(reasoning_text_fragments.join("\n\n"))
413    };
414
415    let reasoning_details = if reasoning_items.is_empty() {
416        None
417    } else {
418        Some(reasoning_items.into_iter().map(|v| v.to_string()).collect())
419    };
420
421    let finish_reason = if !tool_calls_vec.is_empty() {
422        FinishReason::ToolCalls
423    } else {
424        FinishReason::Stop
425    };
426
427    let tool_calls = if tool_calls_vec.is_empty() {
428        None
429    } else {
430        Some(tool_calls_vec)
431    };
432
433    let usage = response_json.get("usage").map(|usage_value| {
434        let cached_prompt_tokens = if include_cached_prompt_metrics {
435            usage_value
436                .get("prompt_tokens_details")
437                .and_then(|details| details.get("cached_tokens"))
438                .or_else(|| usage_value.get("prompt_cache_hit_tokens"))
439                .and_then(|value| value.as_u64())
440                .and_then(|value| u32::try_from(value).ok())
441        } else {
442            None
443        };
444
445        Usage {
446            prompt_tokens: usage_value
447                .get("input_tokens")
448                .or_else(|| usage_value.get("prompt_tokens"))
449                .and_then(|pt| pt.as_u64())
450                .and_then(|v| u32::try_from(v).ok())
451                .unwrap_or(0),
452            completion_tokens: usage_value
453                .get("output_tokens")
454                .or_else(|| usage_value.get("completion_tokens"))
455                .and_then(|ct| ct.as_u64())
456                .and_then(|v| u32::try_from(v).ok())
457                .unwrap_or(0),
458            total_tokens: usage_value
459                .get("total_tokens")
460                .and_then(|tt| tt.as_u64())
461                .and_then(|v| u32::try_from(v).ok())
462                .unwrap_or(0),
463            cached_prompt_tokens,
464            cache_creation_tokens: None,
465            cache_read_tokens: None,
466        }
467    });
468
469    Ok(LLMResponse {
470        content,
471        tool_calls,
472        model,
473        usage,
474        finish_reason,
475        reasoning,
476        reasoning_details,
477        tool_references,
478        request_id: response_json
479            .get("id")
480            .and_then(|value| value.as_str())
481            .map(ToOwned::to_owned)
482            .or_else(|| {
483                response_json
484                    .get("request_id")
485                    .and_then(|value| value.as_str())
486                    .map(ToOwned::to_owned)
487            }),
488        organization_id: None,
489        compaction: None,
490    })
491}
492
493/// Build a standard (non-Codex) Responses API payload.
494pub fn build_standard_responses_payload(
495    request: &LLMRequest,
496    include_structured_history_in_input: bool,
497) -> Result<OpenAIResponsesPayload, LLMError> {
498    let mut input = Vec::new();
499    let mut active_tool_calls: HashMap<String, ResponsesToolCallKind> = HashMap::new();
500    let mut pending_tool_call_order: Vec<String> = Vec::new();
501    let mut deferred_tool_outputs: HashMap<String, Value> = HashMap::new();
502    let mut instructions_segments = Vec::new();
503
504    if let Some(system_prompt) = &request.system_prompt {
505        let trimmed = system_prompt.trim();
506        if !trimmed.is_empty() {
507            instructions_segments.push(trimmed.to_string());
508        }
509    }
510
511    for msg in &request.messages {
512        match msg.role {
513            MessageRole::System => {
514                let content_text = msg.content.as_text();
515                let trimmed = content_text.trim();
516                if !trimmed.is_empty() {
517                    instructions_segments.push(trimmed.to_string());
518                }
519            }
520            MessageRole::User => {
521                let mut content_parts: Vec<Value> = Vec::new();
522                append_user_content_parts(&mut content_parts, &msg.content);
523
524                if !content_parts.is_empty() {
525                    input.push(json!({
526                        "role": "user",
527                        "content": content_parts
528                    }));
529                }
530            }
531            MessageRole::Assistant => {
532                // Inject any persisted reasoning items from previous turns
533                if include_structured_history_in_input
534                    && let Some(reasoning_details) = &msg.reasoning_details
535                {
536                    append_normalized_reasoning_detail_items(&mut input, reasoning_details);
537                }
538
539                let mut content_parts = Vec::new();
540                let mut tool_call_items = Vec::new();
541                if !msg.content.is_empty() {
542                    if include_structured_history_in_input {
543                        content_parts.push(json!({
544                            "type": "output_text",
545                            "text": msg.content.as_text()
546                        }));
547                    } else {
548                        append_assistant_text_to_instructions(
549                            &mut instructions_segments,
550                            &msg.content.as_text(),
551                        );
552                    }
553                }
554
555                if let Some(tool_calls) = &msg.tool_calls {
556                    for call in tool_calls {
557                        if let Some(ref func) = call.function {
558                            let call_kind = responses_tool_call_kind(call);
559                            if active_tool_calls
560                                .insert(call.id.clone(), call_kind)
561                                .is_none()
562                            {
563                                pending_tool_call_order.push(call.id.clone());
564                            }
565                            if include_structured_history_in_input {
566                                let replay_item = match call_kind {
567                                    ResponsesToolCallKind::Function => json!({
568                                        "type": "function_call",
569                                        "call_id": &call.id,
570                                        "name": &func.name,
571                                        "arguments": &func.arguments
572                                    }),
573                                    ResponsesToolCallKind::Custom => json!({
574                                        "type": "custom_tool_call",
575                                        "call_id": &call.id,
576                                        "name": &func.name,
577                                        "input": call.text.as_deref().unwrap_or(&func.arguments)
578                                    }),
579                                };
580                                tool_call_items.push(replay_item);
581                                if let Some(deferred_output) =
582                                    deferred_tool_outputs.remove(&call.id)
583                                {
584                                    active_tool_calls.remove(&call.id);
585                                    tool_call_items.push(json!({
586                                        "type": responses_tool_call_output_type(call_kind),
587                                        "call_id": &call.id,
588                                        "output": deferred_output,
589                                    }));
590                                }
591                            }
592                        }
593                    }
594                }
595
596                if !content_parts.is_empty() {
597                    input.push(assistant_input_item(content_parts, msg.phase));
598                }
599                input.extend(tool_call_items);
600            }
601            MessageRole::Tool => {
602                let tool_call_id = msg.tool_call_id.as_ref().ok_or_else(|| {
603                    let formatted_error = error_display::format_llm_error(
604                        "OpenAI",
605                        "Tool messages must include tool_call_id for Responses API",
606                    );
607                    LLMError::InvalidRequest {
608                        message: formatted_error,
609                        metadata: None,
610                    }
611                })?;
612
613                if !active_tool_calls.contains_key(tool_call_id) {
614                    if include_structured_history_in_input {
615                        deferred_tool_outputs.insert(
616                            tool_call_id.clone(),
617                            function_output_value_from_message_content(&msg.content),
618                        );
619                    }
620                    continue;
621                }
622
623                if !include_structured_history_in_input {
624                    append_tool_result_to_instructions(
625                        &mut instructions_segments,
626                        Some(tool_call_id),
627                        &msg.content,
628                    );
629                    active_tool_calls.remove(tool_call_id);
630                    continue;
631                }
632
633                let call_kind = active_tool_calls
634                    .remove(tool_call_id)
635                    .unwrap_or(ResponsesToolCallKind::Function);
636                input.push(json!({
637                    "type": responses_tool_call_output_type(call_kind),
638                    "call_id": tool_call_id,
639                    "output": function_output_value_from_message_content(&msg.content),
640                }));
641            }
642        }
643    }
644
645    // Responses API requires every tool call item to have a paired output item.
646    // Synthesize any missing outputs so replay cannot
647    // fail on partially paired history.
648    if include_structured_history_in_input {
649        for call_id in pending_tool_call_order {
650            let Some(call_kind) = active_tool_calls.remove(&call_id) else {
651                continue;
652            };
653            input.push(json!({
654                "type": responses_tool_call_output_type(call_kind),
655                "call_id": call_id,
656                "output": "aborted",
657            }));
658        }
659    }
660
661    let instructions = if instructions_segments.is_empty() {
662        None
663    } else {
664        Some(instructions_segments.join("\n\n"))
665    };
666
667    Ok(OpenAIResponsesPayload {
668        input,
669        instructions,
670    })
671}
672
673#[cfg(test)]
674mod tests {
675    use super::{build_standard_responses_payload, parse_responses_payload};
676    use crate::llm::provider::{LLMRequest, Message, ToolCall};
677    use serde_json::{Value, json};
678
679    fn assert_multimodal_tool_result(payload: super::OpenAIResponsesPayload) {
680        let tool_msg = payload
681            .input
682            .iter()
683            .find(|item| item.get("type").and_then(Value::as_str) == Some("function_call_output"))
684            .expect("function_call_output should exist");
685        let tool_result_content = tool_msg
686            .get("output")
687            .and_then(Value::as_array)
688            .expect("function_call_output output should be an array");
689
690        assert_eq!(tool_result_content.len(), 2);
691        assert_eq!(tool_result_content[0]["type"], "input_text");
692        assert_eq!(tool_result_content[0]["text"], "inline image note");
693        assert_eq!(tool_result_content[1]["type"], "input_image");
694        assert_eq!(
695            tool_result_content[1]["image_url"],
696            "data:image/png;base64,abc"
697        );
698    }
699
700    #[test]
701    fn standard_payload_normalizes_stringified_reasoning_details_items() {
702        let request = LLMRequest {
703            model: "gpt-5".to_string(),
704            messages: vec![
705                Message::assistant("answer".to_string()).with_reasoning_details(Some(vec![
706                    json!(r#"{"type":"compaction","id":"cmp_1","encrypted_content":"opaque"}"#),
707                    json!("plain-text"),
708                ])),
709            ],
710            ..Default::default()
711        };
712
713        let payload =
714            build_standard_responses_payload(&request, true).expect("payload should build");
715        assert_eq!(payload.input.len(), 2);
716        assert_eq!(payload.input[0]["type"], "compaction");
717    }
718
719    #[test]
720    fn standard_payload_preserves_multimodal_tool_result_content() {
721        let request = LLMRequest {
722            model: "gpt-5".to_string(),
723            messages: vec![
724                Message::assistant_with_tools(
725                    String::new(),
726                    vec![ToolCall::function(
727                        "call_1".to_string(),
728                        "view_image".to_string(),
729                        "{\"path\":\"./img.png\"}".to_string(),
730                    )],
731                ),
732                Message::tool_response(
733                    "call_1".to_string(),
734                    r#"[{"type":"input_text","text":"inline image note"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]"#
735                        .to_string(),
736                ),
737            ],
738            ..Default::default()
739        };
740
741        let payload =
742            build_standard_responses_payload(&request, true).expect("payload should build");
743        assert_multimodal_tool_result(payload);
744    }
745
746    #[test]
747    fn standard_payload_uses_responses_function_call_items_for_structured_tool_history() {
748        let request = LLMRequest {
749            model: "gpt-5.3-codex".to_string(),
750            messages: vec![
751                Message::user("run cargo fmt".to_string()),
752                Message::assistant_with_tools(
753                    String::new(),
754                    vec![ToolCall::function(
755                        "direct_unified_exec_1".to_string(),
756                        "unified_exec".to_string(),
757                        "{\"command\":\"cargo fmt\"}".to_string(),
758                    )],
759                ),
760                Message::tool_response(
761                    "direct_unified_exec_1".to_string(),
762                    "{\"output\":\"\",\"exit_code\":0,\"backend\":\"pipe\"}".to_string(),
763                ),
764                Message::assistant("cargo fmt completed successfully.".to_string()),
765            ],
766            ..Default::default()
767        };
768
769        let payload =
770            build_standard_responses_payload(&request, true).expect("payload should build");
771
772        assert_eq!(payload.input.len(), 4);
773        assert_eq!(payload.input[0]["role"], "user");
774        assert_eq!(payload.input[1]["type"], "function_call");
775        assert!(payload.input[1].get("id").is_none());
776        assert_eq!(payload.input[1]["call_id"], "direct_unified_exec_1");
777        assert_eq!(payload.input[2]["type"], "function_call_output");
778        assert_eq!(payload.input[2]["call_id"], "direct_unified_exec_1");
779        assert_eq!(
780            payload.input[2]["output"],
781            "{\"output\":\"\",\"exit_code\":0,\"backend\":\"pipe\"}"
782        );
783        assert_eq!(payload.input[3]["role"], "assistant");
784    }
785
786    #[test]
787    fn standard_payload_synthesizes_missing_function_call_output_for_orphan_call() {
788        let request = LLMRequest {
789            model: "gpt-5.3-codex".to_string(),
790            messages: vec![
791                Message::user("run cargo fmt".to_string()),
792                Message::assistant_with_tools(
793                    String::new(),
794                    vec![ToolCall::function(
795                        "call_orphan".to_string(),
796                        "unified_exec".to_string(),
797                        "{\"command\":\"cargo fmt\"}".to_string(),
798                    )],
799                ),
800                Message::user("continue".to_string()),
801            ],
802            ..Default::default()
803        };
804
805        let payload =
806            build_standard_responses_payload(&request, true).expect("payload should build");
807
808        assert!(payload.input.iter().any(|item| {
809            item.get("type").and_then(Value::as_str) == Some("function_call")
810                && item.get("call_id").and_then(Value::as_str) == Some("call_orphan")
811        }));
812        assert!(payload.input.iter().any(|item| {
813            item.get("type").and_then(Value::as_str) == Some("function_call_output")
814                && item.get("call_id").and_then(Value::as_str) == Some("call_orphan")
815                && item.get("output").and_then(Value::as_str) == Some("aborted")
816        }));
817    }
818
819    #[test]
820    fn standard_payload_pairs_deferred_tool_output_when_output_precedes_call() {
821        let request = LLMRequest {
822            model: "gpt-5.3-codex".to_string(),
823            messages: vec![
824                Message::user("continue".to_string()),
825                Message::tool_response("call_1".to_string(), "{\"output\":\"late\"}".to_string()),
826                Message::assistant_with_tools(
827                    String::new(),
828                    vec![ToolCall::function(
829                        "call_1".to_string(),
830                        "unified_exec".to_string(),
831                        "{\"command\":\"echo late\"}".to_string(),
832                    )],
833                ),
834            ],
835            ..Default::default()
836        };
837
838        let payload =
839            build_standard_responses_payload(&request, true).expect("payload should build");
840
841        let call_index = payload
842            .input
843            .iter()
844            .position(|item| {
845                item.get("type").and_then(Value::as_str) == Some("function_call")
846                    && item.get("call_id").and_then(Value::as_str) == Some("call_1")
847            })
848            .expect("function_call should exist");
849        let output_index = payload
850            .input
851            .iter()
852            .position(|item| {
853                item.get("type").and_then(Value::as_str) == Some("function_call_output")
854                    && item.get("call_id").and_then(Value::as_str) == Some("call_1")
855            })
856            .expect("function_call_output should exist");
857
858        assert!(output_index > call_index);
859        assert_eq!(
860            payload.input[output_index]["output"],
861            "{\"output\":\"late\"}"
862        );
863        assert_ne!(payload.input[output_index]["output"], "aborted");
864    }
865
866    #[test]
867    fn standard_payload_omits_function_call_id_for_codex_replay_shape() {
868        let request = LLMRequest {
869            model: "gpt-5.1-codex".to_string(),
870            messages: vec![
871                Message::user("run cargo fmt and report".to_string()),
872                Message::assistant_with_tools(
873                    String::new(),
874                    vec![ToolCall::function(
875                        "call_T4IsdQtJifUHQUXutDlwoFLd".to_string(),
876                        "unified_exec".to_string(),
877                        r#"{"command":"cd /Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode && cargo fmt","workdir":"/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode","sandbox_permissions":"use_default","additional_permissions":{"fs_read":[],"fs_write":[]}}"#.to_string(),
878                    )],
879                ),
880                Message::tool_response(
881                    "call_T4IsdQtJifUHQUXutDlwoFLd".to_string(),
882                    r#"{"output":"","exit_code":0,"backend":"pipe"}"#.to_string(),
883                ),
884                Message::system(
885                    "Previous turn already completed tool execution. Reuse the latest tool outputs in history instead of rerunning the same exploration.".to_string(),
886                ),
887                Message::user("ok".to_string()),
888            ],
889            ..Default::default()
890        };
891
892        let payload =
893            build_standard_responses_payload(&request, true).expect("payload should build");
894        let function_call = payload
895            .input
896            .iter()
897            .find(|item| item.get("type").and_then(Value::as_str) == Some("function_call"))
898            .expect("function_call item should exist");
899
900        assert_eq!(
901            function_call.get("call_id").and_then(Value::as_str),
902            Some("call_T4IsdQtJifUHQUXutDlwoFLd")
903        );
904        assert!(
905            function_call.get("id").is_none(),
906            "function_call replay items should omit id"
907        );
908    }
909
910    #[test]
911    fn parse_responses_payload_prefers_call_id_for_tool_correlation() {
912        let response = json!({
913            "output": [
914                {
915                    "type": "function_call",
916                    "id": "fc_123",
917                    "call_id": "call_123",
918                    "name": "unified_exec",
919                    "arguments": "{\"command\":\"cargo fmt\"}"
920                }
921            ]
922        });
923
924        let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
925            .expect("payload should parse");
926
927        let tool_calls = parsed.tool_calls.expect("tool calls should exist");
928        assert_eq!(tool_calls.len(), 1);
929        assert_eq!(tool_calls[0].id, "call_123");
930        assert_eq!(
931            tool_calls[0]
932                .function
933                .as_ref()
934                .map(|function| function.name.as_str()),
935            Some("unified_exec")
936        );
937    }
938
939    #[test]
940    fn parse_responses_payload_preserves_function_namespace() {
941        let response = json!({
942            "output": [
943                {
944                    "type": "function_call",
945                    "id": "fc_456",
946                    "call_id": "call_456",
947                    "namespace": "repo_browser",
948                    "name": "list_files",
949                    "arguments": "{\"path\":\".\"}"
950                }
951            ]
952        });
953
954        let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
955            .expect("payload should parse");
956
957        let tool_calls = parsed.tool_calls.expect("tool calls should exist");
958        let namespace = tool_calls[0]
959            .function
960            .as_ref()
961            .and_then(|function| function.namespace.as_deref());
962
963        assert_eq!(namespace, Some("repo_browser"));
964    }
965
966    #[test]
967    fn parse_responses_payload_parses_custom_tool_calls() {
968        let response = json!({
969            "output": [
970                {
971                    "type": "custom_tool_call",
972                    "id": "ct_123",
973                    "call_id": "call_patch_1",
974                    "name": "apply_patch",
975                    "input": "*** Begin Patch\n*** End Patch\n"
976                }
977            ]
978        });
979
980        let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
981            .expect("payload should parse");
982
983        let tool_calls = parsed.tool_calls.expect("tool calls should exist");
984        assert_eq!(tool_calls.len(), 1);
985        assert!(tool_calls[0].is_custom());
986        assert_eq!(tool_calls[0].id, "call_patch_1");
987        assert_eq!(tool_calls[0].tool_name(), Some("apply_patch"));
988        assert_eq!(
989            tool_calls[0].raw_input(),
990            Some("*** Begin Patch\n*** End Patch\n")
991        );
992    }
993
994    #[test]
995    fn standard_payload_replays_custom_tool_turns_with_custom_items() {
996        let request = LLMRequest {
997            messages: vec![
998                Message::user("Apply patch".to_string()),
999                Message::assistant_with_tools(
1000                    String::new(),
1001                    vec![ToolCall::custom(
1002                        "call_patch_1".to_string(),
1003                        "apply_patch".to_string(),
1004                        "*** Begin Patch\n*** End Patch\n".to_string(),
1005                    )],
1006                ),
1007                Message::tool_response("call_patch_1".to_string(), "patched".to_string()),
1008            ],
1009            ..Default::default()
1010        };
1011
1012        let payload =
1013            build_standard_responses_payload(&request, true).expect("payload should build");
1014
1015        assert_eq!(payload.input.len(), 3);
1016        assert_eq!(payload.input[1]["type"], "custom_tool_call");
1017        assert_eq!(payload.input[1]["call_id"], "call_patch_1");
1018        assert_eq!(payload.input[1]["name"], "apply_patch");
1019        assert_eq!(
1020            payload.input[1]["input"],
1021            "*** Begin Patch\n*** End Patch\n"
1022        );
1023        assert_eq!(payload.input[2]["type"], "custom_tool_call_output");
1024        assert_eq!(payload.input[2]["call_id"], "call_patch_1");
1025        assert_eq!(payload.input[2]["output"], "patched");
1026    }
1027
1028    #[test]
1029    fn parse_responses_payload_extracts_tool_search_references() {
1030        let response = json!({
1031            "output": [
1032                {
1033                    "type": "tool_search_output",
1034                    "execution": "client",
1035                    "status": "completed",
1036                    "tools": [
1037                        {
1038                            "name": "read_file",
1039                            "description": "Read a file"
1040                        },
1041                        {
1042                            "name": "namespace_group",
1043                            "tools": [
1044                                {
1045                                    "name": "write_file",
1046                                    "description": "Write a file"
1047                                }
1048                            ]
1049                        }
1050                    ]
1051                }
1052            ]
1053        });
1054
1055        let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
1056            .expect("payload should parse");
1057
1058        assert_eq!(
1059            parsed.tool_references,
1060            vec!["read_file".to_string(), "write_file".to_string()]
1061        );
1062    }
1063
1064    #[test]
1065    fn standard_payload_can_move_assistant_text_history_into_instructions() {
1066        let request = LLMRequest {
1067            model: "gpt-5.2-codex".to_string(),
1068            messages: vec![
1069                Message::user("What is this project?".to_string()),
1070                Message::assistant("VT Code is a Rust Cargo workspace.".to_string()),
1071                Message::user("Tell me more.".to_string()),
1072            ],
1073            ..Default::default()
1074        };
1075
1076        let payload =
1077            build_standard_responses_payload(&request, false).expect("payload should build");
1078
1079        assert_eq!(payload.input.len(), 2);
1080        assert_eq!(payload.input[0]["role"], "user");
1081        assert_eq!(payload.input[1]["role"], "user");
1082        assert_eq!(
1083            payload.instructions.as_deref(),
1084            Some("Previous assistant response:\nVT Code is a Rust Cargo workspace.")
1085        );
1086    }
1087
1088    #[test]
1089    fn standard_payload_can_omit_reasoning_details_from_input() {
1090        let request = LLMRequest {
1091            model: "gpt-5.2-codex".to_string(),
1092            messages: vec![
1093                Message::assistant("answer".to_string()).with_reasoning_details(Some(vec![
1094                    json!({
1095                        "type": "reasoning",
1096                        "id": "rs_1",
1097                        "summary": [{"type":"summary_text","text":"opaque"}]
1098                    }),
1099                ])),
1100                Message::user("next".to_string()),
1101            ],
1102            ..Default::default()
1103        };
1104
1105        let payload =
1106            build_standard_responses_payload(&request, false).expect("payload should build");
1107
1108        assert_eq!(payload.input.len(), 1);
1109        assert_eq!(payload.input[0]["role"], "user");
1110    }
1111
1112    #[test]
1113    fn standard_payload_can_move_tool_turn_history_into_instructions() {
1114        let request = LLMRequest {
1115            model: "gpt-5.2-codex".to_string(),
1116            messages: vec![
1117                Message::user("run cargo check".to_string()),
1118                Message::assistant_with_tools(
1119                    String::new(),
1120                    vec![ToolCall::function(
1121                        "call_1".to_string(),
1122                        "unified_exec".to_string(),
1123                        "{\"command\":\"cargo check\"}".to_string(),
1124                    )],
1125                ),
1126                Message::tool_response(
1127                    "call_1".to_string(),
1128                    "{\"output\":\"Finished `dev` profile\",\"exit_code\":0}".to_string(),
1129                ),
1130                Message::assistant("cargo check completed successfully.".to_string()),
1131                Message::user("tell me more".to_string()),
1132            ],
1133            ..Default::default()
1134        };
1135
1136        let payload =
1137            build_standard_responses_payload(&request, false).expect("payload should build");
1138
1139        assert_eq!(payload.input.len(), 2);
1140        assert_eq!(payload.input[0]["role"], "user");
1141        assert_eq!(payload.input[1]["role"], "user");
1142        let instructions = payload.instructions.expect("instructions should exist");
1143        assert!(instructions.contains("Previous tool result (call_1):"));
1144        assert!(instructions.contains("Finished `dev` profile"));
1145        assert!(
1146            instructions
1147                .contains("Previous assistant response:\ncargo check completed successfully.")
1148        );
1149    }
1150
1151    #[test]
1152    fn parse_responses_payload_ignores_hosted_shell_trace_items() {
1153        let response = json!({
1154            "output": [
1155                {
1156                    "type": "shell_call",
1157                    "id": "sh_1",
1158                    "status": "completed",
1159                    "action": { "type": "command", "command": ["pwd"] }
1160                },
1161                {
1162                    "type": "shell_call_output",
1163                    "id": "sho_1",
1164                    "call_id": "sh_1",
1165                    "output": "workspace\n"
1166                },
1167                {
1168                    "type": "message",
1169                    "content": [
1170                        { "type": "output_text", "text": "Done." }
1171                    ]
1172                }
1173            ]
1174        });
1175
1176        let parsed =
1177            parse_responses_payload(response, "gpt-5".to_string(), false).expect("should parse");
1178
1179        assert_eq!(parsed.content.as_deref(), Some("Done."));
1180        assert!(parsed.tool_calls.unwrap_or_default().is_empty());
1181    }
1182}