| 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( |
| Default::default(), |
| None, |
| 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::<String>() |
| .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( 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( 123_456); |
| let raw_output = format!( |
| "HEAD-{}\n{marker}\nTAIL-{}", |
| "a".repeat( 100), |
| "z".repeat( 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( 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( 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); |
|
|
| |
| 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); |
| } |
|
|