1use std::io::{self, Write};
2
3use serde::Serialize;
4use serde_json::Value;
5
6#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
7#[serde(tag = "type")]
8pub enum ProtocolEvent {
9 #[serde(rename = "session")]
10 Session { session_id: String, resumed: bool },
11 #[serde(rename = "assistant_delta")]
12 AssistantDelta { text: String },
13 #[serde(rename = "tool_call")]
14 ToolCall {
15 id: String,
16 name: String,
17 arguments: String,
18 },
19 #[serde(rename = "tool_result")]
20 ToolResult {
21 id: String,
22 name: String,
23 result: Value,
24 },
25 #[serde(rename = "turn_end")]
26 TurnEnd,
27 #[serde(rename = "turn_interrupted")]
28 TurnInterrupted { reason: String, phase: String },
29 #[serde(rename = "error")]
30 Error { message: String },
31}
32
33pub trait EventSink {
35 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()>;
36
37 fn reasoning_started(&mut self) -> io::Result<()> {
40 Ok(())
41 }
42
43 fn reasoning_completed(&mut self) -> io::Result<()> {
46 Ok(())
47 }
48
49 fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
53 Ok(())
54 }
55
56 fn context_usage(&mut self, _tokens: usize) -> io::Result<()> {
59 Ok(())
60 }
61
62 fn compaction_started(&mut self) -> io::Result<()> {
65 Ok(())
66 }
67
68 fn compaction_finished(
71 &mut self,
72 _tokens_before: usize,
73 _tokens_after: usize,
74 ) -> io::Result<()> {
75 Ok(())
76 }
77}
78
79pub struct ProtocolWriter<W> {
80 writer: W,
81}
82
83impl<W: Write> ProtocolWriter<W> {
84 pub fn new(writer: W) -> Self {
85 Self { writer }
86 }
87
88 pub fn emit(&mut self, event: &ProtocolEvent) -> io::Result<()> {
89 self.emit_serializable(event)
90 }
91
92 pub fn emit_serializable<T: Serialize>(&mut self, record: &T) -> io::Result<()> {
93 serde_json::to_writer(&mut self.writer, record)
94 .map_err(|error| io::Error::other(format!("encode protocol event: {error}")))?;
95 self.writer.write_all(b"\n")?;
96 self.writer.flush()
97 }
98
99 pub fn session(&mut self, session_id: &str, resumed: bool) -> io::Result<()> {
100 self.emit(&ProtocolEvent::Session {
101 session_id: session_id.to_owned(),
102 resumed,
103 })
104 }
105
106 pub fn assistant_delta(&mut self, text: &str) -> io::Result<()> {
107 if text.is_empty() {
108 return Ok(());
109 }
110 self.emit(&ProtocolEvent::AssistantDelta {
111 text: text.to_owned(),
112 })
113 }
114
115 pub fn tool_call(&mut self, id: &str, name: &str, arguments: &str) -> io::Result<()> {
116 self.emit(&ProtocolEvent::ToolCall {
117 id: id.to_owned(),
118 name: name.to_owned(),
119 arguments: arguments.to_owned(),
120 })
121 }
122
123 pub fn tool_result(&mut self, id: &str, name: &str, result: Value) -> io::Result<()> {
124 self.emit(&ProtocolEvent::ToolResult {
125 id: id.to_owned(),
126 name: name.to_owned(),
127 result,
128 })
129 }
130
131 pub fn turn_end(&mut self) -> io::Result<()> {
132 self.emit(&ProtocolEvent::TurnEnd)
133 }
134
135 pub fn turn_interrupted(&mut self, reason: &str, phase: &str) -> io::Result<()> {
136 self.emit(&ProtocolEvent::TurnInterrupted {
137 reason: reason.to_owned(),
138 phase: phase.to_owned(),
139 })
140 }
141
142 pub fn error(&mut self, message: &str) -> io::Result<()> {
143 self.emit(&ProtocolEvent::Error {
144 message: message.to_owned(),
145 })
146 }
147}
148
149impl<W: Write> EventSink for ProtocolWriter<W> {
150 fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
151 self.emit(event)
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn protocol_writer_emits_only_single_line_json_records() {
161 let mut output = Vec::new();
162 {
163 let mut writer = ProtocolWriter::new(&mut output);
164 writer.assistant_delta("line one\nline two").expect("event");
165 writer
166 .tool_result(
167 "call-1",
168 "cmd",
169 serde_json::json!({"stdout":"provider-shape-is-not-forwarded"}),
170 )
171 .expect("result");
172 }
173 let text = String::from_utf8(output).expect("UTF-8");
174 assert_eq!(text.lines().count(), 2);
175 for line in text.lines() {
176 serde_json::from_str::<Value>(line).expect("JSONL record");
177 }
178 assert!(!text.contains("choices"));
179 }
180
181 #[test]
182 fn compaction_frontend_state_is_not_emitted_to_jsonl() {
183 let mut output = Vec::new();
184 let mut writer = ProtocolWriter::new(&mut output);
185 writer.context_usage(100).expect("context usage");
186 writer.compaction_started().expect("compaction start");
187 writer
188 .compaction_finished(100, 20)
189 .expect("compaction finish");
190
191 assert!(output.is_empty());
192 }
193
194 #[test]
195 fn skill_attachment_state_is_not_emitted_to_jsonl() {
196 let mut output = Vec::new();
197 let mut writer = ProtocolWriter::new(&mut output);
198 writer
199 .skill_instruction_attached("release-notes")
200 .expect("non-public TUI state");
201 assert!(output.is_empty());
202 }
203
204 #[test]
205 fn interruption_event_is_a_normalized_json_record() {
206 let event = ProtocolEvent::TurnInterrupted {
207 reason: "user_cancelled".to_owned(),
208 phase: "provider_stream".to_owned(),
209 };
210 let value = serde_json::to_value(event).expect("event JSON");
211 assert_eq!(value["type"], "turn_interrupted");
212 assert_eq!(value["reason"], "user_cancelled");
213 assert_eq!(value["phase"], "provider_stream");
214 assert!(!serde_json::to_string(&value)
215 .expect("serialized event")
216 .contains("choices"));
217 }
218}