1use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum ChunkContent {
14 Text { text: String },
15 Image { mime_type: Option<String>, uri: Option<String>, data: Option<String> },
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ToolKind {
23 Read,
24 Edit,
25 Delete,
26 Move,
27 Execute,
28 Search,
29 Fetch,
30 Think,
31 SwitchMode,
32 #[serde(other)]
33 Other,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ToolCallStatus {
41 InProgress,
42 Completed,
43 Failed,
44 Killed,
45 #[serde(other)]
46 Pending,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ToolCall {
51 pub tool_call_id: String,
52 pub title: String,
53 pub kind: ToolKind,
54 pub status: ToolCallStatus,
55 pub content: Vec<ToolCallContent>,
56 pub raw_input: Option<Value>,
57 pub raw_output: Option<String>,
58 pub output_metadata: Option<ToolOutputMetadata>,
59 pub task_metadata: Option<TaskMetadata>,
60 pub locations: Vec<ToolCallLocation>,
61 pub meta: Option<Value>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ToolCallUpdate {
66 pub tool_call_id: String,
67 pub fields: ToolCallUpdateFields,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
71pub struct ToolCallUpdateFields {
72 pub title: Option<String>,
73 pub kind: Option<ToolKind>,
74 pub status: Option<ToolCallStatus>,
75 pub content: Option<Vec<ToolCallContent>>,
76 pub raw_input: Option<Value>,
77 pub raw_output: Option<String>,
78 pub output_metadata: Option<ToolOutputMetadata>,
79 pub task_metadata: Option<TaskMetadata>,
80 pub locations: Option<Vec<ToolCallLocation>>,
81 pub meta: Option<Value>,
82}
83
84fn raw_input_carries_content(v: &Value) -> bool {
87 !v.as_object().is_some_and(serde_json::Map::is_empty)
88}
89
90impl ToolCall {
91 pub fn merge(&mut self, fields: ToolCallUpdateFields) {
97 let ToolCallUpdateFields {
98 title,
99 kind,
100 status,
101 content,
102 raw_input,
103 raw_output,
104 output_metadata,
105 task_metadata,
106 locations,
107 meta,
108 } = fields;
109
110 if let Some(title) = title {
111 self.title = title;
112 }
113 if let Some(kind) = kind {
114 self.kind = kind;
115 }
116 if let Some(status) = status {
117 self.status = status;
118 }
119 if let Some(content) = content {
120 self.content = content;
121 }
122 if raw_input.as_ref().is_some_and(raw_input_carries_content) {
123 self.raw_input = raw_input;
124 }
125 if raw_output.is_some() {
126 self.raw_output = raw_output;
127 }
128 if output_metadata.is_some() {
129 self.output_metadata = output_metadata;
130 }
131 if let Some(update) = task_metadata {
132 match self.task_metadata.as_mut() {
133 Some(existing) => existing.merge(update),
134 None => self.task_metadata = Some(update),
135 }
136 }
137 if let Some(locations) = locations {
138 self.locations = locations;
139 }
140 if meta.is_some() {
141 self.meta = meta;
142 }
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct ToolCallLocation {
151 pub path: std::path::PathBuf,
152 pub line: Option<u32>,
153}
154
155impl ToolCallLocation {
156 pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
157 Self { path: path.into(), line: None }
158 }
159
160 pub fn line(mut self, line: u32) -> Self {
161 self.line = Some(line);
162 self
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
167pub struct BashOutputMetadata {
168 pub assistant_auto_backgrounded: Option<bool>,
169}
170
171impl BashOutputMetadata {
172 pub fn new() -> Self {
173 Self::default()
174 }
175
176 pub fn assistant_auto_backgrounded(
177 mut self,
178 assistant_auto_backgrounded: Option<bool>,
179 ) -> Self {
180 self.assistant_auto_backgrounded = assistant_auto_backgrounded;
181 self
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
186pub struct ToolOutputMetadata {
187 pub bash: Option<BashOutputMetadata>,
188}
189
190impl ToolOutputMetadata {
191 pub fn new() -> Self {
192 Self::default()
193 }
194
195 pub fn bash(mut self, bash: Option<BashOutputMetadata>) -> Self {
196 self.bash = bash;
197 self
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
202pub struct TaskMetadata {
203 pub end_time: Option<u64>,
204 pub total_paused_ms: Option<u64>,
205 pub error: Option<String>,
206 pub is_backgrounded: Option<bool>,
207}
208
209impl TaskMetadata {
210 pub fn new() -> Self {
211 Self::default()
212 }
213
214 pub fn end_time(mut self, end_time: Option<u64>) -> Self {
215 self.end_time = end_time;
216 self
217 }
218
219 pub fn total_paused_ms(mut self, total_paused_ms: Option<u64>) -> Self {
220 self.total_paused_ms = total_paused_ms;
221 self
222 }
223
224 pub fn error(mut self, error: Option<String>) -> Self {
225 self.error = error;
226 self
227 }
228
229 pub fn backgrounded(mut self, is_backgrounded: Option<bool>) -> Self {
230 self.is_backgrounded = is_backgrounded;
231 self
232 }
233
234 pub fn merge(&mut self, update: TaskMetadata) {
239 let TaskMetadata { end_time, total_paused_ms, error, is_backgrounded } = update;
240 if end_time.is_some() {
241 self.end_time = end_time;
242 }
243 if total_paused_ms.is_some() {
244 self.total_paused_ms = total_paused_ms;
245 }
246 if error.is_some() {
247 self.error = error;
248 }
249 if is_backgrounded.is_some() {
250 self.is_backgrounded = is_backgrounded;
251 }
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(tag = "type", rename_all = "snake_case")]
257pub enum ToolCallContent {
258 Content {
259 content: ChunkContent,
260 },
261 Diff {
262 new_path: String,
263 old: String,
264 new: String,
265 repository: Option<String>,
266 },
267 McpResource {
268 uri: String,
269 mime_type: Option<String>,
270 text: Option<String>,
271 blob_saved_to: Option<String>,
272 },
273}
274
275#[cfg(test)]
276mod tests {
277 use super::{
278 ChunkContent, TaskMetadata, ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus,
279 ToolCallUpdateFields, ToolKind,
280 };
281 use serde_json::json;
282
283 fn sample_tool_call() -> ToolCall {
284 ToolCall {
285 tool_call_id: "tc_1".into(),
286 title: "old title".into(),
287 kind: ToolKind::Execute,
288 status: ToolCallStatus::InProgress,
289 content: vec![ToolCallContent::Content {
290 content: ChunkContent::Text { text: "before".into() },
291 }],
292 raw_input: Some(json!({"cmd": "ls"})),
293 raw_output: Some("old stdout".into()),
294 output_metadata: None,
295 task_metadata: None,
296 locations: vec![ToolCallLocation::new("/old").line(1)],
297 meta: Some(json!({"old": true})),
298 }
299 }
300
301 #[test]
302 fn merge_with_empty_fields_leaves_tool_call_unchanged() {
303 let mut tc = sample_tool_call();
304 let before = tc.clone();
305 tc.merge(ToolCallUpdateFields::default());
306 assert_eq!(tc, before);
307 }
308
309 #[test]
310 fn merge_overrides_each_set_field() {
311 let mut tc = sample_tool_call();
312 tc.merge(ToolCallUpdateFields {
313 title: Some("new title".into()),
314 kind: Some(ToolKind::Read),
315 status: Some(ToolCallStatus::Completed),
316 content: Some(vec![ToolCallContent::Content {
317 content: ChunkContent::Text { text: "after".into() },
318 }]),
319 raw_input: Some(json!({"cmd": "pwd"})),
320 raw_output: Some("new stdout".into()),
321 output_metadata: None,
322 task_metadata: None,
323 locations: Some(vec![ToolCallLocation::new("/new").line(7)]),
324 meta: Some(json!({"new": true})),
325 });
326 assert_eq!(tc.title, "new title");
327 assert_eq!(tc.kind, ToolKind::Read);
328 assert_eq!(tc.status, ToolCallStatus::Completed);
329 assert_eq!(tc.content.len(), 1);
330 assert_eq!(tc.raw_input, Some(json!({"cmd": "pwd"})));
331 assert_eq!(tc.raw_output.as_deref(), Some("new stdout"));
332 assert_eq!(tc.locations.len(), 1);
333 assert_eq!(tc.locations[0].path, std::path::PathBuf::from("/new"));
334 assert_eq!(tc.meta, Some(json!({"new": true})));
335 }
336
337 #[test]
338 fn merge_none_fields_preserve_existing_some_on_tool_call() {
339 let mut tc = sample_tool_call();
342 tc.merge(ToolCallUpdateFields::default());
343 assert_eq!(tc.raw_input, Some(json!({"cmd": "ls"})));
344 assert_eq!(tc.raw_output.as_deref(), Some("old stdout"));
345 assert_eq!(tc.meta, Some(json!({"old": true})));
346 }
347
348 #[test]
349 fn merge_empty_object_raw_input_preserves_existing_input() {
350 let mut tc = sample_tool_call();
353 tc.merge(ToolCallUpdateFields { raw_input: Some(json!({})), ..Default::default() });
354 assert_eq!(tc.raw_input, Some(json!({"cmd": "ls"})));
355 }
356
357 #[test]
358 fn merge_content_bearing_raw_input_overwrites_existing_input() {
359 let mut tc = sample_tool_call();
360 tc.merge(ToolCallUpdateFields {
361 raw_input: Some(json!({"cmd": "pwd"})),
362 ..Default::default()
363 });
364 assert_eq!(tc.raw_input, Some(json!({"cmd": "pwd"})));
365 }
366
367 #[test]
368 fn merge_task_metadata_sets_when_base_is_none() {
369 let mut tc = sample_tool_call();
370 assert!(tc.task_metadata.is_none());
371 tc.merge(ToolCallUpdateFields {
372 task_metadata: Some(TaskMetadata {
373 end_time: Some(1234),
374 total_paused_ms: None,
375 error: None,
376 is_backgrounded: None,
377 }),
378 ..Default::default()
379 });
380 assert_eq!(tc.task_metadata.as_ref().and_then(|m| m.end_time), Some(1234));
381 }
382
383 #[test]
384 fn merge_task_metadata_field_wise_when_base_is_some() {
385 let mut tc = sample_tool_call();
386 tc.task_metadata = Some(TaskMetadata {
387 end_time: Some(1000),
388 total_paused_ms: Some(50),
389 error: None,
390 is_backgrounded: Some(false),
391 });
392 tc.merge(ToolCallUpdateFields {
395 task_metadata: Some(TaskMetadata {
396 end_time: Some(2000),
397 total_paused_ms: None,
398 error: Some("boom".into()),
399 is_backgrounded: None,
400 }),
401 ..Default::default()
402 });
403 let tm = tc.task_metadata.expect("task_metadata present");
404 assert_eq!(tm.end_time, Some(2000));
405 assert_eq!(tm.total_paused_ms, Some(50));
406 assert_eq!(tm.error.as_deref(), Some("boom"));
407 assert_eq!(tm.is_backgrounded, Some(false));
408 }
409
410 #[test]
411 fn task_metadata_merge_destructure_overrides_set_fields() {
412 let mut tm = TaskMetadata {
413 end_time: Some(1),
414 total_paused_ms: Some(2),
415 error: None,
416 is_backgrounded: Some(false),
417 };
418 tm.merge(TaskMetadata {
419 end_time: None,
420 total_paused_ms: Some(99),
421 error: Some("oops".into()),
422 is_backgrounded: None,
423 });
424 assert_eq!(tm.end_time, Some(1));
425 assert_eq!(tm.total_paused_ms, Some(99));
426 assert_eq!(tm.error.as_deref(), Some("oops"));
427 assert_eq!(tm.is_backgrounded, Some(false));
428 }
429}