Skip to main content

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}