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