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
494pub 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 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 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}