Skip to main content

opencode_sdk_rs/resources/
event.rs

1//! Event resource types and the `EventResource` struct.
2//!
3//! Events are delivered via Server-Sent Events (SSE).  The [`EventResource`]
4//! will expose a streaming `list()` method once SSE support is wired up
5//! (Task 3.1).  For now only the data types are defined.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use super::{
12    session::{Message, Part, Session},
13    shared::SessionError,
14};
15use crate::client::Opencode;
16
17// ---------------------------------------------------------------------------
18// EventListResponse — internally-tagged discriminated union
19// ---------------------------------------------------------------------------
20
21/// A single event from the `/event` SSE stream.
22///
23/// Internally tagged on `"type"` to match the JS SDK representation.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(tag = "type")]
26pub enum EventListResponse {
27    /// An installation was updated to a new version.
28    #[serde(rename = "installation.updated")]
29    InstallationUpdated {
30        /// Payload.
31        properties: InstallationUpdatedProps,
32    },
33
34    /// LSP client diagnostics were received.
35    #[serde(rename = "lsp.client.diagnostics")]
36    LspClientDiagnostics {
37        /// Payload.
38        properties: LspClientDiagnosticsProps,
39    },
40
41    /// A message was updated.
42    #[serde(rename = "message.updated")]
43    MessageUpdated {
44        /// Payload.
45        properties: MessageUpdatedProps,
46    },
47
48    /// A message was removed.
49    #[serde(rename = "message.removed")]
50    MessageRemoved {
51        /// Payload.
52        properties: MessageRemovedProps,
53    },
54
55    /// A message part was updated.
56    #[serde(rename = "message.part.updated")]
57    MessagePartUpdated {
58        /// Payload.
59        properties: MessagePartUpdatedProps,
60    },
61
62    /// A message part was removed.
63    #[serde(rename = "message.part.removed")]
64    MessagePartRemoved {
65        /// Payload.
66        properties: MessagePartRemovedProps,
67    },
68
69    /// A storage key was written.
70    #[serde(rename = "storage.write")]
71    StorageWrite {
72        /// Payload.
73        properties: StorageWriteProps,
74    },
75
76    /// A permission was updated.
77    #[serde(rename = "permission.updated")]
78    PermissionUpdated {
79        /// Payload.
80        properties: PermissionUpdatedProps,
81    },
82
83    /// A file was edited.
84    #[serde(rename = "file.edited")]
85    FileEdited {
86        /// Payload.
87        properties: FileEditedProps,
88    },
89
90    /// A session was updated.
91    #[serde(rename = "session.updated")]
92    SessionUpdated {
93        /// Payload.
94        properties: SessionUpdatedProps,
95    },
96
97    /// A session was deleted.
98    #[serde(rename = "session.deleted")]
99    SessionDeleted {
100        /// Payload.
101        properties: SessionDeletedProps,
102    },
103
104    /// A session became idle.
105    #[serde(rename = "session.idle")]
106    SessionIdle {
107        /// Payload.
108        properties: SessionIdleProps,
109    },
110
111    /// A session encountered an error.
112    #[serde(rename = "session.error")]
113    SessionError {
114        /// Payload.
115        properties: SessionErrorProps,
116    },
117
118    /// A file-watcher event was received.
119    #[serde(rename = "file.watcher.updated")]
120    FileWatcherUpdated {
121        /// Payload.
122        properties: FileWatcherUpdatedProps,
123    },
124
125    /// An IDE was installed.
126    #[serde(rename = "ide.installed")]
127    IdeInstalled {
128        /// Payload.
129        properties: IdeInstalledProps,
130    },
131}
132
133// ---------------------------------------------------------------------------
134// Property structs
135// ---------------------------------------------------------------------------
136
137/// Properties for [`EventListResponse::InstallationUpdated`].
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct InstallationUpdatedProps {
140    /// New version string.
141    pub version: String,
142}
143
144/// Properties for [`EventListResponse::LspClientDiagnostics`].
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146pub struct LspClientDiagnosticsProps {
147    /// File path.
148    pub path: String,
149    /// Language-server identifier.
150    #[serde(rename = "serverID")]
151    pub server_id: String,
152}
153
154/// Properties for [`EventListResponse::MessageUpdated`].
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
156pub struct MessageUpdatedProps {
157    /// The updated message.
158    pub info: Message,
159}
160
161/// Properties for [`EventListResponse::MessageRemoved`].
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163pub struct MessageRemovedProps {
164    /// ID of the removed message.
165    #[serde(rename = "messageID")]
166    pub message_id: String,
167    /// Session the message belonged to.
168    #[serde(rename = "sessionID")]
169    pub session_id: String,
170}
171
172/// Properties for [`EventListResponse::MessagePartUpdated`].
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174pub struct MessagePartUpdatedProps {
175    /// The updated part.
176    pub part: Part,
177}
178
179/// Properties for [`EventListResponse::MessagePartRemoved`].
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
181pub struct MessagePartRemovedProps {
182    /// Message the part belonged to.
183    #[serde(rename = "messageID")]
184    pub message_id: String,
185    /// ID of the removed part.
186    #[serde(rename = "partID")]
187    pub part_id: String,
188}
189
190/// Properties for [`EventListResponse::StorageWrite`].
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192pub struct StorageWriteProps {
193    /// Storage key.
194    pub key: String,
195    /// Optional storage content.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub content: Option<serde_json::Value>,
198}
199
200/// Properties for [`EventListResponse::PermissionUpdated`].
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
202pub struct PermissionUpdatedProps {
203    /// Permission identifier.
204    pub id: String,
205    /// Arbitrary metadata map.
206    pub metadata: HashMap<String, serde_json::Value>,
207    /// Session the permission belongs to.
208    #[serde(rename = "sessionID")]
209    pub session_id: String,
210    /// Timestamps.
211    pub time: PermissionTime,
212    /// Human-readable title.
213    pub title: String,
214}
215
216/// Timestamp container for [`PermissionUpdatedProps`].
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
218pub struct PermissionTime {
219    /// Unix epoch seconds when the permission was created.
220    pub created: f64,
221}
222
223/// Properties for [`EventListResponse::FileEdited`].
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub struct FileEditedProps {
226    /// The edited file path.
227    pub file: String,
228}
229
230/// Properties for [`EventListResponse::SessionUpdated`].
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
232pub struct SessionUpdatedProps {
233    /// The updated session.
234    pub info: Session,
235}
236
237/// Properties for [`EventListResponse::SessionDeleted`].
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
239pub struct SessionDeletedProps {
240    /// The deleted session.
241    pub info: Session,
242}
243
244/// Properties for [`EventListResponse::SessionIdle`].
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
246pub struct SessionIdleProps {
247    /// The idle session's ID.
248    #[serde(rename = "sessionID")]
249    pub session_id: String,
250}
251
252/// Properties for [`EventListResponse::SessionError`].
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254pub struct SessionErrorProps {
255    /// The error, if any.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub error: Option<SessionError>,
258    /// The session ID, if available.
259    #[serde(rename = "sessionID")]
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub session_id: Option<String>,
262}
263
264/// Kind of file-watcher event.
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
266pub enum FileWatcherEvent {
267    /// A file was renamed.
268    #[serde(rename = "rename")]
269    Rename,
270    /// A file was changed.
271    #[serde(rename = "change")]
272    Change,
273}
274
275/// Properties for [`EventListResponse::FileWatcherUpdated`].
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
277pub struct FileWatcherUpdatedProps {
278    /// The kind of file-system event.
279    pub event: FileWatcherEvent,
280    /// The affected file path.
281    pub file: String,
282}
283
284/// Properties for [`EventListResponse::IdeInstalled`].
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
286pub struct IdeInstalledProps {
287    /// The IDE identifier.
288    pub ide: String,
289}
290
291// ---------------------------------------------------------------------------
292// EventResource
293// ---------------------------------------------------------------------------
294
295/// Resource accessor for the `/event` SSE endpoint.
296pub struct EventResource<'a> {
297    client: &'a Opencode,
298}
299
300impl<'a> EventResource<'a> {
301    /// Create a new `EventResource` bound to the given client.
302    pub(crate) const fn new(client: &'a Opencode) -> Self {
303        Self { client }
304    }
305
306    /// List events as an SSE stream.
307    ///
308    /// The `/event` endpoint returns a Server-Sent Events stream where
309    /// each event's `data` field is a JSON-encoded [`EventListResponse`].
310    pub async fn list(
311        &self,
312    ) -> Result<crate::streaming::SseStream<EventListResponse>, crate::error::OpencodeError> {
313        self.client.get_stream("/event").await
314    }
315}
316
317// ---------------------------------------------------------------------------
318// Tests
319// ---------------------------------------------------------------------------
320
321#[cfg(test)]
322mod tests {
323    use serde_json::json;
324
325    use super::*;
326    use crate::resources::session::{UserMessage, UserMessageTime};
327
328    // -- InstallationUpdated round-trip --
329
330    #[test]
331    fn installation_updated_round_trip() {
332        let event = EventListResponse::InstallationUpdated {
333            properties: InstallationUpdatedProps { version: "1.2.3".into() },
334        };
335        let json_str = serde_json::to_string(&event).unwrap();
336        assert!(json_str.contains(r#""type":"installation.updated"#));
337        assert!(json_str.contains(r#""version":"1.2.3"#));
338        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
339        assert_eq!(event, back);
340    }
341
342    // -- MessageUpdated round-trip (with full Message) --
343
344    #[test]
345    fn message_updated_round_trip() {
346        let msg = Message::User(UserMessage {
347            id: "msg_u001".into(),
348            session_id: "sess_001".into(),
349            time: UserMessageTime { created: 1_700_000_100.0 },
350        });
351
352        let event = EventListResponse::MessageUpdated {
353            properties: MessageUpdatedProps { info: msg.clone() },
354        };
355        let json_str = serde_json::to_string(&event).unwrap();
356        assert!(json_str.contains(r#""type":"message.updated"#));
357        assert!(json_str.contains(r#""role":"user"#));
358        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
359        assert_eq!(event, back);
360    }
361
362    // -- SessionError round-trip --
363
364    #[test]
365    fn session_error_round_trip() {
366        use crate::resources::shared::{SessionError as SE, UnknownErrorData};
367
368        let event = EventListResponse::SessionError {
369            properties: SessionErrorProps {
370                error: Some(SE::UnknownError {
371                    data: UnknownErrorData { message: "something broke".into() },
372                }),
373                session_id: Some("sess_err_001".into()),
374            },
375        };
376        let json_str = serde_json::to_string(&event).unwrap();
377        assert!(json_str.contains(r#""type":"session.error"#));
378        assert!(json_str.contains(r#""name":"UnknownError"#));
379        assert!(json_str.contains("something broke"));
380        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
381        assert_eq!(event, back);
382    }
383
384    #[test]
385    fn session_error_empty_round_trip() {
386        let event = EventListResponse::SessionError {
387            properties: SessionErrorProps { error: None, session_id: None },
388        };
389        let json_str = serde_json::to_string(&event).unwrap();
390        // Optional fields should be omitted (check for the key, not substring)
391        assert!(!json_str.contains(r#""error""#));
392        assert!(!json_str.contains(r#""sessionID""#));
393        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
394        assert_eq!(event, back);
395    }
396
397    // -- FileWatcherUpdated round-trip --
398
399    #[test]
400    fn file_watcher_updated_round_trip() {
401        let event = EventListResponse::FileWatcherUpdated {
402            properties: FileWatcherUpdatedProps {
403                event: FileWatcherEvent::Rename,
404                file: "src/main.rs".into(),
405            },
406        };
407        let json_str = serde_json::to_string(&event).unwrap();
408        assert!(json_str.contains(r#""type":"file.watcher.updated"#));
409        assert!(json_str.contains(r#""event":"rename"#));
410        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
411        assert_eq!(event, back);
412
413        // Also test the Change variant
414        let event2 = EventListResponse::FileWatcherUpdated {
415            properties: FileWatcherUpdatedProps {
416                event: FileWatcherEvent::Change,
417                file: "Cargo.toml".into(),
418            },
419        };
420        let json_str2 = serde_json::to_string(&event2).unwrap();
421        assert!(json_str2.contains(r#""event":"change"#));
422        let back2: EventListResponse = serde_json::from_str(&json_str2).unwrap();
423        assert_eq!(event2, back2);
424    }
425
426    // -- StorageWrite round-trip --
427
428    #[test]
429    fn storage_write_round_trip() {
430        let event = EventListResponse::StorageWrite {
431            properties: StorageWriteProps {
432                key: "my-key".into(),
433                content: Some(json!({"nested": true, "count": 42})),
434            },
435        };
436        let json_str = serde_json::to_string(&event).unwrap();
437        assert!(json_str.contains(r#""type":"storage.write"#));
438        assert!(json_str.contains(r#""key":"my-key"#));
439        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
440        assert_eq!(event, back);
441    }
442
443    #[test]
444    fn storage_write_no_content_round_trip() {
445        let event = EventListResponse::StorageWrite {
446            properties: StorageWriteProps { key: "empty-key".into(), content: None },
447        };
448        let json_str = serde_json::to_string(&event).unwrap();
449        assert!(!json_str.contains("content"));
450        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
451        assert_eq!(event, back);
452    }
453
454    // -- Deserialization from raw JSON --
455
456    #[test]
457    fn deserialize_from_raw_json() {
458        let raw = r#"{
459            "type": "ide.installed",
460            "properties": { "ide": "vscode" }
461        }"#;
462        let event: EventListResponse = serde_json::from_str(raw).unwrap();
463        assert_eq!(
464            event,
465            EventListResponse::IdeInstalled {
466                properties: IdeInstalledProps { ide: "vscode".into() }
467            }
468        );
469    }
470
471    #[test]
472    fn deserialize_permission_updated() {
473        let raw = r#"{
474            "type": "permission.updated",
475            "properties": {
476                "id": "perm_001",
477                "metadata": {"tool": "bash"},
478                "sessionID": "sess_001",
479                "time": {"created": 1700000000.0},
480                "title": "Run bash command"
481            }
482        }"#;
483        let event: EventListResponse = serde_json::from_str(raw).unwrap();
484        match &event {
485            EventListResponse::PermissionUpdated { properties } => {
486                assert_eq!(properties.id, "perm_001");
487                assert_eq!(properties.session_id, "sess_001");
488                assert_eq!(properties.title, "Run bash command");
489                assert_eq!(properties.time.created, 1_700_000_000.0);
490                assert_eq!(properties.metadata.get("tool"), Some(&json!("bash")));
491            }
492            other => panic!("expected PermissionUpdated, got {other:?}"),
493        }
494        // Round-trip
495        let json_str = serde_json::to_string(&event).unwrap();
496        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
497        assert_eq!(event, back);
498    }
499
500    // -- Missing event variant round-trips --
501
502    #[test]
503    fn lsp_client_diagnostics_round_trip() {
504        let event = EventListResponse::LspClientDiagnostics {
505            properties: LspClientDiagnosticsProps {
506                path: "src/main.rs".into(),
507                server_id: "rust-analyzer".into(),
508            },
509        };
510        let json_str = serde_json::to_string(&event).unwrap();
511        assert!(json_str.contains(r#""type":"lsp.client.diagnostics"#));
512        assert!(json_str.contains(r#""serverID":"rust-analyzer"#));
513        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
514        assert_eq!(event, back);
515    }
516
517    #[test]
518    fn message_removed_round_trip() {
519        let event = EventListResponse::MessageRemoved {
520            properties: MessageRemovedProps {
521                message_id: "msg_del_001".into(),
522                session_id: "sess_001".into(),
523            },
524        };
525        let json_str = serde_json::to_string(&event).unwrap();
526        assert!(json_str.contains(r#""type":"message.removed"#));
527        assert!(json_str.contains("messageID"));
528        assert!(json_str.contains("sessionID"));
529        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
530        assert_eq!(event, back);
531    }
532
533    #[test]
534    fn message_part_updated_round_trip() {
535        use crate::resources::session::{Part, TextPart};
536
537        let event = EventListResponse::MessagePartUpdated {
538            properties: MessagePartUpdatedProps {
539                part: Part::Text(TextPart {
540                    id: "p_upd_001".into(),
541                    message_id: "msg_001".into(),
542                    session_id: "sess_001".into(),
543                    text: "updated text".into(),
544                    synthetic: None,
545                    time: None,
546                }),
547            },
548        };
549        let json_str = serde_json::to_string(&event).unwrap();
550        assert!(json_str.contains(r#""type":"message.part.updated"#));
551        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
552        assert_eq!(event, back);
553    }
554
555    #[test]
556    fn message_part_removed_round_trip() {
557        let event = EventListResponse::MessagePartRemoved {
558            properties: MessagePartRemovedProps {
559                message_id: "msg_001".into(),
560                part_id: "p_del_001".into(),
561            },
562        };
563        let json_str = serde_json::to_string(&event).unwrap();
564        assert!(json_str.contains(r#""type":"message.part.removed"#));
565        assert!(json_str.contains("messageID"));
566        assert!(json_str.contains("partID"));
567        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
568        assert_eq!(event, back);
569    }
570
571    #[test]
572    fn file_edited_round_trip() {
573        let event = EventListResponse::FileEdited {
574            properties: FileEditedProps { file: "src/lib.rs".into() },
575        };
576        let json_str = serde_json::to_string(&event).unwrap();
577        assert!(json_str.contains(r#""type":"file.edited"#));
578        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
579        assert_eq!(event, back);
580    }
581
582    #[test]
583    fn session_updated_round_trip() {
584        let event = EventListResponse::SessionUpdated {
585            properties: SessionUpdatedProps {
586                info: Session {
587                    id: "sess_upd".into(),
588                    time: crate::resources::session::SessionTime {
589                        created: 1_700_000_000.0,
590                        updated: 1_700_001_000.0,
591                    },
592                    title: "Updated".into(),
593                    version: "1".into(),
594                    parent_id: None,
595                    revert: None,
596                    share: None,
597                },
598            },
599        };
600        let json_str = serde_json::to_string(&event).unwrap();
601        assert!(json_str.contains(r#""type":"session.updated"#));
602        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
603        assert_eq!(event, back);
604    }
605
606    #[test]
607    fn session_deleted_round_trip() {
608        let event = EventListResponse::SessionDeleted {
609            properties: SessionDeletedProps {
610                info: Session {
611                    id: "sess_del".into(),
612                    time: crate::resources::session::SessionTime {
613                        created: 1_700_000_000.0,
614                        updated: 1_700_000_000.0,
615                    },
616                    title: "Deleted".into(),
617                    version: "1".into(),
618                    parent_id: None,
619                    revert: None,
620                    share: None,
621                },
622            },
623        };
624        let json_str = serde_json::to_string(&event).unwrap();
625        assert!(json_str.contains(r#""type":"session.deleted"#));
626        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
627        assert_eq!(event, back);
628    }
629
630    #[test]
631    fn session_idle_round_trip() {
632        let event = EventListResponse::SessionIdle {
633            properties: SessionIdleProps { session_id: "sess_idle_001".into() },
634        };
635        let json_str = serde_json::to_string(&event).unwrap();
636        assert!(json_str.contains(r#""type":"session.idle"#));
637        assert!(json_str.contains("sessionID"));
638        let back: EventListResponse = serde_json::from_str(&json_str).unwrap();
639        assert_eq!(event, back);
640    }
641
642    #[test]
643    fn storage_write_null_content() {
644        // Deserialize from JSON where content is explicitly null
645        let raw = r#"{
646            "type": "storage.write",
647            "properties": { "key": "k", "content": null }
648        }"#;
649        let event: EventListResponse = serde_json::from_str(raw).unwrap();
650        match &event {
651            EventListResponse::StorageWrite { properties } => {
652                assert_eq!(properties.key, "k");
653                assert_eq!(properties.content, None);
654            }
655            other => panic!("expected StorageWrite, got {other:?}"),
656        }
657    }
658
659    #[test]
660    fn session_error_both_fields_null() {
661        // Deserialize from JSON where both fields are explicitly null
662        let raw = r#"{
663            "type": "session.error",
664            "properties": { "error": null, "sessionID": null }
665        }"#;
666        let event: EventListResponse = serde_json::from_str(raw).unwrap();
667        match &event {
668            EventListResponse::SessionError { properties } => {
669                assert_eq!(properties.error, None);
670                assert_eq!(properties.session_id, None);
671            }
672            other => panic!("expected SessionError, got {other:?}"),
673        }
674    }
675}