zeph_core/json_event_sink.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `JsonEventSink`: the single stdout writer for `--json` mode.
5//!
6//! All JSON events in a `--json` session are emitted through a shared
7//! `Arc<JsonEventSink>`. The internal `Mutex<Stdout>` ensures that concurrent
8//! emitters from different tasks cannot interleave partial lines.
9//!
10//! # Ordering guarantee
11//!
12//! Events emitted from the same thread preserve their issuance order. Events
13//! from concurrent threads interleave in mutex-acquisition order. Within a
14//! single response, `response_chunk` events precede `response_end`. Tool events
15//! may interleave with chunks when tools run mid-stream (normal for agent loops).
16//!
17//! # Lock discipline
18//!
19//! `emit` holds the lock only for serialization + write + flush. It never
20//! `.await`s while holding the lock, satisfying invariant §10.
21
22use std::io::{self, Write};
23use std::sync::Mutex;
24
25use serde::Serialize;
26
27#[non_exhaustive]
28/// Structured event emitted on stdout in `--json` mode.
29///
30/// All variants are serialized as JSONL with a `"event"` discriminator field.
31#[derive(Serialize)]
32#[serde(tag = "event", rename_all = "snake_case")]
33pub enum JsonEvent<'a> {
34 /// Session boot banner emitted before the first prompt.
35 Boot {
36 version: &'a str,
37 bare: bool,
38 auto: bool,
39 /// `--safe-mode`/`ZEPH_SAFE_MODE` (#6031): customizations (ZEPH.md/CLAUDE.md/AGENTS.md,
40 /// plugins, skills, hooks, MCP servers) are disabled for this session.
41 safe_mode: bool,
42 },
43 /// User input received from stdin.
44 Query { text: &'a str, queue_len: usize },
45 /// Streaming assistant text chunk.
46 ResponseChunk { text: &'a str },
47 /// End-of-response marker.
48 ResponseEnd,
49 /// A tool invocation is about to run.
50 ToolCall {
51 tool: &'a str,
52 args: &'a serde_json::Value,
53 id: &'a str,
54 },
55 /// A tool returned a result.
56 ToolResult {
57 tool: &'a str,
58 id: &'a str,
59 output: &'a str,
60 is_error: bool,
61 },
62 /// Token counts and estimated cost summary.
63 Cost {
64 input_tokens: u64,
65 output_tokens: u64,
66 total_usd: f64,
67 },
68 /// Loop tick notification fired each `/loop` iteration.
69 LoopTick {
70 iteration: u64,
71 total_ticks: u64,
72 prompt_preview: &'a str,
73 },
74 /// Slash command acknowledgement — distinguishes `/loop start` confirmation
75 /// from regular assistant output in JSON streams.
76 CommandAck { command: &'a str, text: &'a str },
77 /// General status message (equivalent to spinner text in interactive channels).
78 Status { message: &'a str },
79 /// Terminal error emitted before the process exits.
80 Error { message: &'a str },
81}
82
83/// The single stdout writer for `--json` mode.
84///
85/// Wrap in `Arc` and share between `JsonCliChannel` and `JsonEventLayer`.
86/// `emit` is synchronous and lock-bounded: it never yields across `.await`.
87pub struct JsonEventSink {
88 writer: Mutex<Box<dyn Write + Send>>,
89}
90
91impl std::fmt::Debug for JsonEventSink {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.debug_struct("JsonEventSink").finish_non_exhaustive()
94 }
95}
96
97impl JsonEventSink {
98 /// Create a new sink that writes to the process's stdout.
99 #[must_use]
100 pub fn new() -> Self {
101 Self {
102 writer: Mutex::new(Box::new(io::stdout())),
103 }
104 }
105
106 /// Create a sink backed by an arbitrary [`Write`] implementation.
107 ///
108 /// Intended for testing: pass a type that implements [`Write`] + [`Send`] + `'static`,
109 /// such as [`std::io::Cursor`]`<Vec<u8>>`, to capture emitted JSONL lines in memory.
110 ///
111 /// # Examples
112 ///
113 /// ```
114 /// use std::io::Cursor;
115 /// use std::sync::Arc;
116 /// use zeph_core::json_event_sink::{JsonEvent, JsonEventSink};
117 ///
118 /// let sink = Arc::new(JsonEventSink::with_writer(Cursor::new(Vec::<u8>::new())));
119 /// sink.emit(&JsonEvent::Status { message: "hello" });
120 /// ```
121 #[must_use]
122 pub fn with_writer(w: impl Write + Send + 'static) -> Self {
123 Self {
124 writer: Mutex::new(Box::new(w)),
125 }
126 }
127
128 /// Serialize `event` as a JSON line and write it to the underlying writer.
129 ///
130 /// Silently drops the event when the mutex is poisoned or serialization fails.
131 /// This is intentional: a JSON output failure must not crash the agent.
132 pub fn emit(&self, event: &JsonEvent<'_>) {
133 let Ok(mut w) = self.writer.lock() else {
134 return;
135 };
136 if let Ok(line) = serde_json::to_string(event) {
137 let _ = writeln!(w, "{line}");
138 let _ = w.flush();
139 }
140 }
141}
142
143impl Default for JsonEventSink {
144 fn default() -> Self {
145 Self::new()
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn boot_event_serializes_correctly() {
155 let event = JsonEvent::Boot {
156 version: "0.1.0",
157 bare: true,
158 auto: false,
159 safe_mode: false,
160 };
161 let s = serde_json::to_string(&event).unwrap();
162 assert!(s.contains("\"event\":\"boot\""));
163 assert!(s.contains("\"version\":\"0.1.0\""));
164 assert!(s.contains("\"bare\":true"));
165 assert!(s.contains("\"safe_mode\":false"));
166 }
167
168 #[test]
169 fn response_end_serializes_without_fields() {
170 let event = JsonEvent::ResponseEnd;
171 let s = serde_json::to_string(&event).unwrap();
172 assert_eq!(s, r#"{"event":"response_end"}"#);
173 }
174
175 #[test]
176 fn emit_does_not_panic_on_concurrent_use() {
177 use std::sync::Arc;
178 use std::thread;
179
180 let sink = Arc::new(JsonEventSink::new());
181 let handles: Vec<_> = (0..4)
182 .map(|i| {
183 let s = Arc::clone(&sink);
184 thread::spawn(move || {
185 for _ in 0..10 {
186 s.emit(&JsonEvent::Status {
187 message: &format!("thread {i}"),
188 });
189 }
190 })
191 })
192 .collect();
193 for h in handles {
194 h.join().unwrap();
195 }
196 }
197}