Skip to main content

phi_agent/render/
json_stream.rs

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
10/// JSON stream renderer: outputs one JSON line per event (JSONL format).
11/// Suitable for IDE integrations and programmatic consumers.
12pub 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    /// Create a new JSON stream renderer writing to the given writer.
21    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    /// Create a renderer that writes to stdout.
26    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    /// Emit an event line, attaching `agent_id` when the event carries one.
38    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            // Drafts are liveness ticks for a status line; the JSON stream is
119            // a line-oriented record of materialized events only.
120            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    // ── Sub-agent (agent_id) tests ──
379
380    #[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}