1#![allow(missing_docs)]
2
3use std::io::{self, Write};
4
5use agent_base::{AgentResult, RuntimeEvent, UserEvent};
6use serde_json::{Value, json};
7
8use crate::render::EventRenderer;
9
10pub struct JsonStreamRenderer {
13 writer: Box<dyn Write + Send>,
14 turn_start: Option<std::time::Instant>,
15 tool_call_count: u32,
16 last_assistant_text: String,
17}
18
19impl JsonStreamRenderer {
20 pub fn new(writer: Box<dyn Write + Send>) -> Self {
22 Self { writer, turn_start: None, tool_call_count: 0, last_assistant_text: String::new() }
23 }
24
25 pub fn stdout() -> Self {
27 Self::new(Box::new(io::stdout()))
28 }
29
30 fn emit(&mut self, value: &Value) -> AgentResult<()> {
31 let line = serde_json::to_string(value)
32 .map_err(|e| agent_base::AgentError::internal(format!("JSON serialize error: {e}")))?;
33 writeln!(self.writer, "{}", line).map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
34 Ok(())
35 }
36
37 fn emit_event(&mut self, event: &RuntimeEvent, mut value: Value) -> AgentResult<()> {
39 if let Some(agent_id) = event.agent_id() {
40 value["agent_id"] = json!(agent_id);
41 }
42 self.emit(&value)
43 }
44}
45
46impl EventRenderer for JsonStreamRenderer {
47 fn render(&mut self, event: RuntimeEvent) -> AgentResult<()> {
48 if self.turn_start.is_none() {
49 self.turn_start = Some(std::time::Instant::now());
50 }
51
52 match &event {
53 RuntimeEvent::ThoughtDelta { text, .. } => {
54 self.emit_event(&event, json!({ "type": "thought_delta", "text": text }))?;
55 },
56 RuntimeEvent::TextDelta { text, .. } => {
57 if event.agent_id().is_none() {
58 self.last_assistant_text.push_str(text);
59 }
60 self.emit_event(&event, json!({ "type": "text_delta", "text": text }))?;
61 },
62 RuntimeEvent::ToolCallStarted { tool_name, args_json, .. } => {
63 self.tool_call_count += 1;
64 let args: Value = serde_json::from_str(args_json).unwrap_or(Value::Null);
65 self.emit_event(
66 &event,
67 json!({
68 "type": "tool_call_started",
69 "tool": tool_name,
70 "args": args,
71 }),
72 )?;
73 },
74 RuntimeEvent::ToolCallFinished { tool_name, summary, denied, .. } => {
75 self.emit_event(
76 &event,
77 json!({
78 "type": "tool_call_finished",
79 "tool": tool_name,
80 "summary": summary,
81 "denied": denied,
82 }),
83 )?;
84 },
85 RuntimeEvent::AwaitingApproval { request, .. } => {
86 self.emit_event(
87 &event,
88 json!({
89 "type": "approval_request",
90 "title": request.title,
91 "risk": format!("{:?}", request.risk_level),
92 "message": request.message,
93 }),
94 )?;
95 },
96 RuntimeEvent::PlanUpdated { explanation, plan, .. } => {
97 self.emit_event(
98 &event,
99 json!({
100 "type": "plan_updated",
101 "explanation": explanation,
102 "plan": plan,
103 }),
104 )?;
105 },
106 RuntimeEvent::UserEvent { event: UserEvent::Structured { event_type, data }, .. } => {
107 self.emit_event(
108 &event,
109 json!({
110 "type": "user_event",
111 "event_type": event_type,
112 "data": data,
113 }),
114 )?;
115 },
116 RuntimeEvent::UserEvent { .. } => {},
117 RuntimeEvent::Checkpoint { .. } => {},
118 RuntimeEvent::ToolCallDraft { .. } => {},
121 RuntimeEvent::RunFinished { .. } => {},
122 RuntimeEvent::RunCancelled { .. } => {
123 self.emit_event(&event, json!({ "type": "run_cancelled" }))?;
124 },
125 }
126
127 Ok(())
128 }
129
130 fn finish_turn(&mut self) -> AgentResult<()> {
131 let duration_ms = self.turn_start.map(|s| s.elapsed().as_millis() as u64).unwrap_or(0);
132
133 self.emit(&json!({
134 "type": "turn_finished",
135 "duration_ms": duration_ms,
136 "tool_call_count": self.tool_call_count,
137 "assistant_text": self.last_assistant_text.trim(),
138 }))?;
139
140 self.turn_start = None;
141 self.tool_call_count = 0;
142 self.last_assistant_text.clear();
143
144 Ok(())
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use agent_base::{ApprovalRequest, PlanItem, PlanStepStatus, RiskLevel, SessionId, UserEvent};
152 use std::io::Write;
153 use std::sync::{Arc, Mutex};
154
155 struct SharedWriter {
156 inner: Arc<Mutex<Vec<u8>>>,
157 }
158
159 impl Write for SharedWriter {
160 fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
161 self.inner.lock().unwrap().extend_from_slice(data);
162 Ok(data.len())
163 }
164 fn flush(&mut self) -> std::io::Result<()> {
165 Ok(())
166 }
167 }
168
169 impl SharedWriter {
170 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
171 let inner = Arc::new(Mutex::new(Vec::new()));
172 (Self { inner: inner.clone() }, inner)
173 }
174 }
175
176 fn session_id() -> SessionId {
177 SessionId { id: 1, external_id: None }
178 }
179
180 fn render_one(event: RuntimeEvent) -> Vec<String> {
181 let (writer, buf) = SharedWriter::new();
182 let mut r = JsonStreamRenderer::new(Box::new(writer));
183 r.render(event).unwrap();
184 drop(r);
185 let text = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
186 if text.is_empty() { vec![] } else { text.lines().map(|l| l.to_string()).collect() }
187 }
188
189 fn render_and_finish(events: &[RuntimeEvent]) -> Vec<String> {
190 let (writer, buf) = SharedWriter::new();
191 let mut r = JsonStreamRenderer::new(Box::new(writer));
192 for e in events {
193 r.render(e.clone()).unwrap();
194 }
195 r.finish_turn().unwrap();
196 drop(r);
197 let text = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
198 text.lines().map(|l| l.to_string()).collect()
199 }
200
201 #[test]
202 fn test_text_delta_produces_valid_json() {
203 let lines = render_one(RuntimeEvent::TextDelta {
204 session_id: session_id(),
205 text: "hello".into(),
206 agent_id: None,
207 trace_id: None,
208 });
209 assert_eq!(lines.len(), 1);
210 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
211 assert_eq!(v["type"], "text_delta");
212 assert_eq!(v["text"], "hello");
213 }
214
215 #[test]
216 fn test_thought_delta_produces_valid_json() {
217 let lines = render_one(RuntimeEvent::ThoughtDelta {
218 session_id: session_id(),
219 text: "thinking...".into(),
220 agent_id: None,
221 trace_id: None,
222 });
223 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
224 assert_eq!(v["type"], "thought_delta");
225 }
226
227 #[test]
228 fn test_tool_call_started_parses_args() {
229 let lines = render_one(RuntimeEvent::ToolCallStarted {
230 session_id: session_id(),
231 tool_name: "shell".into(),
232 args_json: r#"{"cmd":"ls"}"#.into(),
233 agent_id: None,
234 trace_id: None,
235 });
236 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
237 assert_eq!(v["type"], "tool_call_started");
238 assert_eq!(v["tool"], "shell");
239 assert_eq!(v["args"]["cmd"], "ls");
240 }
241
242 #[test]
243 fn test_tool_call_finished_produces_valid_json() {
244 let lines = render_one(RuntimeEvent::ToolCallFinished {
245 session_id: session_id(),
246 tool_name: "shell".into(),
247 summary: "done".into(),
248 agent_id: None,
249 trace_id: None,
250 denied: false,
251 details: None,
252 });
253 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
254 assert_eq!(v["type"], "tool_call_finished");
255 assert_eq!(v["tool"], "shell");
256 assert_eq!(v["summary"], "done");
257 assert_eq!(v["denied"], false);
258 }
259
260 #[test]
261 fn test_awaiting_approval_produces_valid_json() {
262 let lines = render_one(RuntimeEvent::AwaitingApproval {
263 session_id: session_id(),
264 request: ApprovalRequest {
265 title: "Delete".into(),
266 message: "Dangerous".into(),
267 action_key: None,
268 risk_level: RiskLevel::Destructive,
269 raw: None,
270 source: None,
271 },
272 agent_id: None,
273 trace_id: None,
274 });
275 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
276 assert_eq!(v["type"], "approval_request");
277 assert_eq!(v["title"], "Delete");
278 }
279
280 #[test]
281 fn test_plan_updated_produces_valid_json() {
282 let lines = render_one(RuntimeEvent::PlanUpdated {
283 session_id: session_id(),
284 objective: "test".into(),
285 explanation: Some("step 1 done".into()),
286 plan: vec![PlanItem { step: "Step 1".into(), status: PlanStepStatus::Completed }],
287 agent_id: None,
288 trace_id: None,
289 });
290 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
291 assert_eq!(v["type"], "plan_updated");
292 }
293
294 #[test]
295 fn test_user_event_structured() {
296 let lines = render_one(RuntimeEvent::UserEvent {
297 session_id: session_id(),
298 event: UserEvent::Structured { event_type: "custom".into(), data: serde_json::json!({"key": "value"}) },
299 agent_id: None,
300 trace_id: None,
301 });
302 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
303 assert_eq!(v["type"], "user_event");
304 assert_eq!(v["event_type"], "custom");
305 assert_eq!(v["data"]["key"], "value");
306 }
307
308 #[test]
309 fn test_user_event_progress_ignored() {
310 let lines = render_one(RuntimeEvent::UserEvent {
311 session_id: session_id(),
312 event: UserEvent::Progress { text: "loading...".into() },
313 agent_id: None,
314 trace_id: None,
315 });
316 assert!(lines.is_empty());
317 }
318
319 #[test]
320 fn test_run_finished_no_output() {
321 let lines = render_one(RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None });
322 assert!(lines.is_empty());
323 }
324
325 #[test]
326 fn test_run_cancelled_produces_valid_json() {
327 let lines = render_one(RuntimeEvent::RunCancelled { session_id: session_id(), agent_id: None, trace_id: None });
328 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
329 assert_eq!(v["type"], "run_cancelled");
330 }
331
332 #[test]
333 fn test_finish_turn_emits_summary() {
334 let lines = render_and_finish(&[RuntimeEvent::TextDelta {
335 session_id: session_id(),
336 text: "hello".into(),
337 agent_id: None,
338 trace_id: None,
339 }]);
340 let last: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
341 assert_eq!(last["type"], "turn_finished");
342 assert!(last["duration_ms"].as_u64().is_some());
343 assert_eq!(last["tool_call_count"], 0);
344 }
345
346 #[test]
347 fn test_tool_call_count_incremented() {
348 let lines = render_and_finish(&[
349 RuntimeEvent::ToolCallStarted {
350 session_id: session_id(),
351 tool_name: "a".into(),
352 args_json: "{}".into(),
353 agent_id: None,
354 trace_id: None,
355 },
356 RuntimeEvent::ToolCallStarted {
357 session_id: session_id(),
358 tool_name: "b".into(),
359 args_json: "{}".into(),
360 agent_id: None,
361 trace_id: None,
362 },
363 ]);
364 let last: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
365 assert_eq!(last["tool_call_count"], 2);
366 }
367
368 #[test]
369 fn test_assistant_text_accumulated() {
370 let lines = render_and_finish(&[
371 RuntimeEvent::TextDelta { session_id: session_id(), text: "Hello ".into(), agent_id: None, trace_id: None },
372 RuntimeEvent::TextDelta { session_id: session_id(), text: "World".into(), agent_id: None, trace_id: None },
373 ]);
374 let last: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
375 assert_eq!(last["assistant_text"], "Hello World");
376 }
377
378 #[test]
381 fn test_subagent_text_delta_includes_agent_id() {
382 let lines = render_one(RuntimeEvent::TextDelta {
383 session_id: session_id(),
384 text: "found 3 items".into(),
385 agent_id: Some("root/searcher".into()),
386 trace_id: None,
387 });
388 assert_eq!(lines.len(), 1);
389 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
390 assert_eq!(v["type"], "text_delta");
391 assert_eq!(v["text"], "found 3 items");
392 assert_eq!(v["agent_id"], "root/searcher");
393 }
394
395 #[test]
396 fn test_subagent_tool_call_includes_agent_id() {
397 let lines = render_one(RuntimeEvent::ToolCallStarted {
398 session_id: session_id(),
399 tool_name: "shell".into(),
400 args_json: r#"{"cmd":"ls"}"#.into(),
401 agent_id: Some("root/worker".into()),
402 trace_id: None,
403 });
404 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
405 assert_eq!(v["type"], "tool_call_started");
406 assert_eq!(v["tool"], "shell");
407 assert_eq!(v["agent_id"], "root/worker");
408 }
409
410 #[test]
411 fn test_root_event_omits_agent_id() {
412 let lines = render_one(RuntimeEvent::TextDelta {
413 session_id: session_id(),
414 text: "hello".into(),
415 agent_id: None,
416 trace_id: None,
417 });
418 let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
419 assert!(v.get("agent_id").is_none());
420 }
421
422 #[test]
423 fn test_subagent_text_does_not_pollute_assistant_text() {
424 let lines = render_and_finish(&[
425 RuntimeEvent::TextDelta { session_id: session_id(), text: "root ".into(), agent_id: None, trace_id: None },
426 RuntimeEvent::TextDelta {
427 session_id: session_id(),
428 text: "child".into(),
429 agent_id: Some("root/child".into()),
430 trace_id: None,
431 },
432 ]);
433 let last: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
434 assert_eq!(last["assistant_text"], "root");
435 }
436}