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            iterations: None,
467        }
468    });
469
470    Ok(LLMResponse {
471        content,
472        tool_calls,
473        model,
474        usage,
475        finish_reason,
476        reasoning,
477        reasoning_details,
478        tool_references,
479        request_id: response_json
480            .get("id")
481            .and_then(|value| value.as_str())
482            .map(ToOwned::to_owned)
483            .or_else(|| {
484                response_json
485                    .get("request_id")
486                    .and_then(|value| value.as_str())
487                    .map(ToOwned::to_owned)
488            }),
489        organization_id: None,
490        compaction: None,
491    })
492}
493
494/// Build a standard (non-Codex) Responses API payload.
495pub fn build_standard_responses_payload(
496    request: &LLMRequest,
497    include_structured_history_in_input: bool,
498) -> Result<OpenAIResponsesPayload, LLMError> {
499    let mut input = Vec::new();
500    let mut active_tool_calls: HashMap<String, ResponsesToolCallKind> = HashMap::new();
501    let mut pending_tool_call_order: Vec<String> = Vec::new();
502    let mut deferred_tool_outputs: HashMap<String, Value> = HashMap::new();
503    let mut instructions_segments = Vec::new();
504
505    if let Some(system_prompt) = &request.system_prompt {
506        let trimmed = system_prompt.trim();
507        if !trimmed.is_empty() {
508            instructions_segments.push(trimmed.to_string());
509        }
510    }
511
512    for msg in &request.messages {
513        match msg.role {
514            MessageRole::System => {
515                let content_text = msg.content.as_text();
516                let trimmed = content_text.trim();
517                if !trimmed.is_empty() {
518                    instructions_segments.push(trimmed.to_string());
519                }
520            }
521            MessageRole::User => {
522                let mut content_parts: Vec<Value> = Vec::new();
523                append_user_content_parts(&mut content_parts, &msg.content);
524
525                if !content_parts.is_empty() {
526                    input.push(json!({
527                        "role": "user",
528                        "content": content_parts
529                    }));
530                }
531            }
532            MessageRole::Assistant => {
533                // Inject any persisted reasoning items from previous turns
534                if include_structured_history_in_input
535                    && let Some(reasoning_details) = &msg.reasoning_details
536                {
537                    append_normalized_reasoning_detail_items(&mut input, reasoning_details);
538                }
539
540                let mut content_parts = Vec::new();
541                let mut tool_call_items = Vec::new();
542                if !msg.content.is_empty() {
543                    if include_structured_history_in_input {
544                        content_parts.push(json!({
545                            "type": "output_text",
546                            "text": msg.content.as_text()
547                        }));
548                    } else {
549                        append_assistant_text_to_instructions(
550                            &mut instructions_segments,
551                            &msg.content.as_text(),
552                        );
553                    }
554                }
555
556                if let Some(tool_calls) = &msg.tool_calls {
557                    for call in tool_calls {
558                        if let Some(ref func) = call.function {
559                            let call_kind = responses_tool_call_kind(call);
560                            if active_tool_calls
561                                .insert(call.id.clone(), call_kind)
562                                .is_none()
563                            {
564                                pending_tool_call_order.push(call.id.clone());
565                            }
566                            if include_structured_history_in_input {
567                                let replay_item = match call_kind {
568                                    ResponsesToolCallKind::Function => json!({
569                                        "type": "function_call",
570                                        "call_id": &call.id,
571                                        "name": &func.name,
572                                        "arguments": &func.arguments
573                                    }),
574                                    ResponsesToolCallKind::Custom => json!({
575                                        "type": "custom_tool_call",
576                                        "call_id": &call.id,
577                                        "name": &func.name,
578                                        "input": call.text.as_deref().unwrap_or(&func.arguments)
579                                    }),
580                                };
581                                tool_call_items.push(replay_item);
582                                if let Some(deferred_output) =
583                                    deferred_tool_outputs.remove(&call.id)
584                                {
585                                    active_tool_calls.remove(&call.id);
586                                    tool_call_items.push(json!({
587                                        "type": responses_tool_call_output_type(call_kind),
588                                        "call_id": &call.id,
589                                        "output": deferred_output,
590                                    }));
591                                }
592                            }
593                        }
594                    }
595                }
596
597                if !content_parts.is_empty() {
598                    input.push(assistant_input_item(content_parts, msg.phase));
599                }
600                input.extend(tool_call_items);
601            }
602            MessageRole::Tool => {
603                let tool_call_id = msg.tool_call_id.as_ref().ok_or_else(|| {
604                    let formatted_error = error_display::format_llm_error(
605                        "OpenAI",
606                        "Tool messages must include tool_call_id for Responses API",
607                    );
608                    LLMError::InvalidRequest {
609                        message: formatted_error,
610                        metadata: None,
611                    }
612                })?;
613
614                if !active_tool_calls.contains_key(tool_call_id) {
615                    if include_structured_history_in_input {
616                        deferred_tool_outputs.insert(
617                            tool_call_id.clone(),
618                            function_output_value_from_message_content(&msg.content),
619                        );
620                    }
621                    continue;
622                }
623
624                if !include_structured_history_in_input {
625                    append_tool_result_to_instructions(
626                        &mut instructions_segments,
627                        Some(tool_call_id),
628                        &msg.content,
629                    );
630                    active_tool_calls.remove(tool_call_id);
631                    continue;
632                }
633
634                let call_kind = active_tool_calls
635                    .remove(tool_call_id)
636                    .unwrap_or(ResponsesToolCallKind::Function);
637                input.push(json!({
638                    "type": responses_tool_call_output_type(call_kind),
639                    "call_id": tool_call_id,
640                    "output": function_output_value_from_message_content(&msg.content),
641                }));
642            }
643        }
644    }
645
646    // Responses API requires every tool call item to have a paired output item.
647    // Synthesize any missing outputs so replay cannot
648    // fail on partially paired history.
649    if include_structured_history_in_input {
650        for call_id in pending_tool_call_order {
651            let Some(call_kind) = active_tool_calls.remove(&call_id) else {
652                continue;
653            };
654            input.push(json!({
655                "type": responses_tool_call_output_type(call_kind),
656                "call_id": call_id,
657                "output": "aborted",
658            }));
659        }
660    }
661
662    let instructions = if instructions_segments.is_empty() {
663        None
664    } else {
665        Some(instructions_segments.join("\n\n"))
666    };
667
668    Ok(OpenAIResponsesPayload {
669        input,
670        instructions,
671    })
672}
673
674#[cfg(test)]
675mod tests {
676    use super::{build_standard_responses_payload, parse_responses_payload};
677    use crate::llm::provider::{LLMRequest, Message, ToolCall};
678    use serde_json::{Value, json};
679
680    fn assert_multimodal_tool_result(payload: super::OpenAIResponsesPayload) {
681        let tool_msg = payload
682            .input
683            .iter()
684            .find(|item| item.get("type").and_then(Value::as_str) == Some("function_call_output"))
685            .expect("function_call_output should exist");
686        let tool_result_content = tool_msg
687            .get("output")
688            .and_then(Value::as_array)
689            .expect("function_call_output output should be an array");
690
691        assert_eq!(tool_result_content.len(), 2);
692        assert_eq!(tool_result_content[0]["type"], "input_text");
693        assert_eq!(tool_result_content[0]["text"], "inline image note");
694        assert_eq!(tool_result_content[1]["type"], "input_image");
695        assert_eq!(
696            tool_result_content[1]["image_url"],
697            "data:image/png;base64,abc"
698        );
699    }
700
701    #[test]
702    fn standard_payload_normalizes_stringified_reasoning_details_items() {
703        let request = LLMRequest {
704            model: "gpt-5".to_string(),
705            messages: vec![
706                Message::assistant("answer".to_string()).with_reasoning_details(Some(vec![
707                    json!(r#"{"type":"compaction","id":"cmp_1","encrypted_content":"opaque"}"#),
708                    json!("plain-text"),
709                ])),
710            ],
711            ..Default::default()
712        };
713
714        let payload =
715            build_standard_responses_payload(&request, true).expect("payload should build");
716        assert_eq!(payload.input.len(), 2);
717        assert_eq!(payload.input[0]["type"], "compaction");
718    }
719
720    #[test]
721    fn standard_payload_preserves_multimodal_tool_result_content() {
722        let request = LLMRequest {
723            model: "gpt-5".to_string(),
724            messages: vec![
725                Message::assistant_with_tools(
726                    String::new(),
727                    vec![ToolCall::function(
728                        "call_1".to_string(),
729                        "view_image".to_string(),
730                        "{\"path\":\"./img.png\"}".to_string(),
731                    )],
732                ),
733                Message::tool_response(
734                    "call_1".to_string(),
735                    r#"[{"type":"input_text","text":"inline image note"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]"#
736                        .to_string(),
737                ),
738            ],
739            ..Default::default()
740        };
741
742        let payload =
743            build_standard_responses_payload(&request, true).expect("payload should build");
744        assert_multimodal_tool_result(payload);
745    }
746
747    #[test]
748    fn standard_payload_uses_responses_function_call_items_for_structured_tool_history() {
749        let request = LLMRequest {
750            model: "gpt-5.3-codex".to_string(),
751            messages: vec![
752                Message::user("run cargo fmt".to_string()),
753                Message::assistant_with_tools(
754                    String::new(),
755                    vec![ToolCall::function(
756                        "direct_unified_exec_1".to_string(),
757                        "unified_exec".to_string(),
758                        "{\"command\":\"cargo fmt\"}".to_string(),
759                    )],
760                ),
761                Message::tool_response(
762                    "direct_unified_exec_1".to_string(),
763                    "{\"output\":\"\",\"exit_code\":0,\"backend\":\"pipe\"}".to_string(),
764                ),
765                Message::assistant("cargo fmt completed successfully.".to_string()),
766            ],
767            ..Default::default()
768        };
769
770        let payload =
771            build_standard_responses_payload(&request, true).expect("payload should build");
772
773        assert_eq!(payload.input.len(), 4);
774        assert_eq!(payload.input[0]["role"], "user");
775        assert_eq!(payload.input[1]["type"], "function_call");
776        assert!(payload.input[1].get("id").is_none());
777        assert_eq!(payload.input[1]["call_id"], "direct_unified_exec_1");
778        assert_eq!(payload.input[2]["type"], "function_call_output");
779        assert_eq!(payload.input[2]["call_id"], "direct_unified_exec_1");
780        assert_eq!(
781            payload.input[2]["output"],
782            "{\"output\":\"\",\"exit_code\":0,\"backend\":\"pipe\"}"
783        );
784        assert_eq!(payload.input[3]["role"], "assistant");
785    }
786
787    #[test]
788    fn standard_payload_synthesizes_missing_function_call_output_for_orphan_call() {
789        let request = LLMRequest {
790            model: "gpt-5.3-codex".to_string(),
791            messages: vec![
792                Message::user("run cargo fmt".to_string()),
793                Message::assistant_with_tools(
794                    String::new(),
795                    vec![ToolCall::function(
796                        "call_orphan".to_string(),
797                        "unified_exec".to_string(),
798                        "{\"command\":\"cargo fmt\"}".to_string(),
799                    )],
800                ),
801                Message::user("continue".to_string()),
802            ],
803            ..Default::default()
804        };
805
806        let payload =
807            build_standard_responses_payload(&request, true).expect("payload should build");
808
809        assert!(payload.input.iter().any(|item| {
810            item.get("type").and_then(Value::as_str) == Some("function_call")
811                && item.get("call_id").and_then(Value::as_str) == Some("call_orphan")
812        }));
813        assert!(payload.input.iter().any(|item| {
814            item.get("type").and_then(Value::as_str) == Some("function_call_output")
815                && item.get("call_id").and_then(Value::as_str) == Some("call_orphan")
816                && item.get("output").and_then(Value::as_str) == Some("aborted")
817        }));
818    }
819
820    #[test]
821    fn standard_payload_pairs_deferred_tool_output_when_output_precedes_call() {
822        let request = LLMRequest {
823            model: "gpt-5.3-codex".to_string(),
824            messages: vec![
825                Message::user("continue".to_string()),
826                Message::tool_response("call_1".to_string(), "{\"output\":\"late\"}".to_string()),
827                Message::assistant_with_tools(
828                    String::new(),
829                    vec![ToolCall::function(
830                        "call_1".to_string(),
831                        "unified_exec".to_string(),
832                        "{\"command\":\"echo late\"}".to_string(),
833                    )],
834                ),
835            ],
836            ..Default::default()
837        };
838
839        let payload =
840            build_standard_responses_payload(&request, true).expect("payload should build");
841
842        let call_index = payload
843            .input
844            .iter()
845            .position(|item| {
846                item.get("type").and_then(Value::as_str) == Some("function_call")
847                    && item.get("call_id").and_then(Value::as_str) == Some("call_1")
848            })
849            .expect("function_call should exist");
850        let output_index = payload
851            .input
852            .iter()
853            .position(|item| {
854                item.get("type").and_then(Value::as_str) == Some("function_call_output")
855                    && item.get("call_id").and_then(Value::as_str) == Some("call_1")
856            })
857            .expect("function_call_output should exist");
858
859        assert!(output_index > call_index);
860        assert_eq!(
861            payload.input[output_index]["output"],
862            "{\"output\":\"late\"}"
863        );
864        assert_ne!(payload.input[output_index]["output"], "aborted");
865    }
866
867    #[test]
868    fn standard_payload_omits_function_call_id_for_codex_replay_shape() {
869        let request = LLMRequest {
870            model: "gpt-5.1-codex".to_string(),
871            messages: vec![
872                Message::user("run cargo fmt and report".to_string()),
873                Message::assistant_with_tools(
874                    String::new(),
875                    vec![ToolCall::function(
876                        "call_T4IsdQtJifUHQUXutDlwoFLd".to_string(),
877                        "unified_exec".to_string(),
878                        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(),
879                    )],
880                ),
881                Message::tool_response(
882                    "call_T4IsdQtJifUHQUXutDlwoFLd".to_string(),
883                    r#"{"output":"","exit_code":0,"backend":"pipe"}"#.to_string(),
884                ),
885                Message::system(
886                    "Previous turn already completed tool execution. Reuse the latest tool outputs in history instead of rerunning the same exploration.".to_string(),
887                ),
888                Message::user("ok".to_string()),
889            ],
890            ..Default::default()
891        };
892
893        let payload =
894            build_standard_responses_payload(&request, true).expect("payload should build");
895        let function_call = payload
896            .input
897            .iter()
898            .find(|item| item.get("type").and_then(Value::as_str) == Some("function_call"))
899            .expect("function_call item should exist");
900
901        assert_eq!(
902            function_call.get("call_id").and_then(Value::as_str),
903            Some("call_T4IsdQtJifUHQUXutDlwoFLd")
904        );
905        assert!(
906            function_call.get("id").is_none(),
907            "function_call replay items should omit id"
908        );
909    }
910
911    #[test]
912    fn parse_responses_payload_prefers_call_id_for_tool_correlation() {
913        let response = json!({
914            "output": [
915                {
916                    "type": "function_call",
917                    "id": "fc_123",
918                    "call_id": "call_123",
919                    "name": "unified_exec",
920                    "arguments": "{\"command\":\"cargo fmt\"}"
921                }
922            ]
923        });
924
925        let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
926            .expect("payload should parse");
927
928        let tool_calls = parsed.tool_calls.expect("tool calls should exist");
929        assert_eq!(tool_calls.len(), 1);
930        assert_eq!(tool_calls[0].id, "call_123");
931        assert_eq!(
932            tool_calls[0]
933                .function
934                .as_ref()
935                .map(|function| function.name.as_str()),
936            Some("unified_exec")
937        );
938    }
939
940    #[test]
941    fn parse_responses_payload_preserves_function_namespace() {
942        let response = json!({
943            "output": [
944                {
945                    "type": "function_call",
946                    "id": "fc_456",
947                    "call_id": "call_456",
948                    "namespace": "repo_browser",
949                    "name": "list_files",
950                    "arguments": "{\"path\":\".\"}"
951                }
952            ]
953        });
954
955        let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
956            .expect("payload should parse");
957
958        let tool_calls = parsed.tool_calls.expect("tool calls should exist");
959        let namespace = tool_calls[0]
960            .function
961            .as_ref()
962            .and_then(|function| function.namespace.as_deref());
963
964        assert_eq!(namespace, Some("repo_browser"));
965    }
966
967    #[test]
968    fn parse_responses_payload_parses_custom_tool_calls() {
969        let response = json!({
970            "output": [
971                {
972                    "type": "custom_tool_call",
973                    "id": "ct_123",
974                    "call_id": "call_patch_1",
975                    "name": "apply_patch",
976                    "input": "*** Begin Patch\n*** End Patch\n"
977                }
978            ]
979        });
980
981        let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
982            .expect("payload should parse");
983
984        let tool_calls = parsed.tool_calls.expect("tool calls should exist");
985        assert_eq!(tool_calls.len(), 1);
986        assert!(tool_calls[0].is_custom());
987        assert_eq!(tool_calls[0].id, "call_patch_1");
988        assert_eq!(tool_calls[0].tool_name(), Some("apply_patch"));
989        assert_eq!(
990            tool_calls[0].raw_input(),
991            Some("*** Begin Patch\n*** End Patch\n")
992        );
993    }
994
995    #[test]
996    fn standard_payload_replays_custom_tool_turns_with_custom_items() {
997        let request = LLMRequest {
998            messages: vec![
999                Message::user("Apply patch".to_string()),
1000                Message::assistant_with_tools(
1001                    String::new(),
1002                    vec![ToolCall::custom(
1003                        "call_patch_1".to_string(),
1004                        "apply_patch".to_string(),
1005                        "*** Begin Patch\n*** End Patch\n".to_string(),
1006                    )],
1007                ),
1008                Message::tool_response("call_patch_1".to_string(), "patched".to_string()),
1009            ],
1010            ..Default::default()
1011        };
1012
1013        let payload =
1014            build_standard_responses_payload(&request, true).expect("payload should build");
1015
1016        assert_eq!(payload.input.len(), 3);
1017        assert_eq!(payload.input[1]["type"], "custom_tool_call");
1018        assert_eq!(payload.input[1]["call_id"], "call_patch_1");
1019        assert_eq!(payload.input[1]["name"], "apply_patch");
1020        assert_eq!(
1021            payload.input[1]["input"],
1022            "*** Begin Patch\n*** End Patch\n"
1023        );
1024        assert_eq!(payload.input[2]["type"], "custom_tool_call_output");
1025        assert_eq!(payload.input[2]["call_id"], "call_patch_1");
1026        assert_eq!(payload.input[2]["output"], "patched");
1027    }
1028
1029    #[test]
1030    fn parse_responses_payload_extracts_tool_search_references() {
1031        let response = json!({
1032            "output": [
1033                {
1034                    "type": "tool_search_output",
1035                    "execution": "client",
1036                    "status": "completed",
1037                    "tools": [
1038                        {
1039                            "name": "read_file",
1040                            "description": "Read a file"
1041                        },
1042                        {
1043                            "name": "namespace_group",
1044                            "tools": [
1045                                {
1046                                    "name": "write_file",
1047                                    "description": "Write a file"
1048                                }
1049                            ]
1050                        }
1051                    ]
1052                }
1053            ]
1054        });
1055
1056        let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
1057            .expect("payload should parse");
1058
1059        assert_eq!(
1060            parsed.tool_references,
1061            vec!["read_file".to_string(), "write_file".to_string()]
1062        );
1063    }
1064
1065    #[test]
1066    fn standard_payload_can_move_assistant_text_history_into_instructions() {
1067        let request = LLMRequest {
1068            model: "gpt-5.2-codex".to_string(),
1069            messages: vec![
1070                Message::user("What is this project?".to_string()),
1071                Message::assistant("VT Code is a Rust Cargo workspace.".to_string()),
1072                Message::user("Tell me more.".to_string()),
1073            ],
1074            ..Default::default()
1075        };
1076
1077        let payload =
1078            build_standard_responses_payload(&request, false).expect("payload should build");
1079
1080        assert_eq!(payload.input.len(), 2);
1081        assert_eq!(payload.input[0]["role"], "user");
1082        assert_eq!(payload.input[1]["role"], "user");
1083        assert_eq!(
1084            payload.instructions.as_deref(),
1085            Some("Previous assistant response:\nVT Code is a Rust Cargo workspace.")
1086        );
1087    }
1088
1089    #[test]
1090    fn standard_payload_can_omit_reasoning_details_from_input() {
1091        let request = LLMRequest {
1092            model: "gpt-5.2-codex".to_string(),
1093            messages: vec![
1094                Message::assistant("answer".to_string()).with_reasoning_details(Some(vec![
1095                    json!({
1096                        "type": "reasoning",
1097                        "id": "rs_1",
1098                        "summary": [{"type":"summary_text","text":"opaque"}]
1099                    }),
1100                ])),
1101                Message::user("next".to_string()),
1102            ],
1103            ..Default::default()
1104        };
1105
1106        let payload =
1107            build_standard_responses_payload(&request, false).expect("payload should build");
1108
1109        assert_eq!(payload.input.len(), 1);
1110        assert_eq!(payload.input[0]["role"], "user");
1111    }
1112
1113    #[test]
1114    fn standard_payload_can_move_tool_turn_history_into_instructions() {
1115        let request = LLMRequest {
1116            model: "gpt-5.2-codex".to_string(),
1117            messages: vec![
1118                Message::user("run cargo check".to_string()),
1119                Message::assistant_with_tools(
1120                    String::new(),
1121                    vec![ToolCall::function(
1122                        "call_1".to_string(),
1123                        "unified_exec".to_string(),
1124                        "{\"command\":\"cargo check\"}".to_string(),
1125                    )],
1126                ),
1127                Message::tool_response(
1128                    "call_1".to_string(),
1129                    "{\"output\":\"Finished `dev` profile\",\"exit_code\":0}".to_string(),
1130                ),
1131                Message::assistant("cargo check completed successfully.".to_string()),
1132                Message::user("tell me more".to_string()),
1133            ],
1134            ..Default::default()
1135        };
1136
1137        let payload =
1138            build_standard_responses_payload(&request, false).expect("payload should build");
1139
1140        assert_eq!(payload.input.len(), 2);
1141        assert_eq!(payload.input[0]["role"], "user");
1142        assert_eq!(payload.input[1]["role"], "user");
1143        let instructions = payload.instructions.expect("instructions should exist");
1144        assert!(instructions.contains("Previous tool result (call_1):"));
1145        assert!(instructions.contains("Finished `dev` profile"));
1146        assert!(
1147            instructions
1148                .contains("Previous assistant response:\ncargo check completed successfully.")
1149        );
1150    }
1151
1152    #[test]
1153    fn parse_responses_payload_ignores_hosted_shell_trace_items() {
1154        let response = json!({
1155            "output": [
1156                {
1157                    "type": "shell_call",
1158                    "id": "sh_1",
1159                    "status": "completed",
1160                    "action": { "type": "command", "command": ["pwd"] }
1161                },
1162                {
1163                    "type": "shell_call_output",
1164                    "id": "sho_1",
1165                    "call_id": "sh_1",
1166                    "output": "workspace\n"
1167                },
1168                {
1169                    "type": "message",
1170                    "content": [
1171                        { "type": "output_text", "text": "Done." }
1172                    ]
1173                }
1174            ]
1175        });
1176
1177        let parsed =
1178            parse_responses_payload(response, "gpt-5".to_string(), false).expect("should parse");
1179
1180        assert_eq!(parsed.content.as_deref(), Some("Done."));
1181        assert!(parsed.tool_calls.unwrap_or_default().is_empty());
1182    }
1183}