Skip to main content

vtcode_llm/open_responses/
items.rs

1//! Output item types for Open Responses.
2//!
3//! Items are the fundamental unit of context in Open Responses. They represent
4//! atomic units of model output, tool invocation, or reasoning state.
5
6use serde::de::{self, MapAccess, Visitor};
7use serde::ser::SerializeMap;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use serde_json::Value;
10use std::collections::HashSet;
11
12use super::{ContentPart, ItemStatus};
13use vtcode_macros::StringNewtype;
14
15/// Unique identifier for an output item.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, StringNewtype)]
17#[serde(transparent)]
18pub struct OutputItemId(String);
19
20/// Output items generated by the model.
21///
22/// Per the Open Responses specification, items are polymorphic (discriminated by `type`),
23/// state machines (with status transitions), and streamable (through delta events).
24///
25/// Custom items serialize with their `custom_type` as the actual `type` field value
26/// (e.g., `"type": "vtcode:file_change"`), per the extension convention.
27#[derive(Debug, Clone, PartialEq)]
28pub enum OutputItem {
29    /// A message from the assistant, user, system, or developer.
30    Message(MessageItem),
31
32    /// Model reasoning/thinking content.
33    Reasoning(ReasoningItem),
34
35    /// A function/tool call request from the model.
36    FunctionCall(FunctionCallItem),
37
38    /// Output from a function/tool call execution.
39    FunctionCallOutput(FunctionCallOutputItem),
40
41    /// Custom/extension item type (prefixed with implementor slug).
42    Custom(CustomItem),
43}
44
45impl Serialize for OutputItem {
46    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
47    where
48        S: Serializer,
49    {
50        match self {
51            Self::Message(item) => {
52                let mut map = serializer.serialize_map(None)?;
53                map.serialize_entry("type", "message")?;
54                map.serialize_entry("id", &item.id)?;
55                map.serialize_entry("status", &item.status)?;
56                map.serialize_entry("role", &item.role)?;
57                map.serialize_entry("content", &item.content)?;
58                map.end()
59            }
60            Self::Reasoning(item) => {
61                let mut map = serializer.serialize_map(None)?;
62                map.serialize_entry("type", "reasoning")?;
63                map.serialize_entry("id", &item.id)?;
64                map.serialize_entry("status", &item.status)?;
65                if let Some(ref summary) = item.summary {
66                    map.serialize_entry("summary", summary)?;
67                }
68                if let Some(ref content) = item.content {
69                    map.serialize_entry("content", content)?;
70                }
71                if let Some(ref encrypted) = item.encrypted_content {
72                    map.serialize_entry("encrypted_content", encrypted)?;
73                }
74                map.end()
75            }
76            Self::FunctionCall(item) => {
77                let mut map = serializer.serialize_map(None)?;
78                map.serialize_entry("type", "function_call")?;
79                map.serialize_entry("id", &item.id)?;
80                map.serialize_entry("status", &item.status)?;
81                map.serialize_entry("name", &item.name)?;
82                map.serialize_entry("arguments", &item.arguments)?;
83                if let Some(ref call_id) = item.call_id {
84                    map.serialize_entry("call_id", call_id)?;
85                }
86                map.end()
87            }
88            Self::FunctionCallOutput(item) => {
89                let mut map = serializer.serialize_map(None)?;
90                map.serialize_entry("type", "function_call_output")?;
91                map.serialize_entry("id", &item.id)?;
92                map.serialize_entry("status", &item.status)?;
93                if let Some(ref call_id) = item.call_id {
94                    map.serialize_entry("call_id", call_id)?;
95                }
96                map.serialize_entry("output", &item.output)?;
97                map.end()
98            }
99            Self::Custom(item) => {
100                // Custom items use their custom_type as the type discriminator
101                let mut map = serializer.serialize_map(None)?;
102                map.serialize_entry("type", &item.custom_type)?;
103                map.serialize_entry("id", &item.id)?;
104                map.serialize_entry("status", &item.status)?;
105                map.serialize_entry("data", &item.data)?;
106                map.end()
107            }
108        }
109    }
110}
111
112impl<'de> Deserialize<'de> for OutputItem {
113    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
114    where
115        D: Deserializer<'de>,
116    {
117        struct OutputItemVisitor;
118
119        impl<'de> Visitor<'de> for OutputItemVisitor {
120            type Value = OutputItem;
121
122            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123                formatter.write_str("an output item object with a type field")
124            }
125
126            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
127            where
128                M: MapAccess<'de>,
129            {
130                let mut type_field: Option<String> = None;
131                let mut id: Option<String> = None;
132                let mut status: Option<ItemStatus> = None;
133                let mut role: Option<MessageRole> = None;
134                let mut content: Option<Vec<ContentPart>> = None;
135                let mut summary: Option<String> = None;
136                let mut reasoning_content: Option<String> = None;
137                let mut encrypted_content: Option<String> = None;
138                let mut name: Option<String> = None;
139                let mut arguments: Option<Value> = None;
140                let mut call_id: Option<String> = None;
141                let mut output: Option<String> = None;
142                let mut data: Option<Value> = None;
143                let mut seen_keys = HashSet::new();
144
145                while let Some(key) = map.next_key::<String>()? {
146                    if !seen_keys.insert(key.clone()) {
147                        return Err(de::Error::custom(format!("duplicate OpenResponses field {key:?}")));
148                    }
149                    match key.as_str() {
150                        "type" => type_field = Some(map.next_value()?),
151                        "id" => id = Some(map.next_value()?),
152                        "status" => status = Some(map.next_value()?),
153                        "role" => role = Some(map.next_value()?),
154                        "content" => {
155                            // content can be Vec<ContentPart> or String
156                            let val: Value = map.next_value()?;
157                            if let Value::Array(_) = &val {
158                                content = Some(serde_json::from_value(val).map_err(de::Error::custom)?);
159                            } else if let Value::String(s) = val {
160                                reasoning_content = Some(s);
161                            }
162                        }
163                        "summary" => summary = Some(map.next_value()?),
164                        "encrypted_content" => encrypted_content = Some(map.next_value()?),
165                        "name" => name = Some(map.next_value()?),
166                        "arguments" => arguments = Some(map.next_value()?),
167                        "call_id" => call_id = Some(map.next_value()?),
168                        "output" => output = Some(map.next_value()?),
169                        "data" => data = Some(map.next_value()?),
170                        _ => {
171                            // Skip unknown fields
172                            let _: Value = map.next_value()?;
173                        }
174                    }
175                }
176
177                let type_str = type_field.ok_or_else(|| de::Error::missing_field("type"))?;
178                let id: OutputItemId = id.ok_or_else(|| de::Error::missing_field("id"))?.into();
179                let status = status.unwrap_or(ItemStatus::InProgress);
180
181                match type_str.as_str() {
182                    "message" => Ok(OutputItem::Message(MessageItem {
183                        id,
184                        status,
185                        role: role.unwrap_or_default(),
186                        content: content.unwrap_or_default(),
187                    })),
188                    "reasoning" => Ok(OutputItem::Reasoning(ReasoningItem {
189                        id,
190                        status,
191                        summary,
192                        content: reasoning_content,
193                        encrypted_content,
194                    })),
195                    "function_call" => Ok(OutputItem::FunctionCall(FunctionCallItem {
196                        id,
197                        status,
198                        name: name.ok_or_else(|| de::Error::missing_field("name"))?,
199                        arguments: arguments.unwrap_or(Value::Null),
200                        call_id,
201                    })),
202                    "function_call_output" => Ok(OutputItem::FunctionCallOutput(FunctionCallOutputItem {
203                        id,
204                        status,
205                        call_id,
206                        output: output.ok_or_else(|| de::Error::missing_field("output"))?,
207                    })),
208                    // Any other type is treated as a custom extension type
209                    custom_type => Ok(OutputItem::Custom(CustomItem {
210                        id,
211                        status,
212                        custom_type: custom_type.to_string(),
213                        data: data.unwrap_or(Value::Null),
214                    })),
215                }
216            }
217        }
218
219        deserializer.deserialize_map(OutputItemVisitor)
220    }
221}
222
223impl OutputItem {
224    /// Returns the unique identifier for this item.
225    fn id(&self) -> &str {
226        match self {
227            Self::Message(m) => &m.id,
228            Self::Reasoning(r) => &r.id,
229            Self::FunctionCall(f) => &f.id,
230            Self::FunctionCallOutput(f) => &f.id,
231            Self::Custom(c) => &c.id,
232        }
233    }
234
235    /// Returns the current status of this item.
236    pub fn status(&self) -> ItemStatus {
237        match self {
238            Self::Message(m) => m.status,
239            Self::Reasoning(r) => r.status,
240            Self::FunctionCall(f) => f.status,
241            Self::FunctionCallOutput(f) => f.status,
242            Self::Custom(c) => c.status,
243        }
244    }
245
246    /// Returns the type name for this item.
247    fn type_name(&self) -> &str {
248        match self {
249            Self::Message(_) => "message",
250            Self::Reasoning(_) => "reasoning",
251            Self::FunctionCall(_) => "function_call",
252            Self::FunctionCallOutput(_) => "function_call_output",
253            Self::Custom(c) => &c.custom_type,
254        }
255    }
256
257    /// Creates a new message item with the given parameters (status: `InProgress`).
258    pub fn message(id: impl Into<OutputItemId>, role: MessageRole, content: Vec<ContentPart>) -> Self {
259        Self::Message(MessageItem {
260            id: id.into(),
261            status: ItemStatus::InProgress,
262            role,
263            content,
264        })
265    }
266
267    /// Creates a new completed message item with the given parameters.
268    pub(crate) fn completed_message(id: impl Into<OutputItemId>, role: MessageRole, content: Vec<ContentPart>) -> Self {
269        Self::Message(MessageItem {
270            id: id.into(),
271            status: ItemStatus::Completed,
272            role,
273            content,
274        })
275    }
276
277    /// Creates a new reasoning item.
278    pub(crate) fn reasoning(id: impl Into<OutputItemId>) -> Self {
279        Self::Reasoning(ReasoningItem {
280            id: id.into(),
281            status: ItemStatus::InProgress,
282            summary: None,
283            content: None,
284            encrypted_content: None,
285        })
286    }
287
288    /// Creates a new function call item.
289    pub fn function_call(id: impl Into<OutputItemId>, name: impl Into<String>, arguments: Value) -> Self {
290        Self::FunctionCall(FunctionCallItem {
291            id: id.into(),
292            status: ItemStatus::InProgress,
293            name: name.into(),
294            arguments,
295            call_id: None,
296        })
297    }
298
299    /// Creates a new function call output item (status: `InProgress`).
300    ///
301    /// Use this for streaming scenarios. For completed tool results, use
302    /// `OutputItem::completed_function_call_output`.
303    pub fn function_call_output(
304        id: impl Into<OutputItemId>,
305        call_id: Option<String>,
306        output: impl Into<String>,
307    ) -> Self {
308        Self::FunctionCallOutput(FunctionCallOutputItem {
309            id: id.into(),
310            status: ItemStatus::InProgress,
311            call_id,
312            output: output.into(),
313        })
314    }
315
316    /// Creates a new completed function call output item.
317    ///
318    /// Use this when the tool execution has finished and the output is final.
319    pub fn completed_function_call_output(
320        id: impl Into<OutputItemId>,
321        call_id: Option<String>,
322        output: impl Into<String>,
323    ) -> Self {
324        Self::FunctionCallOutput(FunctionCallOutputItem {
325            id: id.into(),
326            status: ItemStatus::Completed,
327            call_id,
328            output: output.into(),
329        })
330    }
331}
332
333/// A message item representing conversation content.
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335pub struct MessageItem {
336    /// Unique identifier for this item.
337    pub(crate) id: OutputItemId,
338
339    /// Current lifecycle status.
340    pub status: ItemStatus,
341
342    /// Role of the message author.
343    pub(crate) role: MessageRole,
344
345    /// Content parts that make up this message.
346    pub(crate) content: Vec<ContentPart>,
347}
348
349/// Role of a message author.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
351#[serde(rename_all = "lowercase")]
352pub enum MessageRole {
353    /// User message.
354    User,
355    /// Assistant/model message.
356    #[default]
357    Assistant,
358    /// System message.
359    System,
360    /// Developer message (for instructions).
361    Developer,
362}
363
364impl std::fmt::Display for MessageRole {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        match self {
367            Self::User => write!(f, "user"),
368            Self::Assistant => write!(f, "assistant"),
369            Self::System => write!(f, "system"),
370            Self::Developer => write!(f, "developer"),
371        }
372    }
373}
374
375/// A reasoning item containing model's internal thought process.
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377pub struct ReasoningItem {
378    /// Unique identifier for this item.
379    pub(crate) id: OutputItemId,
380
381    /// Current lifecycle status.
382    pub(crate) status: ItemStatus,
383
384    /// Summary of the reasoning (human-readable).
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub(crate) summary: Option<String>,
387
388    /// Raw reasoning trace content.
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub(crate) content: Option<String>,
391
392    /// Encrypted reasoning content for rehydration.
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub(crate) encrypted_content: Option<String>,
395}
396
397/// A function call item representing a tool invocation request.
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399pub struct FunctionCallItem {
400    /// Unique identifier for this item.
401    pub(crate) id: OutputItemId,
402
403    /// Current lifecycle status.
404    pub(crate) status: ItemStatus,
405
406    /// Name of the function to call.
407    pub name: String,
408
409    /// Arguments to pass to the function (JSON object).
410    pub arguments: Value,
411
412    /// Optional call ID for correlating with output.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub(crate) call_id: Option<String>,
415}
416
417/// Output from a function call execution.
418#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
419pub struct FunctionCallOutputItem {
420    /// Unique identifier for this item.
421    pub(crate) id: OutputItemId,
422
423    /// Current lifecycle status.
424    pub(crate) status: ItemStatus,
425
426    /// ID of the function call this output corresponds to.
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub(crate) call_id: Option<String>,
429
430    /// Output content from the function execution.
431    pub(crate) output: String,
432}
433
434/// Custom/extension item type.
435///
436/// Custom types must be prefixed with an implementor slug (e.g., `vtcode:file_change`).
437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
438pub struct CustomItem {
439    /// Unique identifier for this item.
440    pub(crate) id: OutputItemId,
441
442    /// Current lifecycle status.
443    pub(crate) status: ItemStatus,
444
445    /// Custom type identifier (must be prefixed, e.g., `vtcode:file_change`).
446    pub(crate) custom_type: String,
447
448    /// Custom data payload.
449    pub(crate) data: Value,
450}
451
452impl CustomItem {
453    /// Creates a new custom item with VT Code prefix.
454    fn vtcode(id: impl Into<OutputItemId>, name: &str, data: Value) -> Self {
455        Self {
456            id: id.into(),
457            status: ItemStatus::InProgress,
458            custom_type: format!("vtcode:{name}"),
459            data,
460        }
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_output_item_id() {
470        let item = OutputItem::message("msg_1", MessageRole::Assistant, vec![]);
471        assert_eq!(item.id(), "msg_1");
472        assert_eq!(item.type_name(), "message");
473    }
474
475    #[test]
476    fn test_function_call_serialization() {
477        let item = OutputItem::function_call("fc_1", "exec_command", serde_json::json!({"path": "/etc/passwd"}));
478        let json = serde_json::to_string(&item).unwrap();
479        assert!(json.contains("\"type\":\"function_call\""));
480        assert!(json.contains("\"name\":\"exec_command\""));
481    }
482
483    #[test]
484    fn test_custom_item_vtcode() {
485        let item =
486            CustomItem::vtcode("custom_1", "file_change", serde_json::json!({"path": "test.rs", "kind": "update"}));
487        assert_eq!(item.custom_type, "vtcode:file_change");
488    }
489
490    #[test]
491    fn test_custom_item_serializes_with_custom_type_as_type() {
492        let item =
493            OutputItem::Custom(CustomItem::vtcode("custom_1", "file_change", serde_json::json!({"path": "test.rs"})));
494        let json = serde_json::to_string(&item).unwrap();
495        // Custom type should be the type discriminator, not "custom"
496        assert!(json.contains("\"type\":\"vtcode:file_change\""));
497        assert!(!json.contains("\"type\":\"custom\""));
498        assert!(!json.contains("\"custom_type\""));
499    }
500
501    #[test]
502    fn test_custom_item_roundtrip() {
503        let original = OutputItem::Custom(CustomItem::vtcode(
504            "custom_1",
505            "file_change",
506            serde_json::json!({"path": "test.rs", "kind": "update"}),
507        ));
508        let json = serde_json::to_string(&original).unwrap();
509        let parsed: OutputItem = serde_json::from_str(&json).unwrap();
510        assert_eq!(original, parsed);
511
512        if let OutputItem::Custom(c) = &parsed {
513            assert_eq!(c.custom_type, "vtcode:file_change");
514            assert_eq!(c.data["path"], "test.rs");
515        } else {
516            panic!("Expected Custom variant");
517        }
518    }
519
520    #[test]
521    fn test_deserialize_unknown_type_as_custom() {
522        let json = r#"{"type":"vendor:special_item","id":"item_1","status":"completed","data":{"key":"value"}}"#;
523        let item: OutputItem = serde_json::from_str(json).unwrap();
524        if let OutputItem::Custom(c) = item {
525            assert_eq!(c.custom_type, "vendor:special_item");
526            assert_eq!(c.id.as_str(), "item_1");
527            assert_eq!(c.status, ItemStatus::Completed);
528            assert_eq!(c.data["key"], "value");
529        } else {
530            panic!("Expected Custom variant for unknown type");
531        }
532    }
533
534    #[test]
535    fn test_duplicate_output_item_keys_are_rejected() {
536        let json = r#"{
537            "type":"message",
538            "id":"item_1",
539            "id":"item_2",
540            "role":"assistant",
541            "content":[]
542        }"#;
543        let error = serde_json::from_str::<OutputItem>(json).expect_err("duplicate keys must be rejected");
544        assert!(error.to_string().contains("duplicate OpenResponses field"));
545    }
546
547    #[test]
548    fn test_completed_message_has_completed_status() {
549        let item = OutputItem::completed_message("msg_1", MessageRole::Assistant, vec![]);
550        assert_eq!(item.status(), ItemStatus::Completed);
551        if let OutputItem::Message(m) = item {
552            assert_eq!(m.status, ItemStatus::Completed);
553        } else {
554            panic!("Expected Message variant");
555        }
556    }
557
558    #[test]
559    fn test_completed_function_call_output_has_completed_status() {
560        let item = OutputItem::completed_function_call_output("fco_1", Some("fc_1".to_string()), "result");
561        assert_eq!(item.status(), ItemStatus::Completed);
562        if let OutputItem::FunctionCallOutput(f) = item {
563            assert_eq!(f.status, ItemStatus::Completed);
564            assert_eq!(f.call_id, Some("fc_1".to_string()));
565            assert_eq!(f.output, "result");
566        } else {
567            panic!("Expected FunctionCallOutput variant");
568        }
569    }
570}