Skip to main content

forge_primitives/
session_update.rs

1//! Wire-side support types for streaming session events: content
2//! chunks, tool-call envelopes, tool-output metadata, plan entries.
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// Render-side chunk payload for streaming session updates
8/// (`SessionUpdate::AgentMessageChunk` etc.). Distinct from the
9/// wire-side `crate::content::ContentBlock`, which carries `ToolUse`,
10/// `ToolResult`, `Thinking`, and server-tool variants.
11#[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/// Typed tool-call category. Unknown wire values fall through to
19/// `Other` so a new CLI variant doesn't break decode.
20#[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/// Lifecycle state of a tool call. Unknown wire values fall through
37/// to `Pending` so a new CLI variant doesn't break decode.
38#[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
84/// A tool's input is fixed at dispatch; a later update that carries no
85/// input (`None`) or an empty object `{}` must not clobber it.
86fn raw_input_carries_content(v: &Value) -> bool {
87    !v.as_object().is_some_and(serde_json::Map::is_empty)
88}
89
90impl ToolCall {
91    /// Apply a `ToolCallUpdateFields` patch in-place: any `Some` field
92    /// overrides the corresponding field on `self`; `None` leaves it
93    /// alone. The destructure forces a compile error when a new field
94    /// is added to `ToolCallUpdateFields` without a corresponding
95    /// merge arm here.
96    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/// File path + optional 1-based line number a tool call inspected
147/// or modified. Used by the chat renderer's clickable file glyphs
148/// and by `/diff` to open the right file.
149#[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    /// Field-wise merge: any `Some` field on `update` overrides `self`;
235    /// `None` leaves `self` alone. The destructure forces a compile
236    /// error if a new field is added without a corresponding merge
237    /// arm.
238    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        // raw_input / raw_output / meta are Option<T> on both sides.
340        // None on the update side must not clobber an existing Some.
341        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        // Input is fixed at dispatch; an empty-object `{}` update
351        // carries no content and must not clobber the populated input.
352        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        // Update only end_time + error; total_paused_ms / is_backgrounded
393        // must survive.
394        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}