codex_protocol/
models.rs

1use std::collections::HashMap;
2
3use base64::Engine;
4use codex_utils_image::load_and_resize_to_fit;
5use mcp_types::CallToolResult;
6use mcp_types::ContentBlock;
7use serde::Deserialize;
8use serde::Deserializer;
9use serde::Serialize;
10use serde::ser::Serializer;
11use ts_rs::TS;
12
13use crate::user_input::UserInput;
14use codex_git::GhostCommit;
15use codex_utils_image::error::ImageProcessingError;
16use schemars::JsonSchema;
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum ResponseInputItem {
21    Message {
22        role: String,
23        content: Vec<ContentItem>,
24    },
25    FunctionCallOutput {
26        call_id: String,
27        output: FunctionCallOutputPayload,
28    },
29    McpToolCallOutput {
30        call_id: String,
31        result: Result<CallToolResult, String>,
32    },
33    CustomToolCallOutput {
34        call_id: String,
35        output: String,
36    },
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum ContentItem {
42    InputText { text: String },
43    InputImage { image_url: String },
44    OutputText { text: String },
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum ResponseItem {
50    Message {
51        #[serde(default, skip_serializing)]
52        #[ts(skip)]
53        id: Option<String>,
54        role: String,
55        content: Vec<ContentItem>,
56    },
57    Reasoning {
58        #[serde(default, skip_serializing)]
59        #[ts(skip)]
60        id: String,
61        summary: Vec<ReasoningItemReasoningSummary>,
62        #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
63        #[ts(optional)]
64        content: Option<Vec<ReasoningItemContent>>,
65        encrypted_content: Option<String>,
66    },
67    LocalShellCall {
68        /// Set when using the chat completions API.
69        #[serde(default, skip_serializing)]
70        #[ts(skip)]
71        id: Option<String>,
72        /// Set when using the Responses API.
73        call_id: Option<String>,
74        status: LocalShellStatus,
75        action: LocalShellAction,
76    },
77    FunctionCall {
78        #[serde(default, skip_serializing)]
79        #[ts(skip)]
80        id: Option<String>,
81        name: String,
82        // The Responses API returns the function call arguments as a *string* that contains
83        // JSON, not as an already‑parsed object. We keep it as a raw string here and let
84        // Session::handle_function_call parse it into a Value. This exactly matches the
85        // Chat Completions + Responses API behavior.
86        arguments: String,
87        call_id: String,
88    },
89    // NOTE: The input schema for `function_call_output` objects that clients send to the
90    // OpenAI /v1/responses endpoint is NOT the same shape as the objects the server returns on the
91    // SSE stream. When *sending* we must wrap the string output inside an object that includes a
92    // required `success` boolean. To ensure we serialize exactly the expected shape we introduce
93    // a dedicated payload struct and flatten it here.
94    FunctionCallOutput {
95        call_id: String,
96        output: FunctionCallOutputPayload,
97    },
98    CustomToolCall {
99        #[serde(default, skip_serializing)]
100        #[ts(skip)]
101        id: Option<String>,
102        #[serde(default, skip_serializing_if = "Option::is_none")]
103        #[ts(optional)]
104        status: Option<String>,
105
106        call_id: String,
107        name: String,
108        input: String,
109    },
110    CustomToolCallOutput {
111        call_id: String,
112        output: String,
113    },
114    // Emitted by the Responses API when the agent triggers a web search.
115    // Example payload (from SSE `response.output_item.done`):
116    // {
117    //   "id":"ws_...",
118    //   "type":"web_search_call",
119    //   "status":"completed",
120    //   "action": {"type":"search","query":"weather: San Francisco, CA"}
121    // }
122    WebSearchCall {
123        #[serde(default, skip_serializing)]
124        #[ts(skip)]
125        id: Option<String>,
126        #[serde(default, skip_serializing_if = "Option::is_none")]
127        #[ts(optional)]
128        status: Option<String>,
129        action: WebSearchAction,
130    },
131    // Generated by the harness but considered exactly as a model response.
132    GhostSnapshot {
133        ghost_commit: GhostCommit,
134    },
135    CompactionSummary {
136        encrypted_content: String,
137    },
138    #[serde(other)]
139    Other,
140}
141
142fn should_serialize_reasoning_content(content: &Option<Vec<ReasoningItemContent>>) -> bool {
143    match content {
144        Some(content) => !content
145            .iter()
146            .any(|c| matches!(c, ReasoningItemContent::ReasoningText { .. })),
147        None => false,
148    }
149}
150
151fn local_image_error_placeholder(
152    path: &std::path::Path,
153    error: impl std::fmt::Display,
154) -> ContentItem {
155    ContentItem::InputText {
156        text: format!(
157            "Codex could not read the local image at `{}`: {}",
158            path.display(),
159            error
160        ),
161    }
162}
163
164fn invalid_image_error_placeholder(
165    path: &std::path::Path,
166    error: impl std::fmt::Display,
167) -> ContentItem {
168    ContentItem::InputText {
169        text: format!(
170            "Image located at `{}` is invalid: {}",
171            path.display(),
172            error
173        ),
174    }
175}
176
177impl From<ResponseInputItem> for ResponseItem {
178    fn from(item: ResponseInputItem) -> Self {
179        match item {
180            ResponseInputItem::Message { role, content } => Self::Message {
181                role,
182                content,
183                id: None,
184            },
185            ResponseInputItem::FunctionCallOutput { call_id, output } => {
186                Self::FunctionCallOutput { call_id, output }
187            }
188            ResponseInputItem::McpToolCallOutput { call_id, result } => {
189                let output = match result {
190                    Ok(result) => FunctionCallOutputPayload::from(&result),
191                    Err(tool_call_err) => FunctionCallOutputPayload {
192                        content: format!("err: {tool_call_err:?}"),
193                        success: Some(false),
194                        ..Default::default()
195                    },
196                };
197                Self::FunctionCallOutput { call_id, output }
198            }
199            ResponseInputItem::CustomToolCallOutput { call_id, output } => {
200                Self::CustomToolCallOutput { call_id, output }
201            }
202        }
203    }
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
207#[serde(rename_all = "snake_case")]
208pub enum LocalShellStatus {
209    Completed,
210    InProgress,
211    Incomplete,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
215#[serde(tag = "type", rename_all = "snake_case")]
216pub enum LocalShellAction {
217    Exec(LocalShellExecAction),
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
221pub struct LocalShellExecAction {
222    pub command: Vec<String>,
223    pub timeout_ms: Option<u64>,
224    pub working_directory: Option<String>,
225    pub env: Option<HashMap<String, String>>,
226    pub user: Option<String>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
230#[serde(tag = "type", rename_all = "snake_case")]
231pub enum WebSearchAction {
232    Search {
233        #[serde(default, skip_serializing_if = "Option::is_none")]
234        #[ts(optional)]
235        query: Option<String>,
236    },
237    OpenPage {
238        #[serde(default, skip_serializing_if = "Option::is_none")]
239        #[ts(optional)]
240        url: Option<String>,
241    },
242    FindInPage {
243        #[serde(default, skip_serializing_if = "Option::is_none")]
244        #[ts(optional)]
245        url: Option<String>,
246        #[serde(default, skip_serializing_if = "Option::is_none")]
247        #[ts(optional)]
248        pattern: Option<String>,
249    },
250
251    #[serde(other)]
252    Other,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
256#[serde(tag = "type", rename_all = "snake_case")]
257pub enum ReasoningItemReasoningSummary {
258    SummaryText { text: String },
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
262#[serde(tag = "type", rename_all = "snake_case")]
263pub enum ReasoningItemContent {
264    ReasoningText { text: String },
265    Text { text: String },
266}
267
268impl From<Vec<UserInput>> for ResponseInputItem {
269    fn from(items: Vec<UserInput>) -> Self {
270        Self::Message {
271            role: "user".to_string(),
272            content: items
273                .into_iter()
274                .map(|c| match c {
275                    UserInput::Text { text } => ContentItem::InputText { text },
276                    UserInput::Image { image_url } => ContentItem::InputImage { image_url },
277                    UserInput::LocalImage { path } => match load_and_resize_to_fit(&path) {
278                        Ok(image) => ContentItem::InputImage {
279                            image_url: image.into_data_url(),
280                        },
281                        Err(err) => {
282                            if matches!(&err, ImageProcessingError::Read { .. }) {
283                                local_image_error_placeholder(&path, &err)
284                            } else if err.is_invalid_image() {
285                                invalid_image_error_placeholder(&path, &err)
286                            } else {
287                                match std::fs::read(&path) {
288                                    Ok(bytes) => {
289                                        let Some(mime_guess) = mime_guess::from_path(&path).first()
290                                        else {
291                                            return local_image_error_placeholder(
292                                                &path,
293                                                "unsupported MIME type (unknown)",
294                                            );
295                                        };
296                                        let mime = mime_guess.essence_str().to_owned();
297                                        if !mime.starts_with("image/") {
298                                            return local_image_error_placeholder(
299                                                &path,
300                                                format!("unsupported MIME type `{mime}`"),
301                                            );
302                                        }
303                                        let encoded =
304                                            base64::engine::general_purpose::STANDARD.encode(bytes);
305                                        ContentItem::InputImage {
306                                            image_url: format!("data:{mime};base64,{encoded}"),
307                                        }
308                                    }
309                                    Err(read_err) => {
310                                        tracing::warn!(
311                                            "Skipping image {} – could not read file: {}",
312                                            path.display(),
313                                            read_err
314                                        );
315                                        local_image_error_placeholder(&path, &read_err)
316                                    }
317                                }
318                            }
319                        }
320                    },
321                })
322                .collect::<Vec<ContentItem>>(),
323        }
324    }
325}
326
327/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
328/// or `shell`, the `arguments` field should deserialize to this struct.
329#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
330pub struct ShellToolCallParams {
331    pub command: Vec<String>,
332    pub workdir: Option<String>,
333
334    /// This is the maximum time in milliseconds that the command is allowed to run.
335    #[serde(alias = "timeout")]
336    pub timeout_ms: Option<u64>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub with_escalated_permissions: Option<bool>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub justification: Option<String>,
341}
342
343/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the
344/// `arguments` field should deserialize to this struct.
345#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
346pub struct ShellCommandToolCallParams {
347    pub command: String,
348    pub workdir: Option<String>,
349
350    /// This is the maximum time in milliseconds that the command is allowed to run.
351    #[serde(alias = "timeout")]
352    pub timeout_ms: Option<u64>,
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub with_escalated_permissions: Option<bool>,
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub justification: Option<String>,
357}
358
359/// Responses API compatible content items that can be returned by a tool call.
360/// This is a subset of ContentItem with the types we support as function call outputs.
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
362#[serde(tag = "type", rename_all = "snake_case")]
363pub enum FunctionCallOutputContentItem {
364    // Do not rename, these are serialized and used directly in the responses API.
365    InputText { text: String },
366    // Do not rename, these are serialized and used directly in the responses API.
367    InputImage { image_url: String },
368}
369
370/// The payload we send back to OpenAI when reporting a tool call result.
371///
372/// `content` preserves the historical plain-string payload so downstream
373/// integrations (tests, logging, etc.) can keep treating tool output as
374/// `String`. When an MCP server returns richer data we additionally populate
375/// `content_items` with the structured form that the Responses/Chat
376/// Completions APIs understand.
377#[derive(Debug, Default, Clone, PartialEq, JsonSchema, TS)]
378pub struct FunctionCallOutputPayload {
379    pub content: String,
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub content_items: Option<Vec<FunctionCallOutputContentItem>>,
382    // TODO(jif) drop this.
383    pub success: Option<bool>,
384}
385
386#[derive(Deserialize)]
387#[serde(untagged)]
388enum FunctionCallOutputPayloadSerde {
389    Text(String),
390    Items(Vec<FunctionCallOutputContentItem>),
391}
392
393// The Responses API expects two *different* shapes depending on success vs failure:
394//   • success → output is a plain string (no nested object)
395//   • failure → output is an object { content, success:false }
396impl Serialize for FunctionCallOutputPayload {
397    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
398    where
399        S: Serializer,
400    {
401        tracing::debug!("Function call output payload: {:?}", self);
402        if let Some(items) = &self.content_items {
403            items.serialize(serializer)
404        } else {
405            serializer.serialize_str(&self.content)
406        }
407    }
408}
409
410impl<'de> Deserialize<'de> for FunctionCallOutputPayload {
411    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
412    where
413        D: Deserializer<'de>,
414    {
415        match FunctionCallOutputPayloadSerde::deserialize(deserializer)? {
416            FunctionCallOutputPayloadSerde::Text(content) => Ok(FunctionCallOutputPayload {
417                content,
418                ..Default::default()
419            }),
420            FunctionCallOutputPayloadSerde::Items(items) => {
421                let content = serde_json::to_string(&items).map_err(serde::de::Error::custom)?;
422                Ok(FunctionCallOutputPayload {
423                    content,
424                    content_items: Some(items),
425                    success: None,
426                })
427            }
428        }
429    }
430}
431
432impl From<&CallToolResult> for FunctionCallOutputPayload {
433    fn from(call_tool_result: &CallToolResult) -> Self {
434        let CallToolResult {
435            content,
436            structured_content,
437            is_error,
438        } = call_tool_result;
439
440        let is_success = is_error != &Some(true);
441
442        if let Some(structured_content) = structured_content
443            && !structured_content.is_null()
444        {
445            match serde_json::to_string(structured_content) {
446                Ok(serialized_structured_content) => {
447                    return FunctionCallOutputPayload {
448                        content: serialized_structured_content,
449                        success: Some(is_success),
450                        ..Default::default()
451                    };
452                }
453                Err(err) => {
454                    return FunctionCallOutputPayload {
455                        content: err.to_string(),
456                        success: Some(false),
457                        ..Default::default()
458                    };
459                }
460            }
461        }
462
463        let serialized_content = match serde_json::to_string(content) {
464            Ok(serialized_content) => serialized_content,
465            Err(err) => {
466                return FunctionCallOutputPayload {
467                    content: err.to_string(),
468                    success: Some(false),
469                    ..Default::default()
470                };
471            }
472        };
473
474        let content_items = convert_content_blocks_to_items(content);
475
476        FunctionCallOutputPayload {
477            content: serialized_content,
478            content_items,
479            success: Some(is_success),
480        }
481    }
482}
483
484fn convert_content_blocks_to_items(
485    blocks: &[ContentBlock],
486) -> Option<Vec<FunctionCallOutputContentItem>> {
487    let mut saw_image = false;
488    let mut items = Vec::with_capacity(blocks.len());
489    tracing::warn!("Blocks: {:?}", blocks);
490    for block in blocks {
491        match block {
492            ContentBlock::TextContent(text) => {
493                items.push(FunctionCallOutputContentItem::InputText {
494                    text: text.text.clone(),
495                });
496            }
497            ContentBlock::ImageContent(image) => {
498                saw_image = true;
499                // Just in case the content doesn't include a data URL, add it.
500                let image_url = if image.data.starts_with("data:") {
501                    image.data.clone()
502                } else {
503                    format!("data:{};base64,{}", image.mime_type, image.data)
504                };
505                items.push(FunctionCallOutputContentItem::InputImage { image_url });
506            }
507            // TODO: render audio, resource, and embedded resource content to the model.
508            _ => return None,
509        }
510    }
511
512    if saw_image { Some(items) } else { None }
513}
514
515// Implement Display so callers can treat the payload like a plain string when logging or doing
516// trivial substring checks in tests (existing tests call `.contains()` on the output). Display
517// returns the raw `content` field.
518
519impl std::fmt::Display for FunctionCallOutputPayload {
520    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521        f.write_str(&self.content)
522    }
523}
524
525impl std::ops::Deref for FunctionCallOutputPayload {
526    type Target = str;
527    fn deref(&self) -> &Self::Target {
528        &self.content
529    }
530}
531
532// (Moved event mapping logic into codex-core to avoid coupling protocol to UI-facing events.)
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use anyhow::Result;
538    use mcp_types::ImageContent;
539    use mcp_types::TextContent;
540    use tempfile::tempdir;
541
542    #[test]
543    fn serializes_success_as_plain_string() -> Result<()> {
544        let item = ResponseInputItem::FunctionCallOutput {
545            call_id: "call1".into(),
546            output: FunctionCallOutputPayload {
547                content: "ok".into(),
548                ..Default::default()
549            },
550        };
551
552        let json = serde_json::to_string(&item)?;
553        let v: serde_json::Value = serde_json::from_str(&json)?;
554
555        // Success case -> output should be a plain string
556        assert_eq!(v.get("output").unwrap().as_str().unwrap(), "ok");
557        Ok(())
558    }
559
560    #[test]
561    fn serializes_failure_as_string() -> Result<()> {
562        let item = ResponseInputItem::FunctionCallOutput {
563            call_id: "call1".into(),
564            output: FunctionCallOutputPayload {
565                content: "bad".into(),
566                success: Some(false),
567                ..Default::default()
568            },
569        };
570
571        let json = serde_json::to_string(&item)?;
572        let v: serde_json::Value = serde_json::from_str(&json)?;
573
574        assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
575        Ok(())
576    }
577
578    #[test]
579    fn serializes_image_outputs_as_array() -> Result<()> {
580        let call_tool_result = CallToolResult {
581            content: vec![
582                ContentBlock::TextContent(TextContent {
583                    annotations: None,
584                    text: "caption".into(),
585                    r#type: "text".into(),
586                }),
587                ContentBlock::ImageContent(ImageContent {
588                    annotations: None,
589                    data: "BASE64".into(),
590                    mime_type: "image/png".into(),
591                    r#type: "image".into(),
592                }),
593            ],
594            is_error: None,
595            structured_content: None,
596        };
597
598        let payload = FunctionCallOutputPayload::from(&call_tool_result);
599        assert_eq!(payload.success, Some(true));
600        let items = payload.content_items.clone().expect("content items");
601        assert_eq!(
602            items,
603            vec![
604                FunctionCallOutputContentItem::InputText {
605                    text: "caption".into(),
606                },
607                FunctionCallOutputContentItem::InputImage {
608                    image_url: "data:image/png;base64,BASE64".into(),
609                },
610            ]
611        );
612
613        let item = ResponseInputItem::FunctionCallOutput {
614            call_id: "call1".into(),
615            output: payload,
616        };
617
618        let json = serde_json::to_string(&item)?;
619        let v: serde_json::Value = serde_json::from_str(&json)?;
620
621        let output = v.get("output").expect("output field");
622        assert!(output.is_array(), "expected array output");
623
624        Ok(())
625    }
626
627    #[test]
628    fn deserializes_array_payload_into_items() -> Result<()> {
629        let json = r#"[
630            {"type": "input_text", "text": "note"},
631            {"type": "input_image", "image_url": "data:image/png;base64,XYZ"}
632        ]"#;
633
634        let payload: FunctionCallOutputPayload = serde_json::from_str(json)?;
635
636        assert_eq!(payload.success, None);
637        let expected_items = vec![
638            FunctionCallOutputContentItem::InputText {
639                text: "note".into(),
640            },
641            FunctionCallOutputContentItem::InputImage {
642                image_url: "data:image/png;base64,XYZ".into(),
643            },
644        ];
645        assert_eq!(payload.content_items, Some(expected_items.clone()));
646
647        let expected_content = serde_json::to_string(&expected_items)?;
648        assert_eq!(payload.content, expected_content);
649
650        Ok(())
651    }
652
653    #[test]
654    fn roundtrips_web_search_call_actions() -> Result<()> {
655        let cases = vec![
656            (
657                r#"{
658                    "type": "web_search_call",
659                    "status": "completed",
660                    "action": {
661                        "type": "search",
662                        "query": "weather seattle"
663                    }
664                }"#,
665                WebSearchAction::Search {
666                    query: Some("weather seattle".into()),
667                },
668                Some("completed".into()),
669            ),
670            (
671                r#"{
672                    "type": "web_search_call",
673                    "status": "open",
674                    "action": {
675                        "type": "open_page",
676                        "url": "https://example.com"
677                    }
678                }"#,
679                WebSearchAction::OpenPage {
680                    url: Some("https://example.com".into()),
681                },
682                Some("open".into()),
683            ),
684            (
685                r#"{
686                    "type": "web_search_call",
687                    "status": "in_progress",
688                    "action": {
689                        "type": "find_in_page",
690                        "url": "https://example.com/docs",
691                        "pattern": "installation"
692                    }
693                }"#,
694                WebSearchAction::FindInPage {
695                    url: Some("https://example.com/docs".into()),
696                    pattern: Some("installation".into()),
697                },
698                Some("in_progress".into()),
699            ),
700        ];
701
702        for (json_literal, expected_action, expected_status) in cases {
703            let parsed: ResponseItem = serde_json::from_str(json_literal)?;
704            let expected = ResponseItem::WebSearchCall {
705                id: None,
706                status: expected_status.clone(),
707                action: expected_action.clone(),
708            };
709            assert_eq!(parsed, expected);
710
711            let serialized = serde_json::to_value(&parsed)?;
712            let original_value: serde_json::Value = serde_json::from_str(json_literal)?;
713            assert_eq!(serialized, original_value);
714        }
715
716        Ok(())
717    }
718
719    #[test]
720    fn deserialize_shell_tool_call_params() -> Result<()> {
721        let json = r#"{
722            "command": ["ls", "-l"],
723            "workdir": "/tmp",
724            "timeout": 1000
725        }"#;
726
727        let params: ShellToolCallParams = serde_json::from_str(json)?;
728        assert_eq!(
729            ShellToolCallParams {
730                command: vec!["ls".to_string(), "-l".to_string()],
731                workdir: Some("/tmp".to_string()),
732                timeout_ms: Some(1000),
733                with_escalated_permissions: None,
734                justification: None,
735            },
736            params
737        );
738        Ok(())
739    }
740
741    #[test]
742    fn local_image_read_error_adds_placeholder() -> Result<()> {
743        let dir = tempdir()?;
744        let missing_path = dir.path().join("missing-image.png");
745
746        let item = ResponseInputItem::from(vec![UserInput::LocalImage {
747            path: missing_path.clone(),
748        }]);
749
750        match item {
751            ResponseInputItem::Message { content, .. } => {
752                assert_eq!(content.len(), 1);
753                match &content[0] {
754                    ContentItem::InputText { text } => {
755                        let display_path = missing_path.display().to_string();
756                        assert!(
757                            text.contains(&display_path),
758                            "placeholder should mention missing path: {text}"
759                        );
760                        assert!(
761                            text.contains("could not read"),
762                            "placeholder should mention read issue: {text}"
763                        );
764                    }
765                    other => panic!("expected placeholder text but found {other:?}"),
766                }
767            }
768            other => panic!("expected message response but got {other:?}"),
769        }
770
771        Ok(())
772    }
773
774    #[test]
775    fn local_image_non_image_adds_placeholder() -> Result<()> {
776        let dir = tempdir()?;
777        let json_path = dir.path().join("example.json");
778        std::fs::write(&json_path, br#"{"hello":"world"}"#)?;
779
780        let item = ResponseInputItem::from(vec![UserInput::LocalImage {
781            path: json_path.clone(),
782        }]);
783
784        match item {
785            ResponseInputItem::Message { content, .. } => {
786                assert_eq!(content.len(), 1);
787                match &content[0] {
788                    ContentItem::InputText { text } => {
789                        assert!(
790                            text.contains("unsupported MIME type `application/json`"),
791                            "placeholder should mention unsupported MIME: {text}"
792                        );
793                        assert!(
794                            text.contains(&json_path.display().to_string()),
795                            "placeholder should mention path: {text}"
796                        );
797                    }
798                    other => panic!("expected placeholder text but found {other:?}"),
799                }
800            }
801            other => panic!("expected message response but got {other:?}"),
802        }
803
804        Ok(())
805    }
806}