Skip to main content

atman_runtime/
stream.rs

1use crate::notify::{NotifyLevel, NotifyLifecycle, NotifyLocation, NotifyStack};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct NotificationFrame {
6    pub level: NotifyLevel,
7    pub location: NotifyLocation,
8    pub lifecycle: NotifyLifecycle,
9    pub stack: NotifyStack,
10    pub message: String,
11}
12
13impl From<crate::notify::Notification> for NotificationFrame {
14    fn from(n: crate::notify::Notification) -> Self {
15        Self {
16            level: n.level,
17            location: n.location,
18            lifecycle: n.lifecycle,
19            stack: n.stack,
20            message: n.message,
21        }
22    }
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
26#[serde(rename_all = "snake_case")]
27pub enum CompactionPhase {
28    Running,
29    Finished,
30    Failed,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub enum StreamFrame {
35    LlmChunk {
36        text: String,
37        model: String,
38    },
39    ThinkingChunk {
40        text: String,
41    },
42    LlmDone {
43        total_tokens: u64,
44    },
45    LlmCallStats {
46        model: String,
47        input_tokens: u64,
48        output_tokens: u64,
49        cache_read: u64,
50        cache_write: u64,
51        ttft_ms: u64,
52        tokens_per_second: f64,
53        wallclock_ms: u64,
54        run_id: Option<String>,
55        node_id: Option<String>,
56    },
57    ToolUseStart {
58        tool: String,
59        args_preview: String,
60        id: String,
61    },
62    ToolUseDone {
63        tool: String,
64        ok: bool,
65        preview: String,
66        id: String,
67    },
68    Note(String),
69    /// Rich notification with level/location/lifecycle/stack.
70    Notification(NotificationFrame),
71    FlowGraph {
72        run_id: String,
73        graph: crate::nodegraph::FlowGraph,
74    },
75    FlowStart {
76        run_id: String,
77        flow_name: String,
78        #[serde(default)]
79        parent_run_id: Option<String>,
80        #[serde(default)]
81        parent_node_id: Option<String>,
82    },
83    FlowNodeStart {
84        run_id: String,
85        node_id: String,
86        kind: crate::nodegraph::NodeKind,
87        label: String,
88        #[serde(default)]
89        parent_node_id: Option<String>,
90    },
91    FlowNodeEnd {
92        run_id: String,
93        node_id: String,
94        status: crate::event::FlowNodeStatus,
95        output_preview: Option<String>,
96        #[serde(default)]
97        parent_node_id: Option<String>,
98    },
99    FlowDone {
100        run_id: String,
101        flow_name: String,
102        ok: bool,
103        #[serde(default)]
104        cancelled: bool,
105    },
106    ToolNode {
107        run_id: String,
108        parent_node_id: String,
109        tool_use_id: String,
110        tool: String,
111        args_preview: String,
112    },
113    AssistantMsg {
114        flow_run_id: Option<String>,
115        message: crate::message::Message,
116    },
117    ToolResultMsg {
118        flow_run_id: Option<String>,
119        message: crate::message::Message,
120    },
121    ToolPendingApproval {
122        run_id: String,
123        tool_use_id: String,
124        tool_name: String,
125        args_preview: String,
126        level: String,
127        #[serde(default, skip_serializing_if = "Option::is_none")]
128        preview: Option<String>,
129    },
130    ToolApproved {
131        run_id: String,
132        tool_use_id: String,
133        decided_by: String,
134    },
135    ToolDenied {
136        run_id: String,
137        tool_use_id: String,
138        reason: String,
139    },
140    TerminalChunk {
141        handle: String,
142        bytes: Vec<u8>,
143        screen: Option<crate::tools::term::TerminalScreen>,
144        state: crate::tools::term::TermStateSnapshot,
145    },
146    TerminalExited {
147        handle: String,
148        exit_code: Option<i32>,
149    },
150    BashChunk {
151        handle: String,
152        kind: String,
153        line: String,
154    },
155    BashExited {
156        handle: String,
157        exit_code: Option<i32>,
158    },
159    DiffPreview {
160        title: String,
161        old_content: Option<String>,
162        new_content: Option<String>,
163        unified_diff: Option<String>,
164    },
165    CompactionSummary {
166        phase: CompactionPhase,
167        range_start: usize,
168        range_end: usize,
169        summary: String,
170        before_tokens: u64,
171        after_tokens: u64,
172        compacted_count: usize,
173    },
174    MermaidDiagram {
175        source: String,
176    },
177    #[serde(other)]
178    Unknown,
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn tool_node_round_trips() {
187        let f = StreamFrame::ToolNode {
188            run_id: "r".into(),
189            parent_node_id: "stmt_0".into(),
190            tool_use_id: "tu_1".into(),
191            tool: "fs.read".into(),
192            args_preview: "{}".into(),
193        };
194        let json = serde_json::to_string(&f).unwrap();
195        let back: StreamFrame = serde_json::from_str(&json).unwrap();
196        assert!(matches!(back, StreamFrame::ToolNode { .. }));
197    }
198
199    #[test]
200    fn flow_node_start_serde_carries_parent() {
201        let f = StreamFrame::FlowNodeStart {
202            run_id: "r".into(),
203            node_id: "stmt_1.branch[0]".into(),
204            kind: crate::nodegraph::NodeKind::UserConfirm,
205            label: "b".into(),
206            parent_node_id: Some("stmt_1".into()),
207        };
208        let json = serde_json::to_string(&f).unwrap();
209        assert!(json.contains("\"parent_node_id\":\"stmt_1\""));
210        let back: StreamFrame = serde_json::from_str(&json).unwrap();
211        if let StreamFrame::FlowNodeStart { parent_node_id, .. } = back {
212            assert_eq!(parent_node_id.as_deref(), Some("stmt_1"));
213        } else {
214            panic!("wrong variant");
215        }
216    }
217
218    #[test]
219    fn unknown_bare_variant_falls_back() {
220        let payload = r#""SomeFutureFrame""#;
221        let back: StreamFrame = serde_json::from_str(payload).unwrap();
222        assert!(matches!(back, StreamFrame::Unknown));
223    }
224
225    #[test]
226    fn terminal_chunk_round_trips() {
227        let screen = crate::tools::term::TerminalScreen {
228            rows: 2,
229            cols: 3,
230            cells: vec![
231                crate::tools::term::TerminalCell {
232                    chars: "A".into(),
233                    ..Default::default()
234                },
235                crate::tools::term::TerminalCell::default(),
236                crate::tools::term::TerminalCell::default(),
237                crate::tools::term::TerminalCell::default(),
238                crate::tools::term::TerminalCell::default(),
239                crate::tools::term::TerminalCell::default(),
240            ],
241            cursor: Some((0, 0)),
242            alt_screen: false,
243        };
244        let f = StreamFrame::TerminalChunk {
245            handle: "term_s_0".into(),
246            bytes: b"hi".to_vec(),
247            screen: Some(screen),
248            state: crate::tools::term::TermStateSnapshot::Running,
249        };
250        let json = serde_json::to_string(&f).unwrap();
251        let back: StreamFrame = serde_json::from_str(&json).unwrap();
252        match back {
253            StreamFrame::TerminalChunk {
254                handle,
255                bytes,
256                screen,
257                state,
258            } => {
259                assert_eq!(handle, "term_s_0");
260                assert_eq!(bytes, b"hi");
261                let screen = screen.expect("screen should be Some");
262                assert_eq!(screen.rows, 2);
263                assert_eq!(screen.cols, 3);
264                assert_eq!(screen.cells.len(), 6);
265                assert_eq!(screen.cells[0].chars, "A");
266                assert!(matches!(
267                    state,
268                    crate::tools::term::TermStateSnapshot::Running
269                ));
270            }
271            _ => panic!("wrong variant"),
272        }
273    }
274
275    #[test]
276    fn terminal_exited_round_trips() {
277        let f = StreamFrame::TerminalExited {
278            handle: "term_s_1".into(),
279            exit_code: Some(0),
280        };
281        let json = serde_json::to_string(&f).unwrap();
282        let back: StreamFrame = serde_json::from_str(&json).unwrap();
283        match back {
284            StreamFrame::TerminalExited { handle, exit_code } => {
285                assert_eq!(handle, "term_s_1");
286                assert_eq!(exit_code, Some(0));
287            }
288            _ => panic!("wrong variant"),
289        }
290    }
291
292    #[test]
293    fn compaction_summary_round_trips() {
294        let f = StreamFrame::CompactionSummary {
295            phase: CompactionPhase::Running,
296            range_start: 3,
297            range_end: 11,
298            summary: String::new(),
299            before_tokens: 42,
300            after_tokens: 0,
301            compacted_count: 8,
302        };
303        let json = serde_json::to_string(&f).unwrap();
304        let back: StreamFrame = serde_json::from_str(&json).unwrap();
305        match back {
306            StreamFrame::CompactionSummary {
307                phase,
308                range_start,
309                range_end,
310                compacted_count,
311                ..
312            } => {
313                assert_eq!(phase, CompactionPhase::Running);
314                assert_eq!(range_start, 3);
315                assert_eq!(range_end, 11);
316                assert_eq!(compacted_count, 8);
317            }
318            _ => panic!("wrong variant"),
319        }
320    }
321}