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 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}