Skip to main content

objectiveai_sdk/viewer/
events.rs

1//! Event bus. Built-in axum routes, dynamic plugin routes, and the
2//! cli_command stream all fan into the same enum; the viewer's
3//! `serve()` emits each variant as-is under the `destination` Tauri
4//! channel name.
5//!
6//! Channel-name namespacing: `"objectiveai"` is reserved as the
7//! built-in destination; plugin repositories named "objectiveai"
8//! are refused at install time (see
9//! `filesystem::plugins::InstallError::ReservedRepositoryName`), so
10//! a plugin can't shadow built-in events.
11
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use tokio::sync::mpsc;
15
16use super::ApiCallSubType;
17
18/// Every event the viewer emits to the JS side. Serde-tagged on
19/// `type` so the JS bridge can pattern-match and decide how to
20/// repackage each variant for the destination iframe.
21///
22/// `destination` is `"objectiveai"` for built-in events, or the
23/// plugin's repository name otherwise. For `CliCommand` it's the
24/// repository name of whichever iframe invoked the CLI — the bridge
25/// derives it from `MessageEvent.source`, the plugin author never
26/// sets it.
27#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
28#[serde(tag = "type", rename_all = "snake_case")]
29#[schemars(rename = "viewer.Event")]
30pub enum Event {
31    /// Host → iframe. Carries data into the plugin (the existing
32    /// path). `sub_type` is the snake_case discriminator the plugin
33    /// listens on (built-ins: `agent_completions` /
34    /// `functions_executions` / `functions_inventions_recursive` /
35    /// `laboratories_executions`; plugins: whatever they declared in
36    /// their manifest's `viewer_routes[i].type`).
37    #[schemars(title = "Inbound")]
38    Inbound {
39        destination: String,
40        sub_type: String,
41        value: serde_json::Value,
42    },
43    /// Host → iframe. One JSON line of cli output emitted by an
44    /// in-process `objectiveai_cli::run()` invocation started by
45    /// this iframe via `invokeCli`. `value` is the cli's `Output<T>`
46    /// envelope. No sub_type — a single invocation produces a single
47    /// stream of lines.
48    #[schemars(title = "CliCommand")]
49    CliCommand {
50        destination: String,
51        value: serde_json::Value,
52    },
53    /// Host → iframe. One value in the response stream of an
54    /// in-process upstream API call started by the iframe via
55    /// `api-call-invoke`. `sub_type` identifies which endpoint the
56    /// stream belongs to (lets the iframe demux multiple concurrent
57    /// calls to *different* endpoints). `value` is an
58    /// [`ApiCallEnvelope`](super::ApiCallEnvelope) JSON object: one
59    /// `begin`, then one or more `chunk`s (or `error`), then exactly
60    /// one `end`.
61    #[schemars(title = "ApiCall")]
62    ApiCall {
63        destination: String,
64        sub_type: ApiCallSubType,
65        value: serde_json::Value,
66    },
67}
68
69impl Event {
70    /// Tauri channel the event fans out on — the repository name of
71    /// the receiving iframe (or `"objectiveai"` for built-ins).
72    pub fn destination(&self) -> &str {
73        match self {
74            Event::Inbound { destination, .. } => destination,
75            Event::CliCommand { destination, .. } => destination,
76            Event::ApiCall { destination, .. } => destination,
77        }
78    }
79}
80
81pub type EventReceiver = mpsc::UnboundedReceiver<Event>;
82pub type EventSender = mpsc::UnboundedSender<Event>;
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use serde_json::json;
88
89    #[test]
90    fn inbound_serializes_with_tag_and_sub_type() {
91        let e = Event::Inbound {
92            destination: "objectiveai".to_string(),
93            sub_type: "agent_completions".to_string(),
94            value: json!({"id": "abc"}),
95        };
96        let s = serde_json::to_string(&e).unwrap();
97        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
98        assert_eq!(v["type"], "inbound");
99        assert_eq!(v["destination"], "objectiveai");
100        assert_eq!(v["sub_type"], "agent_completions");
101        assert_eq!(v["value"], json!({"id": "abc"}));
102
103        let back: Event = serde_json::from_str(&s).unwrap();
104        match back {
105            Event::Inbound { destination, sub_type, value } => {
106                assert_eq!(destination, "objectiveai");
107                assert_eq!(sub_type, "agent_completions");
108                assert_eq!(value, json!({"id": "abc"}));
109            }
110            _ => panic!("expected Inbound"),
111        }
112    }
113
114    #[test]
115    fn cli_command_serializes_with_tag_and_no_sub_type() {
116        let e = Event::CliCommand {
117            destination: "my_plugin".to_string(),
118            value: json!({"type": "notification", "value": {"x": 1}}),
119        };
120        let s = serde_json::to_string(&e).unwrap();
121        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
122        assert_eq!(v["type"], "cli_command");
123        assert_eq!(v["destination"], "my_plugin");
124        assert!(v.get("sub_type").is_none());
125        assert_eq!(v["value"]["type"], "notification");
126    }
127
128    #[test]
129    fn destination_accessor() {
130        let i = Event::Inbound {
131            destination: "d1".to_string(),
132            sub_type: "s".to_string(),
133            value: json!(null),
134        };
135        let c = Event::CliCommand {
136            destination: "d2".to_string(),
137            value: json!(null),
138        };
139        let a = Event::ApiCall {
140            destination: "d3".to_string(),
141            sub_type: ApiCallSubType::PostAgentCompletions,
142            value: json!(null),
143        };
144        assert_eq!(i.destination(), "d1");
145        assert_eq!(c.destination(), "d2");
146        assert_eq!(a.destination(), "d3");
147    }
148
149    #[test]
150    fn api_call_serializes_with_method_underscore_path_subtype() {
151        let e = Event::ApiCall {
152            destination: "my_plugin".to_string(),
153            sub_type: ApiCallSubType::PostAgentCompletions,
154            value: json!({"type": "chunk", "chunk": {"id": "abc"}}),
155        };
156        let s = serde_json::to_string(&e).unwrap();
157        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
158        assert_eq!(v["type"], "api_call");
159        assert_eq!(v["destination"], "my_plugin");
160        assert_eq!(v["sub_type"], "POST_/agent/completions");
161        assert_eq!(v["value"]["type"], "chunk");
162
163        let back: Event = serde_json::from_str(&s).unwrap();
164        match back {
165            Event::ApiCall { destination, sub_type, value } => {
166                assert_eq!(destination, "my_plugin");
167                assert_eq!(sub_type, ApiCallSubType::PostAgentCompletions);
168                assert_eq!(value["chunk"]["id"], "abc");
169            }
170            _ => panic!("expected ApiCall"),
171        }
172    }
173}