1use lago_core::EventEnvelope;
2use lago_core::event::EventPayload;
3use serde_json::json;
4
5use super::format::{SseFormat, SseFrame};
6
7pub struct VercelFormat;
14
15impl SseFormat for VercelFormat {
16 fn format(&self, event: &EventEnvelope) -> Vec<SseFrame> {
17 let id = Some(event.seq.to_string());
18
19 match &event.payload {
20 EventPayload::Message { content, .. } => {
21 vec![
23 make_frame("start-step", json!({}), &id),
24 make_frame("text-start", json!({}), &id),
25 make_frame(
26 "text-delta",
27 json!({
28 "id": event.event_id.to_string(),
29 "delta": content,
30 }),
31 &id,
32 ),
33 make_frame("text-end", json!({}), &id),
34 make_frame("finish-step", json!({}), &id),
35 ]
36 }
37
38 EventPayload::TextDelta { delta, .. } => {
39 vec![make_frame(
40 "text-delta",
41 json!({
42 "id": event.event_id.to_string(),
43 "delta": delta,
44 }),
45 &id,
46 )]
47 }
48
49 EventPayload::ToolCallRequested {
50 call_id,
51 tool_name,
52 arguments,
53 ..
54 } => {
55 let args_str = arguments.to_string();
56 vec![
57 make_frame(
58 "tool-input-start",
59 json!({
60 "toolCallId": call_id,
61 "toolName": tool_name,
62 }),
63 &id,
64 ),
65 make_frame(
66 "tool-input-delta",
67 json!({
68 "toolCallId": call_id,
69 "delta": args_str,
70 }),
71 &id,
72 ),
73 make_frame(
74 "tool-input-available",
75 json!({
76 "toolCallId": call_id,
77 "toolName": tool_name,
78 "input": arguments,
79 }),
80 &id,
81 ),
82 ]
83 }
84
85 EventPayload::ToolCallCompleted {
86 call_id,
87 tool_name,
88 result,
89 ..
90 } => {
91 vec![make_frame(
92 "tool-output-available",
93 json!({
94 "toolCallId": call_id,
95 "toolName": tool_name,
96 "output": result,
97 }),
98 &id,
99 )]
100 }
101
102 _ => Vec::new(),
104 }
105 }
106
107 fn done_frame(&self) -> Option<SseFrame> {
108 let done = json!({
109 "type": "finish",
110 "finishReason": "stop",
111 });
112 Some(SseFrame {
113 event: None,
114 data: done.to_string(),
115 id: None,
116 })
117 }
118
119 fn extra_headers(&self) -> Vec<(String, String)> {
120 vec![(
121 "x-vercel-ai-ui-message-stream".to_string(),
122 "v1".to_string(),
123 )]
124 }
125
126 fn name(&self) -> &str {
127 "vercel"
128 }
129}
130
131fn make_frame(frame_type: &str, mut data: serde_json::Value, id: &Option<String>) -> SseFrame {
133 data["type"] = json!(frame_type);
134 SseFrame {
135 event: None,
136 data: data.to_string(),
137 id: id.clone(),
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use lago_core::event::SpanStatus;
145 use lago_core::id::*;
146 use std::collections::HashMap;
147
148 fn make_envelope(payload: EventPayload, seq: u64) -> EventEnvelope {
149 EventEnvelope {
150 event_id: EventId::from_string("EVT001"),
151 session_id: SessionId::from_string("SESS001"),
152 branch_id: BranchId::from_string("main"),
153 run_id: None,
154 seq,
155 timestamp: 1_700_000_000_000_000,
156 parent_id: None,
157 payload,
158 metadata: HashMap::new(),
159 schema_version: 1,
160 }
161 }
162
163 fn parse_frame(frame: &SseFrame) -> serde_json::Value {
164 serde_json::from_str(&frame.data).unwrap()
165 }
166
167 #[test]
168 fn message_produces_lifecycle_frames() {
169 let fmt = VercelFormat;
170 let event = make_envelope(
171 EventPayload::Message {
172 role: "assistant".into(),
173 content: "Hello!".into(),
174 model: None,
175 token_usage: None,
176 },
177 3,
178 );
179 let frames = fmt.format(&event);
180 assert_eq!(frames.len(), 5);
181
182 assert_eq!(parse_frame(&frames[0])["type"], "start-step");
183 assert_eq!(parse_frame(&frames[1])["type"], "text-start");
184 assert_eq!(parse_frame(&frames[2])["type"], "text-delta");
185 assert_eq!(parse_frame(&frames[2])["delta"], "Hello!");
186 assert_eq!(parse_frame(&frames[3])["type"], "text-end");
187 assert_eq!(parse_frame(&frames[4])["type"], "finish-step");
188
189 for frame in &frames {
191 assert_eq!(frame.id.as_deref(), Some("3"));
192 }
193 }
194
195 #[test]
196 fn message_delta_produces_text_delta_frame() {
197 let fmt = VercelFormat;
198 let event = make_envelope(
199 EventPayload::TextDelta {
200 delta: "chunk".into(),
201 index: Some(0),
202 },
203 7,
204 );
205 let frames = fmt.format(&event);
206 assert_eq!(frames.len(), 1);
207 let data = parse_frame(&frames[0]);
208 assert_eq!(data["type"], "text-delta");
209 assert_eq!(data["delta"], "chunk");
210 }
211
212 #[test]
213 fn tool_invoke_produces_tool_input_frames() {
214 let fmt = VercelFormat;
215 let event = make_envelope(
216 EventPayload::ToolCallRequested {
217 call_id: "call-1".into(),
218 tool_name: "read_file".into(),
219 arguments: serde_json::json!({"path": "/etc/hosts"}),
220 category: None,
221 },
222 10,
223 );
224 let frames = fmt.format(&event);
225 assert_eq!(frames.len(), 3);
226
227 let f0 = parse_frame(&frames[0]);
228 assert_eq!(f0["type"], "tool-input-start");
229 assert_eq!(f0["toolCallId"], "call-1");
230 assert_eq!(f0["toolName"], "read_file");
231
232 let f1 = parse_frame(&frames[1]);
233 assert_eq!(f1["type"], "tool-input-delta");
234 assert_eq!(f1["toolCallId"], "call-1");
235 assert!(f1["delta"].as_str().unwrap().contains("/etc/hosts"));
236
237 let f2 = parse_frame(&frames[2]);
238 assert_eq!(f2["type"], "tool-input-available");
239 assert_eq!(f2["toolCallId"], "call-1");
240 assert_eq!(f2["input"]["path"], "/etc/hosts");
241 }
242
243 #[test]
244 fn tool_result_produces_tool_output_frame() {
245 let fmt = VercelFormat;
246 let event = make_envelope(
247 EventPayload::ToolCallCompleted {
248 tool_run_id: lago_core::protocol_bridge::aios_protocol::ToolRunId::default(),
249 call_id: Some("call-1".into()),
250 tool_name: "read_file".into(),
251 result: serde_json::json!({"content": "data"}),
252 duration_ms: 42,
253 status: SpanStatus::Ok,
254 },
255 11,
256 );
257 let frames = fmt.format(&event);
258 assert_eq!(frames.len(), 1);
259
260 let data = parse_frame(&frames[0]);
261 assert_eq!(data["type"], "tool-output-available");
262 assert_eq!(data["toolCallId"], "call-1");
263 assert_eq!(data["output"]["content"], "data");
264 }
265
266 #[test]
267 fn non_message_events_filtered() {
268 let fmt = VercelFormat;
269 let event = make_envelope(
270 EventPayload::FileWrite {
271 path: "/a".into(),
272 blob_hash: BlobHash::from_hex("abc").into(),
273 size_bytes: 10,
274 content_type: None,
275 },
276 1,
277 );
278 assert!(fmt.format(&event).is_empty());
279 }
280
281 #[test]
282 fn done_frame_is_finish() {
283 let fmt = VercelFormat;
284 let done = fmt.done_frame().unwrap();
285 let data = parse_frame(&done);
286 assert_eq!(data["type"], "finish");
287 assert_eq!(data["finishReason"], "stop");
288 }
289
290 #[test]
291 fn extra_headers_include_ui_message_stream_header() {
292 let fmt = VercelFormat;
293 let headers = fmt.extra_headers();
294 assert_eq!(headers.len(), 1);
295 assert_eq!(headers[0].0, "x-vercel-ai-ui-message-stream");
296 assert_eq!(headers[0].1, "v1");
297 }
298
299 #[test]
300 fn name_is_vercel() {
301 assert_eq!(VercelFormat.name(), "vercel");
302 }
303
304 #[test]
305 fn sandbox_events_filtered() {
306 let fmt = VercelFormat;
307 let event = make_envelope(
308 EventPayload::SandboxCreated {
309 sandbox_id: "sbx-001".into(),
310 tier: "container".into(),
311 config: serde_json::json!({
312 "tier": "container",
313 "allowed_paths": [],
314 "allowed_commands": [],
315 "network_access": false,
316 }),
317 },
318 20,
319 );
320 assert!(fmt.format(&event).is_empty());
321 }
322}