Skip to main content

lucy/
protocol.rs

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
33/// The normalized event boundary shared by the machine protocol and the TUI.
34pub trait EventSink {
35    fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()>;
36
37    /// Notify interactive frontends that the provider sent genuine reasoning
38    /// metadata. This is intentionally not part of the public protocol.
39    fn reasoning_started(&mut self) -> io::Result<()> {
40        Ok(())
41    }
42
43    /// Notify interactive frontends after the provider's reasoning metadata
44    /// has ended. This is intentionally not part of the public protocol.
45    fn reasoning_completed(&mut self) -> io::Result<()> {
46        Ok(())
47    }
48
49    /// Notify interactive frontends after an explicit skill invocation was
50    /// expanded from the immutable session snapshot. This is not a public
51    /// JSONL protocol event.
52    fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
53        Ok(())
54    }
55
56    /// Notify interactive frontends of the estimated prompt context size.
57    /// This is intentionally not part of the public JSONL protocol.
58    fn context_usage(&mut self, _tokens: usize) -> io::Result<()> {
59        Ok(())
60    }
61
62    /// Notify interactive frontends that an internal context compaction began.
63    /// This is intentionally not part of the public JSONL protocol.
64    fn compaction_started(&mut self) -> io::Result<()> {
65        Ok(())
66    }
67
68    /// Notify interactive frontends after a compaction boundary was persisted.
69    /// This is intentionally not part of the public JSONL protocol.
70    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}