1use std::collections::HashMap;
2
3use agcodex_mcp_types::CallToolResult;
4use base64::Engine;
5use serde::Deserialize;
6use serde::Deserializer;
7use serde::Serialize;
8use serde::ser::Serializer;
9
10use crate::protocol::InputItem;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ResponseInputItem {
15 Message {
16 role: String,
17 content: Vec<ContentItem>,
18 },
19 FunctionCallOutput {
20 call_id: String,
21 output: FunctionCallOutputPayload,
22 },
23 McpToolCallOutput {
24 call_id: String,
25 result: Result<CallToolResult, String>,
26 },
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30#[serde(tag = "type", rename_all = "snake_case")]
31pub enum ContentItem {
32 InputText { text: String },
33 InputImage { image_url: String },
34 OutputText { text: String },
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum ResponseItem {
40 Message {
41 id: Option<String>,
42 role: String,
43 content: Vec<ContentItem>,
44 },
45 Reasoning {
46 id: String,
47 summary: Vec<ReasoningItemReasoningSummary>,
48 #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
49 content: Option<Vec<ReasoningItemContent>>,
50 encrypted_content: Option<String>,
51 },
52 LocalShellCall {
53 id: Option<String>,
55 call_id: Option<String>,
57 status: LocalShellStatus,
58 action: LocalShellAction,
59 },
60 FunctionCall {
61 id: Option<String>,
62 name: String,
63 arguments: String,
68 call_id: String,
69 },
70 FunctionCallOutput {
77 call_id: String,
78 output: FunctionCallOutputPayload,
79 },
80 #[serde(other)]
81 Other,
82}
83
84fn should_serialize_reasoning_content(content: &Option<Vec<ReasoningItemContent>>) -> bool {
85 match content {
86 Some(content) => !content
87 .iter()
88 .any(|c| matches!(c, ReasoningItemContent::ReasoningText { .. })),
89 None => false,
90 }
91}
92
93impl From<ResponseInputItem> for ResponseItem {
94 fn from(item: ResponseInputItem) -> Self {
95 match item {
96 ResponseInputItem::Message { role, content } => Self::Message {
97 role,
98 content,
99 id: None,
100 },
101 ResponseInputItem::FunctionCallOutput { call_id, output } => {
102 Self::FunctionCallOutput { call_id, output }
103 }
104 ResponseInputItem::McpToolCallOutput { call_id, result } => Self::FunctionCallOutput {
105 call_id,
106 output: FunctionCallOutputPayload {
107 success: Some(result.is_ok()),
108 content: result.map_or_else(
109 |tool_call_err| format!("err: {tool_call_err:?}"),
110 |result| {
111 serde_json::to_string(&result)
112 .unwrap_or_else(|e| format!("JSON serialization error: {e}"))
113 },
114 ),
115 },
116 },
117 }
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122#[serde(rename_all = "snake_case")]
123pub enum LocalShellStatus {
124 Completed,
125 InProgress,
126 Incomplete,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130#[serde(tag = "type", rename_all = "snake_case")]
131pub enum LocalShellAction {
132 Exec(LocalShellExecAction),
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct LocalShellExecAction {
137 pub command: Vec<String>,
138 pub timeout_ms: Option<u64>,
139 pub working_directory: Option<String>,
140 pub env: Option<HashMap<String, String>>,
141 pub user: Option<String>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
145#[serde(tag = "type", rename_all = "snake_case")]
146pub enum ReasoningItemReasoningSummary {
147 SummaryText { text: String },
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
151#[serde(tag = "type", rename_all = "snake_case")]
152pub enum ReasoningItemContent {
153 ReasoningText { text: String },
154 Text { text: String },
155}
156
157impl From<Vec<InputItem>> for ResponseInputItem {
158 fn from(items: Vec<InputItem>) -> Self {
159 Self::Message {
160 role: "user".to_string(),
161 content: items
162 .into_iter()
163 .filter_map(|c| match c {
164 InputItem::Text { text } => Some(ContentItem::InputText { text }),
165 InputItem::Image { image_url } => Some(ContentItem::InputImage { image_url }),
166 InputItem::LocalImage { path } => match std::fs::read(&path) {
167 Ok(bytes) => {
168 let mime = mime_guess::from_path(&path)
169 .first()
170 .map(|m| m.essence_str().to_owned())
171 .unwrap_or_else(|| "application/octet-stream".to_string());
172 let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
173 Some(ContentItem::InputImage {
174 image_url: format!("data:{mime};base64,{encoded}"),
175 })
176 }
177 Err(err) => {
178 tracing::warn!(
179 "Skipping image {} – could not read file: {}",
180 path.display(),
181 err
182 );
183 None
184 }
185 },
186 _ => None,
187 })
188 .collect::<Vec<ContentItem>>(),
189 }
190 }
191}
192
193#[derive(Deserialize, Debug, Clone, PartialEq)]
196pub struct ShellToolCallParams {
197 pub command: Vec<String>,
198 pub workdir: Option<String>,
199
200 #[serde(rename = "timeout")]
202 pub timeout_ms: Option<u64>,
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub with_escalated_permissions: Option<bool>,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub justification: Option<String>,
209}
210
211#[derive(Debug, Clone, PartialEq)]
212pub struct FunctionCallOutputPayload {
213 pub content: String,
214 pub success: Option<bool>,
215}
216
217impl Serialize for FunctionCallOutputPayload {
224 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
225 where
226 S: Serializer,
227 {
228 serializer.serialize_str(&self.content)
235 }
236}
237
238impl<'de> Deserialize<'de> for FunctionCallOutputPayload {
239 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
240 where
241 D: Deserializer<'de>,
242 {
243 let s = String::deserialize(deserializer)?;
244 Ok(FunctionCallOutputPayload {
245 content: s,
246 success: None,
247 })
248 }
249}
250
251impl std::fmt::Display for FunctionCallOutputPayload {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257 f.write_str(&self.content)
258 }
259}
260
261impl std::ops::Deref for FunctionCallOutputPayload {
262 type Target = str;
263 fn deref(&self) -> &Self::Target {
264 &self.content
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn serializes_success_as_plain_string() {
274 let item = ResponseInputItem::FunctionCallOutput {
275 call_id: "call1".into(),
276 output: FunctionCallOutputPayload {
277 content: "ok".into(),
278 success: None,
279 },
280 };
281
282 let json = serde_json::to_string(&item).unwrap();
283 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
284
285 assert_eq!(v.get("output").unwrap().as_str().unwrap(), "ok");
287 }
288
289 #[test]
290 fn serializes_failure_as_string() {
291 let item = ResponseInputItem::FunctionCallOutput {
292 call_id: "call1".into(),
293 output: FunctionCallOutputPayload {
294 content: "bad".into(),
295 success: Some(false),
296 },
297 };
298
299 let json = serde_json::to_string(&item).unwrap();
300 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
301
302 assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
303 }
304
305 #[test]
306 fn deserialize_shell_tool_call_params() {
307 let json = r#"{
308 "command": ["ls", "-l"],
309 "workdir": "/tmp",
310 "timeout": 1000
311 }"#;
312
313 let params: ShellToolCallParams = serde_json::from_str(json).unwrap();
314 assert_eq!(
315 ShellToolCallParams {
316 command: vec!["ls".to_string(), "-l".to_string()],
317 workdir: Some("/tmp".to_string()),
318 timeout_ms: Some(1000),
319 with_escalated_permissions: None,
320 justification: None,
321 },
322 params
323 );
324 }
325}