use super::*; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::ImageReference; use codex_protocol::models::SearchToolCallParams; use core_test_support::assert_regex_match; use pretty_assertions::assert_eq; use serde_json::json; #[test] fn custom_tool_calls_should_roundtrip_as_custom_outputs() { let payload = ToolPayload::Custom { input: "patch".to_string(), }; let response = FunctionToolOutput::from_text("patched".to_string(), Some(true)) .to_response_item("call-42", &payload); match response { ResponseInputItem::CustomToolCallOutput { call_id, output, .. } => { assert_eq!(call_id, "call-42"); assert_eq!(output.content_items(), None); assert_eq!(output.body.to_text().as_deref(), Some("patched")); assert_eq!(output.success, Some(true)); } other => panic!("expected CustomToolCallOutput, got {other:?}"), } } #[test] fn function_payloads_remain_function_outputs() { let payload = ToolPayload::Function { arguments: "{}".to_string(), }; let response = FunctionToolOutput::from_text("ok".to_string(), Some(true)) .to_response_item("fn-1", &payload); match response { ResponseInputItem::FunctionCallOutput { call_id, output } => { assert_eq!(call_id, "fn-1"); assert_eq!(output.content_items(), None); assert_eq!(output.body.to_text().as_deref(), Some("ok")); assert_eq!(output.success, Some(true)); } other => panic!("expected FunctionCallOutput, got {other:?}"), } } #[test] fn mcp_code_mode_result_omits_private_metadata() { let output = CallToolResult { content: vec![serde_json::json!({ "type": "text", "text": "ignored", })], structured_content: Some(serde_json::json!({ "threadId": "thread_123", "content": "done", })), is_error: Some(false), meta: Some(serde_json::json!({ "source": "mcp", })), }; let result = output.code_mode_result(&ToolPayload::Function { arguments: "{}".to_string(), }); assert_eq!( result, serde_json::json!({ "content": [{ "type": "text", "text": "ignored", }], "structuredContent": { "threadId": "thread_123", "content": "done", }, "isError": false, }) ); assert_eq!(output.meta, Some(serde_json::json!({ "source": "mcp" }))); } #[test] fn mcp_tool_output_response_item_includes_wall_time() { let output = McpToolOutput { result: CallToolResult { content: vec![serde_json::json!({ "type": "text", "text": "done", })], structured_content: None, is_error: Some(false), meta: None, }, tool_input: json!({}), result_metadata_capture_allowed: false, wall_time: std::time::Duration::from_millis(1250), original_image_detail_supported: false, truncation_policy: TruncationPolicy::Bytes(1024), }; let response = output.to_response_item( "mcp-call-1", &ToolPayload::Function { arguments: "{}".to_string(), }, ); assert_eq!( response, ResponseInputItem::FunctionCallOutput { call_id: "mcp-call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ FunctionCallOutputContentItem::InputText { text: "Wall time: 1.2500 seconds\nOutput:".to_string(), }, FunctionCallOutputContentItem::InputText { text: "done".to_string(), }, ]), success: Some(true), }, } ); } #[test] fn mcp_tool_output_response_item_truncates_large_structured_content() { let output = McpToolOutput { result: CallToolResult { content: vec![serde_json::json!({ "type": "text", "text": "ignored when structured content is present", })], structured_content: Some(serde_json::json!({ "items": "large structured value ".repeat(1_000), })), is_error: Some(false), meta: None, }, tool_input: json!({}), result_metadata_capture_allowed: false, wall_time: std::time::Duration::from_millis(1250), original_image_detail_supported: false, truncation_policy: TruncationPolicy::Bytes(128), }; assert_eq!( output.log_output(), format!( "Wall time: 1.2500 seconds\nOutput:\n{}", json!({"items": "large structured value ".repeat(1_000)}) ) ); let response = output.to_response_item( "mcp-call-large", &ToolPayload::Function { arguments: "{}".to_string(), }, ); match response { ResponseInputItem::FunctionCallOutput { call_id, output } => { assert_eq!(call_id, "mcp-call-large"); assert_eq!(output.success, Some(true)); let text = output .body .to_text() .expect("MCP output should serialize as text"); assert!(text.starts_with("Wall time: 1.2500 seconds\nOutput:\n")); assert!(text.contains("chars truncated")); assert!(!text.contains("ignored when structured content is present")); } other => panic!("expected FunctionCallOutput, got {other:?}"), } } #[test] fn mcp_tool_output_response_item_preserves_content_items() { let image_url = "data:image/png;base64,AAA"; let output = McpToolOutput { result: CallToolResult { content: vec![serde_json::json!({ "type": "image", "mimeType": "image/png", "data": "AAA", })], structured_content: None, is_error: Some(false), meta: None, }, tool_input: json!({}), result_metadata_capture_allowed: false, wall_time: std::time::Duration::from_millis(500), original_image_detail_supported: false, truncation_policy: TruncationPolicy::Bytes(1024), }; let response = output.to_response_item( "mcp-call-2", &ToolPayload::Function { arguments: "{}".to_string(), }, ); match response { ResponseInputItem::FunctionCallOutput { output, .. } => { assert_eq!( output.content_items(), Some( vec![ FunctionCallOutputContentItem::InputText { text: "Wall time: 0.5000 seconds\nOutput:".to_string(), }, FunctionCallOutputContentItem::InputImage { image: ImageReference::Inline { image_url: image_url.to_string() }, detail: Some(DEFAULT_IMAGE_DETAIL), }, ] .as_slice() ) ); assert_eq!( output.body.to_text().as_deref(), Some("Wall time: 0.5000 seconds\nOutput:") ); } other => panic!("expected FunctionCallOutput, got {other:?}"), } } #[test_case::test_case(TruncationPolicy::Bytes(64); "byte budget")] #[test_case::test_case(TruncationPolicy::Tokens(1); "token budget")] fn mcp_tool_output_code_mode_result_preserves_content_without_private_metadata( truncation_policy: TruncationPolicy, ) { let large_content = "large structured value ".repeat(1_000); let output = McpToolOutput { result: CallToolResult { content: vec![serde_json::json!({ "type": "text", "text": "ignored", })], structured_content: Some(serde_json::json!({ "content": large_content, })), is_error: Some(false), meta: Some(serde_json::json!({ "hive_dispatch_id": "private-dispatch-id", })), }, tool_input: json!({}), result_metadata_capture_allowed: false, wall_time: std::time::Duration::from_millis(1250), original_image_detail_supported: false, truncation_policy, }; let payload = ToolPayload::Function { arguments: "{}".to_string(), }; let result = output.code_mode_result(&payload); assert_eq!( result, serde_json::json!({ "content": [{ "type": "text", "text": "ignored", }], "structuredContent": { "content": "large structured value ".repeat(1_000), }, "isError": false, }) ); assert_eq!( output.result.meta, Some(serde_json::json!({ "hive_dispatch_id": "private-dispatch-id" })) ); } #[test] fn custom_tool_calls_can_derive_text_from_content_items() { let payload = ToolPayload::Custom { input: "patch".to_string(), }; let response = FunctionToolOutput::from_content( vec![ FunctionCallOutputContentItem::InputText { text: "line 1".to_string(), }, FunctionCallOutputContentItem::InputImage { image: ImageReference::Inline { image_url: "data:image/png;base64,AAA".to_string(), }, detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputText { text: "line 2".to_string(), }, ], Some(true), ) .to_response_item("call-99", &payload); match response { ResponseInputItem::CustomToolCallOutput { call_id, output, .. } => { let expected = vec![ FunctionCallOutputContentItem::InputText { text: "line 1".to_string(), }, FunctionCallOutputContentItem::InputImage { image: ImageReference::Inline { image_url: "data:image/png;base64,AAA".to_string(), }, detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputText { text: "line 2".to_string(), }, ]; assert_eq!(call_id, "call-99"); assert_eq!(output.content_items(), Some(expected.as_slice())); assert_eq!(output.body.to_text().as_deref(), Some("line 1\nline 2")); assert_eq!(output.success, Some(true)); } other => panic!("expected CustomToolCallOutput, got {other:?}"), } } #[test] fn tool_search_payloads_roundtrip_as_tool_search_outputs() { let payload = ToolPayload::ToolSearch { arguments: SearchToolCallParams { query: "calendar".to_string(), limit: None, }, }; let response = ToolSearchOutput { tools: vec![LoadableToolSpec::Function(codex_tools::ResponsesApiTool { name: "create_event".to_string(), description: String::new(), strict: false, defer_loading: Some(true), parameters: codex_tools::JsonSchema::object( /*properties*/ Default::default(), /*required*/ None, /*additional_properties*/ None, ), output_schema: None, })], } .to_response_item("search-1", &payload); match response { ResponseInputItem::ToolSearchOutput { call_id, status, execution, tools, } => { assert_eq!(call_id, "search-1"); assert_eq!(status, "completed"); assert_eq!(execution, "client"); assert_eq!( tools, vec![json!({ "type": "function", "name": "create_event", "description": "", "strict": false, "defer_loading": true, "parameters": { "type": "object", "properties": {} } })] ); } other => panic!("expected ToolSearchOutput, got {other:?}"), } } #[test] fn log_preview_uses_content_items_when_plain_text_is_missing() { let output = FunctionToolOutput::from_content( vec![FunctionCallOutputContentItem::InputText { text: "preview".to_string(), }], Some(true), ); assert_eq!(output.log_output(), "preview"); assert_eq!( function_call_output_content_items_to_text(&output.body), Some("preview".to_string()) ); } #[test] fn exec_command_tool_output_formats_truncated_response() { let payload = ToolPayload::Function { arguments: "{}".to_string(), }; let output = ExecCommandToolOutput { event_call_id: "call-42".to_string(), chunk_id: "abc123".to_string(), wall_time: std::time::Duration::from_millis(1250), raw_output: b"token one token two token three token four token five".to_vec(), truncation_policy: TruncationPolicy::Tokens(10_000), max_output_tokens: Some(4), process_id: None, exit_code: Some(0), original_token_count: Some(10), output_omitted_bytes: None, hook_command: None, }; assert_eq!( output.log_output(), "Chunk ID: abc123\nWall time: 1.2500 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\ntoken one token two token three token four token five" ); let response = output.to_response_item("call-42", &payload); match response { ResponseInputItem::FunctionCallOutput { call_id, output } => { assert_eq!(call_id, "call-42"); assert_eq!(output.success, Some(true)); let text = output .body .to_text() .expect("exec output should serialize as text"); assert_regex_match( r#"(?sx) ^Chunk\ ID:\ abc123 \nWall\ time:\ \d+\.\d{4}\ seconds \nProcess\ exited\ with\ code\ 0 \nOriginal\ token\ count:\ 10 \nOutput: \n.*tokens\ truncated.* $"#, &text, ); } other => panic!("expected FunctionCallOutput, got {other:?}"), } } #[test] fn exec_command_tool_output_reserves_metadata_budget_and_preserves_policy_units() { let payload = ToolPayload::Function { arguments: "{}".to_string(), }; let raw_output = (1..=150) .map(|line| format!("{line}\n")) .collect::() .into_bytes(); for (policy, marker) in [ (TruncationPolicy::Bytes(200), "chars truncated"), (TruncationPolicy::Tokens(50), "tokens truncated"), ] { let response = ExecCommandToolOutput { event_call_id: "call-42".to_string(), chunk_id: "abc123".to_string(), wall_time: std::time::Duration::from_millis(/*millis*/ 1250), raw_output: raw_output.clone(), truncation_policy: policy, max_output_tokens: None, process_id: None, exit_code: Some(0), original_token_count: Some(123), output_omitted_bytes: None, hook_command: None, } .to_response_item("call-42", &payload); let ResponseInputItem::FunctionCallOutput { output, .. } = response else { panic!("expected FunctionCallOutput"); }; let text = output .body .to_text() .expect("exec output should serialize as text"); assert!(text.len() <= (policy * 1.2).byte_budget()); assert_eq!(text.matches(marker).count(), 1); assert!(text.contains("Original token count: 123")); assert!(text.contains("Total output lines: 150")); assert!(text.contains("\n1\n2\n3\n")); assert!(text.ends_with("149\n150\n")); } } #[test] fn exec_command_tool_output_preserves_omission_metadata_when_truncated() { let payload = ToolPayload::Function { arguments: "{}".to_string(), }; let marker = format_output_omission_marker(/*omitted_bytes*/ 123_456); let raw_output = format!( "HEAD-{}\n{marker}\nTAIL-{}", "a".repeat(/*n*/ 100), "z".repeat(/*n*/ 100) ) .into_bytes(); let mut output = ExecCommandToolOutput { event_call_id: "call-omitted".to_string(), chunk_id: "abc123".to_string(), wall_time: std::time::Duration::from_millis(/*millis*/ 1250), raw_output, truncation_policy: TruncationPolicy::Tokens(10_000), max_output_tokens: Some(4), process_id: None, exit_code: Some(0), original_token_count: Some(42_000), output_omitted_bytes: NonZeroUsize::new(/*n*/ 123_456), hook_command: None, }; let expected_header = "Chunk ID: abc123\nWall time: 1.2500 seconds\nProcess exited with code 0\nOriginal token count: 42000\nOutput:\n"; assert_eq!( output.log_output(), format!( "{expected_header}{}", String::from_utf8_lossy(&output.raw_output) ) ); let response = output.to_response_item("call-omitted", &payload); // Collection may report omitted bytes without including the marker in its text. output.raw_output = b"remaining output".to_vec(); assert_eq!( output.log_output(), format!("{expected_header}{marker}\nremaining output") ); let ResponseInputItem::FunctionCallOutput { output, .. } = response else { panic!("expected FunctionCallOutput"); }; let text = output .body .to_text() .expect("exec output should serialize as text"); assert!(text.contains("Original token count: 42000")); assert!(text.contains("Warning: truncated output (original token count: 42000)")); assert_eq!(text.matches(&marker).count(), 1); }