Skip to main content

nika_engine/runtime/builtin/
emit.rs

1//! nika:emit - Emit custom event to EventLog.
2//!
3//! # Parameters
4//!
5//! ```json
6//! {
7//!   "name": "my_event",      // Event name/type
8//!   "payload": { ... }       // Arbitrary JSON payload (optional)
9//! }
10//! ```
11//!
12//! # Returns
13//!
14//! ```json
15//! {
16//!   "emitted": true,
17//!   "name": "my_event",
18//!   "payload": { ... }
19//! }
20//! ```
21
22use super::BuiltinTool;
23use crate::error::NikaError;
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26use std::future::Future;
27use std::pin::Pin;
28
29/// Parameters for nika:emit tool.
30/// Supports both `payload` (JSON) and `payload_json` (string) for OpenAI compatibility.
31#[derive(Debug, Clone, Deserialize)]
32struct EmitParams {
33    /// Event name/type.
34    name: String,
35    /// Event payload as JSON string (for OpenAI strict mode).
36    #[serde(default)]
37    payload_json: Option<String>,
38    /// Event payload as direct JSON (for Claude and other providers).
39    #[serde(default)]
40    payload: Option<Value>,
41}
42
43impl EmitParams {
44    /// Get the payload as a JSON Value, parsing from payload_json if needed.
45    fn get_payload(&self) -> Result<Value, NikaError> {
46        if let Some(ref json_str) = self.payload_json {
47            serde_json::from_str(json_str).map_err(|e| NikaError::BuiltinInvalidParams {
48                tool: "nika_emit".into(),
49                reason: format!("Invalid payload_json: {}", e),
50            })
51        } else if let Some(ref value) = self.payload {
52            Ok(value.clone())
53        } else {
54            Ok(Value::Null)
55        }
56    }
57}
58
59/// Response from nika:emit tool.
60#[derive(Debug, Clone, Serialize)]
61struct EmitResponse {
62    /// Whether the event was emitted.
63    emitted: bool,
64    /// The event name.
65    name: String,
66    /// The payload that was emitted.
67    payload: Value,
68}
69
70/// nika:emit builtin tool.
71///
72/// Emits custom events to the EventLog for workflow observability.
73/// Events can carry arbitrary JSON payloads.
74pub struct EmitTool;
75
76impl BuiltinTool for EmitTool {
77    fn name(&self) -> &'static str {
78        "emit"
79    }
80
81    fn description(&self) -> &'static str {
82        "Emit custom event to EventLog for workflow observability"
83    }
84
85    fn parameters_schema(&self) -> serde_json::Value {
86        // OpenAI-compatible schema
87        // OpenAI strict mode requires additionalProperties: false everywhere
88        // To support arbitrary payloads, we use payload_json as a string (JSON-encoded)
89        serde_json::json!({
90            "type": "object",
91            "properties": {
92                "name": {
93                    "type": "string",
94                    "description": "Event name/type identifier"
95                },
96                "payload_json": {
97                    "type": "string",
98                    "description": "Event payload as JSON string (e.g., '{\"key\": \"value\"}' or '123' or '\"text\"')"
99                }
100            },
101            "required": ["name", "payload_json"],
102            "additionalProperties": false
103        })
104    }
105
106    fn call<'a>(
107        &'a self,
108        args: String,
109    ) -> Pin<Box<dyn Future<Output = Result<String, NikaError>> + Send + 'a>> {
110        Box::pin(async move {
111            // Parse parameters
112            let params: EmitParams =
113                serde_json::from_str(&args).map_err(|e| NikaError::BuiltinInvalidParams {
114                    tool: "nika:emit".into(),
115                    reason: format!("Invalid JSON parameters: {}", e),
116                })?;
117
118            // Validate event name
119            if params.name.is_empty() {
120                return Err(NikaError::BuiltinInvalidParams {
121                    tool: "nika:emit".into(),
122                    reason: "Event name cannot be empty".into(),
123                });
124            }
125
126            // Get payload from either payload_json (OpenAI) or payload (Claude)
127            let payload = params.get_payload()?;
128
129            // Note: The actual EventLog emission will be handled by the Router
130            // when integrated with the Executor. For now, we just validate and
131            // return success. The Router will capture the params and emit the
132            // EventKind::Custom event.
133            tracing::debug!(
134                target: "nika_emit",
135                name = %params.name,
136                "Custom event emitted"
137            );
138
139            // Return response
140            let response = EmitResponse {
141                emitted: true,
142                name: params.name,
143                payload,
144            };
145
146            serde_json::to_string(&response).map_err(|e| NikaError::BuiltinToolError {
147                tool: "nika:emit".into(),
148                reason: format!("Failed to serialize response: {}", e),
149            })
150        })
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_emit_tool_name() {
160        let tool = EmitTool;
161        assert_eq!(tool.name(), "emit");
162    }
163
164    #[test]
165    fn test_emit_tool_description() {
166        let tool = EmitTool;
167        assert!(tool.description().contains("EventLog"));
168    }
169
170    #[test]
171    fn test_emit_tool_schema() {
172        let tool = EmitTool;
173        let schema = tool.parameters_schema();
174        assert_eq!(schema["type"], "object");
175        assert!(schema["properties"]["name"].is_object());
176        // payload_json for OpenAI compatibility
177        assert!(schema["properties"]["payload_json"].is_object());
178        assert_eq!(schema["additionalProperties"], false);
179        assert!(schema["required"]
180            .as_array()
181            .unwrap()
182            .contains(&serde_json::json!("name")));
183    }
184
185    #[tokio::test]
186    async fn test_emit_simple_event() {
187        let tool = EmitTool;
188        let result = tool.call(r#"{"name": "test_event"}"#.to_string()).await;
189
190        assert!(result.is_ok());
191        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
192        assert_eq!(response["emitted"], true);
193        assert_eq!(response["name"], "test_event");
194        assert_eq!(response["payload"], serde_json::json!(null));
195    }
196
197    #[tokio::test]
198    async fn test_emit_with_payload() {
199        let tool = EmitTool;
200        let result = tool
201            .call(
202                r#"{"name": "user_action", "payload": {"action": "click", "target": "button"}}"#
203                    .to_string(),
204            )
205            .await;
206
207        assert!(result.is_ok());
208        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
209        assert_eq!(response["emitted"], true);
210        assert_eq!(response["name"], "user_action");
211        assert_eq!(response["payload"]["action"], "click");
212        assert_eq!(response["payload"]["target"], "button");
213    }
214
215    #[tokio::test]
216    async fn test_emit_with_complex_payload() {
217        let tool = EmitTool;
218        let result = tool
219            .call(
220                r#"{
221                    "name": "metrics",
222                    "payload": {
223                        "count": 42,
224                        "values": [1, 2, 3],
225                        "nested": {"key": "value"}
226                    }
227                }"#
228                .to_string(),
229            )
230            .await;
231
232        assert!(result.is_ok());
233        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
234        assert_eq!(response["payload"]["count"], 42);
235        assert_eq!(response["payload"]["values"][1], 2);
236        assert_eq!(response["payload"]["nested"]["key"], "value");
237    }
238
239    #[tokio::test]
240    async fn test_emit_empty_name() {
241        let tool = EmitTool;
242        let result = tool.call(r#"{"name": ""}"#.to_string()).await;
243
244        assert!(result.is_err());
245        let err = result.unwrap_err();
246        assert!(err.to_string().contains("cannot be empty"));
247    }
248
249    #[tokio::test]
250    async fn test_emit_missing_name() {
251        let tool = EmitTool;
252        let result = tool.call(r#"{"payload": {"test": 1}}"#.to_string()).await;
253
254        assert!(result.is_err());
255        let err = result.unwrap_err();
256        assert!(err.to_string().contains("Invalid JSON parameters"));
257    }
258
259    #[tokio::test]
260    async fn test_emit_invalid_json() {
261        let tool = EmitTool;
262        let result = tool.call("not json".to_string()).await;
263
264        assert!(result.is_err());
265        let err = result.unwrap_err();
266        assert!(err.to_string().contains("Invalid JSON parameters"));
267    }
268
269    #[tokio::test]
270    async fn test_emit_null_payload() {
271        let tool = EmitTool;
272        let result = tool
273            .call(r#"{"name": "event", "payload": null}"#.to_string())
274            .await;
275
276        assert!(result.is_ok());
277        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
278        assert_eq!(response["payload"], serde_json::json!(null));
279    }
280
281    #[tokio::test]
282    async fn test_emit_array_payload() {
283        let tool = EmitTool;
284        let result = tool
285            .call(r#"{"name": "items", "payload": [1, 2, 3]}"#.to_string())
286            .await;
287
288        assert!(result.is_ok());
289        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
290        assert_eq!(response["payload"][0], 1);
291        assert_eq!(response["payload"][2], 3);
292    }
293
294    #[tokio::test]
295    async fn test_emit_string_payload() {
296        let tool = EmitTool;
297        let result = tool
298            .call(r#"{"name": "message", "payload": "hello world"}"#.to_string())
299            .await;
300
301        assert!(result.is_ok());
302        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
303        assert_eq!(response["payload"], "hello world");
304    }
305}