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 = "background_result_pending")]
26    BackgroundResultPending {
27        completion_id: String,
28        task_id: String,
29        child_session_id: String,
30        status: String,
31        result: Value,
32        completed_at: u64,
33    },
34    #[serde(rename = "background_result_delivered")]
35    BackgroundResultDelivered {
36        completion_id: String,
37        task_id: String,
38        logical_turn_id: String,
39        delivery: String,
40    },
41    #[serde(rename = "turn_end")]
42    TurnEnd,
43    #[serde(rename = "turn_interrupted")]
44    TurnInterrupted { reason: String, phase: String },
45    #[serde(rename = "error")]
46    Error { message: String },
47}
48
49/// The normalized event boundary shared by the machine protocol and the TUI.
50pub trait EventSink {
51    fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()>;
52
53    /// Notify interactive frontends that the provider sent genuine reasoning
54    /// metadata. This is intentionally not part of the public protocol.
55    fn reasoning_started(&mut self) -> io::Result<()> {
56        Ok(())
57    }
58
59    /// Notify interactive frontends after the provider's reasoning metadata
60    /// has ended. This is intentionally not part of the public protocol.
61    fn reasoning_completed(&mut self) -> io::Result<()> {
62        Ok(())
63    }
64
65    /// Notify interactive frontends after an explicit skill invocation was
66    /// expanded from the immutable session snapshot. This is not a public
67    /// JSONL protocol event.
68    fn skill_instruction_attached(&mut self, _name: &str) -> io::Result<()> {
69        Ok(())
70    }
71
72    /// Notify interactive frontends of the estimated prompt context size.
73    /// This is intentionally not part of the public JSONL protocol.
74    fn context_usage(&mut self, _tokens: usize) -> io::Result<()> {
75        Ok(())
76    }
77
78    /// Notify interactive frontends that an internal context compaction began.
79    /// This is intentionally not part of the public JSONL protocol.
80    fn compaction_started(&mut self) -> io::Result<()> {
81        Ok(())
82    }
83
84    /// Notify interactive frontends after a compaction boundary was persisted.
85    /// This is intentionally not part of the public JSONL protocol.
86    fn compaction_finished(
87        &mut self,
88        _tokens_before: usize,
89        _tokens_after: usize,
90    ) -> io::Result<()> {
91        Ok(())
92    }
93}
94
95pub struct ProtocolWriter<W> {
96    writer: W,
97}
98
99impl<W: Write> ProtocolWriter<W> {
100    pub fn new(writer: W) -> Self {
101        Self { writer }
102    }
103
104    pub fn emit(&mut self, event: &ProtocolEvent) -> io::Result<()> {
105        self.emit_serializable(event)
106    }
107
108    pub fn emit_serializable<T: Serialize>(&mut self, record: &T) -> io::Result<()> {
109        serde_json::to_writer(&mut self.writer, record)
110            .map_err(|error| io::Error::other(format!("encode protocol event: {error}")))?;
111        self.writer.write_all(b"\n")?;
112        self.writer.flush()
113    }
114
115    pub fn session(&mut self, session_id: &str, resumed: bool) -> io::Result<()> {
116        self.emit(&ProtocolEvent::Session {
117            session_id: session_id.to_owned(),
118            resumed,
119        })
120    }
121
122    pub fn assistant_delta(&mut self, text: &str) -> io::Result<()> {
123        if text.is_empty() {
124            return Ok(());
125        }
126        self.emit(&ProtocolEvent::AssistantDelta {
127            text: text.to_owned(),
128        })
129    }
130
131    pub fn tool_call(&mut self, id: &str, name: &str, arguments: &str) -> io::Result<()> {
132        self.emit(&ProtocolEvent::ToolCall {
133            id: id.to_owned(),
134            name: name.to_owned(),
135            arguments: arguments.to_owned(),
136        })
137    }
138
139    pub fn tool_result(&mut self, id: &str, name: &str, result: Value) -> io::Result<()> {
140        self.emit(&ProtocolEvent::ToolResult {
141            id: id.to_owned(),
142            name: name.to_owned(),
143            result,
144        })
145    }
146
147    pub fn turn_end(&mut self) -> io::Result<()> {
148        self.emit(&ProtocolEvent::TurnEnd)
149    }
150
151    pub fn turn_interrupted(&mut self, reason: &str, phase: &str) -> io::Result<()> {
152        self.emit(&ProtocolEvent::TurnInterrupted {
153            reason: reason.to_owned(),
154            phase: phase.to_owned(),
155        })
156    }
157
158    pub fn error(&mut self, message: &str) -> io::Result<()> {
159        self.emit(&ProtocolEvent::Error {
160            message: message.to_owned(),
161        })
162    }
163}
164
165impl<W: Write> EventSink for ProtocolWriter<W> {
166    fn emit_event(&mut self, event: &ProtocolEvent) -> io::Result<()> {
167        self.emit(event)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn protocol_writer_emits_only_single_line_json_records() {
177        let mut output = Vec::new();
178        {
179            let mut writer = ProtocolWriter::new(&mut output);
180            writer.assistant_delta("line one\nline two").expect("event");
181            writer
182                .tool_result(
183                    "call-1",
184                    "cmd",
185                    serde_json::json!({"stdout":"provider-shape-is-not-forwarded"}),
186                )
187                .expect("result");
188        }
189        let text = String::from_utf8(output).expect("UTF-8");
190        assert_eq!(text.lines().count(), 2);
191        for line in text.lines() {
192            serde_json::from_str::<Value>(line).expect("JSONL record");
193        }
194        assert!(!text.contains("choices"));
195    }
196
197    #[test]
198    fn compaction_frontend_state_is_not_emitted_to_jsonl() {
199        let mut output = Vec::new();
200        let mut writer = ProtocolWriter::new(&mut output);
201        writer.context_usage(100).expect("context usage");
202        writer.compaction_started().expect("compaction start");
203        writer
204            .compaction_finished(100, 20)
205            .expect("compaction finish");
206
207        assert!(output.is_empty());
208    }
209
210    #[test]
211    fn skill_attachment_state_is_not_emitted_to_jsonl() {
212        let mut output = Vec::new();
213        let mut writer = ProtocolWriter::new(&mut output);
214        writer
215            .skill_instruction_attached("release-notes")
216            .expect("non-public TUI state");
217        assert!(output.is_empty());
218    }
219
220    #[test]
221    fn interruption_event_is_a_normalized_json_record() {
222        let event = ProtocolEvent::TurnInterrupted {
223            reason: "user_cancelled".to_owned(),
224            phase: "provider_stream".to_owned(),
225        };
226        let value = serde_json::to_value(event).expect("event JSON");
227        assert_eq!(value["type"], "turn_interrupted");
228        assert_eq!(value["reason"], "user_cancelled");
229        assert_eq!(value["phase"], "provider_stream");
230        assert!(!serde_json::to_string(&value)
231            .expect("serialized event")
232            .contains("choices"));
233    }
234}