Skip to main content

active_call/handler/
handler.rs

1use crate::media::cache;
2use crate::synthesis::{SynthesisEvent, SynthesisOption};
3use crate::{
4    app::AppState,
5    call::{
6        ActiveCall, ActiveCallType, Command,
7        active_call::{ActiveCallGuard, CallParams},
8    },
9    handler::playbook,
10    playbook::{Playbook, PlaybookRunner},
11};
12use crate::{event::SessionEvent, media::track::TrackConfig};
13use axum::{
14    Json, Router,
15    extract::{Path, Query, State, WebSocketUpgrade, ws::Message},
16    response::sse::{Event, KeepAlive, Sse},
17    response::{IntoResponse, Response},
18    routing::{get, post},
19};
20use bytes::Bytes;
21use chrono::Utc;
22use futures::{SinkExt, StreamExt};
23use rustrtc::IceServer;
24use serde_json::json;
25use std::collections::HashMap;
26use std::{path::PathBuf, sync::Arc, time::Duration};
27use tokio::{join, select};
28use tokio_util::sync::CancellationToken;
29use tracing::{debug, info, trace, warn};
30use uuid::Uuid;
31
32fn filter_headers(
33    extras: &mut std::collections::HashMap<String, serde_json::Value>,
34    allowed_headers: &[String],
35) {
36    extras.retain(|k, _| allowed_headers.iter().any(|h| h.eq_ignore_ascii_case(k)));
37}
38
39pub fn call_router() -> Router<AppState> {
40    let r = Router::new()
41        .route("/call", get(ws_handler))
42        .route("/call/webrtc", get(webrtc_handler))
43        .route("/call/sip", get(sip_handler))
44        .route("/list", get(list_active_calls))
45        .route("/kill/{id}", get(kill_active_call))
46        .route("/events/{id}", get(stream_events))
47        .route("/command/{id}", post(send_command))
48        .route("/precache", post(precache));
49    r
50}
51
52pub fn iceservers_router() -> Router<AppState> {
53    let r = Router::new();
54    r.route("/iceservers", get(get_iceservers))
55}
56
57pub fn playbook_router() -> Router<AppState> {
58    Router::new()
59        .route("/api/playbooks", get(playbook::list_playbooks))
60        .route(
61            "/api/playbooks/{name}",
62            get(playbook::get_playbook).post(playbook::save_playbook),
63        )
64        .route(
65            "/api/playbook/run",
66            axum::routing::post(playbook::run_playbook),
67        )
68        .route("/api/records", get(playbook::list_records))
69}
70
71pub async fn ws_handler(
72    ws: WebSocketUpgrade,
73    State(state): State<AppState>,
74    Query(params): Query<CallParams>,
75) -> Response {
76    call_handler(ActiveCallType::WebSocket, ws, state, params).await
77}
78
79pub async fn sip_handler(
80    ws: WebSocketUpgrade,
81    State(state): State<AppState>,
82    Query(params): Query<CallParams>,
83) -> Response {
84    call_handler(ActiveCallType::Sip, ws, state, params).await
85}
86
87pub async fn webrtc_handler(
88    ws: WebSocketUpgrade,
89    State(state): State<AppState>,
90    Query(params): Query<CallParams>,
91) -> Response {
92    call_handler(ActiveCallType::Webrtc, ws, state, params).await
93}
94
95/// Core call handling logic that works with either WebSocket or mpsc channels
96///
97/// `extras` and `playbook_name` are session-scoped parameters passed directly
98/// by the caller (SIP handler, CLI, etc.) instead of through global maps.
99/// Returns the final call extras (including `_hangup_headers` if set) so the
100/// caller can use them for SIP BYE or other post-call processing.
101pub async fn call_handler_core(
102    call_type: ActiveCallType,
103    session_id: String,
104    app_state: AppState,
105    cancel_token: CancellationToken,
106    audio_receiver: tokio::sync::mpsc::UnboundedReceiver<Bytes>,
107    server_side_track: Option<String>,
108    dump_events: bool,
109    ping_interval: u64,
110    mut command_receiver: tokio::sync::mpsc::UnboundedReceiver<Command>,
111    event_sender_to_client: tokio::sync::mpsc::UnboundedSender<crate::event::SessionEvent>,
112    extras: Option<HashMap<String, serde_json::Value>>,
113    playbook_name: Option<String>,
114) -> Option<HashMap<String, serde_json::Value>> {
115    let _cancel_guard = cancel_token.clone().drop_guard();
116    let track_config = TrackConfig::default();
117
118    let active_call = Arc::new(ActiveCall::new(
119        call_type.clone(),
120        cancel_token.clone(),
121        session_id.clone(),
122        app_state.invitation.clone(),
123        app_state.clone(),
124        track_config,
125        Some(audio_receiver),
126        dump_events,
127        server_side_track,
128        extras,
129        None,
130    ));
131
132    // Load playbook: prefer direct parameter, fall back to pending_playbooks
133    // (pending_playbooks is used by the run_playbook HTTP endpoint)
134    {
135        let name_or_content = playbook_name.or_else(|| {
136            app_state
137                .pending_playbooks
138                .try_lock()
139                .ok()
140                .and_then(|mut pending| pending.remove(&session_id).map(|(val, _)| val))
141        });
142        if let Some(name_or_content) = name_or_content {
143            let playbook_result = if name_or_content.trim().starts_with("---") {
144                Playbook::parse(&name_or_content)
145            } else {
146                // If path already contains config/playbook, use it as-is; otherwise prepend it
147                let path = if name_or_content.starts_with("config/playbook/") {
148                    PathBuf::from(&name_or_content)
149                } else {
150                    PathBuf::from("config/playbook").join(&name_or_content)
151                };
152                Playbook::load(path).await
153            };
154
155            match playbook_result {
156                Ok(mut playbook) => {
157                    // Filter extracted headers if configured (only for SIP calls)
158                    if call_type == ActiveCallType::Sip {
159                        if let Some(sip_config) = &playbook.config.sip {
160                            if let Some(allowed_headers) = &sip_config.extract_headers {
161                                let mut state = active_call.call_state.write().await;
162                                if let Some(extras) = &mut state.extras {
163                                    filter_headers(extras, allowed_headers);
164                                    // Store the list of SIP header keys for later template rendering
165                                    let header_keys: Vec<String> = extras
166                                        .keys()
167                                        .filter(|k| !k.starts_with('_'))
168                                        .cloned()
169                                        .collect();
170                                    extras.insert(
171                                        "_sip_header_keys".to_string(),
172                                        serde_json::to_value(&header_keys).unwrap_or_default(),
173                                    );
174                                    if let Ok(result) = playbook.render(extras) {
175                                        playbook = result;
176                                    }
177                                }
178                            }
179                        }
180                    }
181
182                    match PlaybookRunner::new(playbook, active_call.clone()) {
183                        Ok(runner) => {
184                            crate::spawn(async move {
185                                runner.run().await;
186                            });
187                            let display_name = if name_or_content.trim().starts_with("---") {
188                                "custom content"
189                            } else {
190                                &name_or_content
191                            };
192                            info!(session_id, "Playbook runner started for {}", display_name);
193                        }
194                        Err(e) => {
195                            let display_name = if name_or_content.trim().starts_with("---") {
196                                "custom content"
197                            } else {
198                                &name_or_content
199                            };
200                            warn!(
201                                session_id,
202                                "Failed to create runner {}: {}", display_name, e
203                            )
204                        }
205                    }
206                }
207                Err(e) => {
208                    let display_name = if name_or_content.trim().starts_with("---") {
209                        "custom content"
210                    } else {
211                        &name_or_content
212                    };
213                    warn!(
214                        session_id,
215                        "Failed to load playbook {}: {}", display_name, e
216                    );
217                    let event = SessionEvent::Error {
218                        timestamp: crate::media::get_timestamp(),
219                        track_id: session_id.clone(),
220                        sender: "playbook".to_string(),
221                        error: format!("{}", e),
222                        code: None,
223                    };
224                    event_sender_to_client.send(event).ok();
225                    return None;
226                }
227            }
228        }
229    }
230
231    let recv_commands_loop = async {
232        while let Some(command) = command_receiver.recv().await {
233            if let Err(_) = active_call.enqueue_command(command).await {
234                break;
235            }
236        }
237    };
238
239    let mut event_receiver = active_call.event_sender.subscribe();
240    let send_events_loop = async {
241        loop {
242            match event_receiver.recv().await {
243                Ok(event) => {
244                    if let Err(_) = event_sender_to_client.send(event) {
245                        break;
246                    }
247                }
248                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
249                Err(_) => break,
250            }
251        }
252    };
253
254    let send_ping_loop = async {
255        if ping_interval == 0 {
256            active_call.cancel_token.cancelled().await;
257            return;
258        }
259        let mut ticker = tokio::time::interval(Duration::from_secs(ping_interval));
260        loop {
261            ticker.tick().await;
262            let payload = Utc::now().to_rfc3339();
263            let event = SessionEvent::Ping {
264                timestamp: crate::media::get_timestamp(),
265                payload: Some(payload),
266            };
267            if let Err(_) = active_call.event_sender.send(event) {
268                break;
269            }
270        }
271    };
272
273    let guard = ActiveCallGuard::new(active_call.clone());
274    info!(
275        session_id,
276        active_calls = guard.active_calls,
277        ?call_type,
278        "new call started"
279    );
280    let receiver = active_call.new_receiver();
281
282    let (r, _) = join! {
283        active_call.serve(receiver),
284        async {
285            select!{
286                _ = send_ping_loop => {},
287                _ = cancel_token.cancelled() => {},
288                _ = send_events_loop => { },
289                _ = recv_commands_loop => {
290                    info!(session_id, "Command receiver closed");
291                },
292            }
293            cancel_token.cancel();
294        }
295    };
296    // drain events
297    while let Ok(event) = event_receiver.try_recv() {
298        if let Err(_) = event_sender_to_client.send(event) {
299            break;
300        }
301    }
302    match r {
303        Ok(_) => info!(session_id, "call ended successfully"),
304        Err(e) => warn!(session_id, "call ended with error: {}", e),
305    }
306
307    // Capture final extras (including _hangup_headers) before cleanup
308    let final_extras = active_call.call_state.read().await.extras.clone();
309
310    active_call.cleanup().await.ok();
311    debug!(session_id, "Call handler core completed");
312
313    final_extras
314}
315
316pub async fn call_handler(
317    call_type: ActiveCallType,
318    ws: WebSocketUpgrade,
319    app_state: AppState,
320    params: CallParams,
321) -> Response {
322    let session_id = params
323        .id
324        .unwrap_or_else(|| format!("s.{}", Uuid::new_v4().to_string()));
325    let server_side_track = params.server_side_track.clone();
326    let dump_events = params.dump_events.unwrap_or(true);
327    let ping_interval = params.ping_interval.unwrap_or(20);
328
329    let resp = ws.on_upgrade(move |socket| async move {
330        let (mut ws_sender, mut ws_receiver) = socket.split();
331        let (audio_sender, audio_receiver) = tokio::sync::mpsc::unbounded_channel::<Bytes>();
332        let (command_sender, command_receiver) = tokio::sync::mpsc::unbounded_channel::<Command>();
333        let (event_sender_to_client, mut event_receiver_from_core) =
334            tokio::sync::mpsc::unbounded_channel::<crate::event::SessionEvent>();
335        let cancel_token = CancellationToken::new();
336
337        // Start core handler in background
338        let session_id_clone = session_id.clone();
339        let app_state_clone = app_state.clone();
340        let cancel_token_clone = cancel_token.clone();
341        crate::spawn(async move {
342            call_handler_core(
343                call_type,
344                session_id_clone,
345                app_state_clone,
346                cancel_token_clone,
347                audio_receiver,
348                server_side_track,
349                dump_events,
350                ping_interval.into(),
351                command_receiver,
352                event_sender_to_client,
353                None, // extras — not used for WebSocket calls
354                None, // playbook_name — falls back to pending_playbooks
355            )
356            .await;
357        });
358
359        // Handle WebSocket I/O
360        let recv_from_ws_loop = async {
361            while let Some(Ok(message)) = ws_receiver.next().await {
362                match message {
363                    Message::Text(text) => {
364                        let command = match serde_json::from_str::<Command>(&text) {
365                            Ok(cmd) => cmd,
366                            Err(e) => {
367                                warn!(session_id, %text, "Failed to parse command {}", e);
368                                continue;
369                            }
370                        };
371                        if let Err(_) = command_sender.send(command) {
372                            break;
373                        }
374                    }
375                    Message::Binary(bin) => {
376                        audio_sender.send(bin.into()).ok();
377                    }
378                    Message::Close(_) => {
379                        info!(session_id, "WebSocket closed by client");
380                        break;
381                    }
382                    _ => {}
383                }
384            }
385        };
386
387        let send_to_ws_loop = async {
388            while let Some(event) = event_receiver_from_core.recv().await {
389                trace!(session_id, %event, "Sending WS message");
390                let message = match event.into_ws_message() {
391                    Ok(msg) => msg,
392                    Err(e) => {
393                        warn!(session_id, error=%e, "Failed to serialize event to WS message");
394                        continue;
395                    }
396                };
397                if let Err(_) = ws_sender.send(message).await {
398                    info!(session_id, "WebSocket send failed, closing");
399                    break;
400                }
401            }
402        };
403
404        select! {
405            _ = recv_from_ws_loop => {
406                info!(session_id, "WebSocket receive loop ended");
407            },
408            _ = send_to_ws_loop => {
409                info!(session_id, "WebSocket send loop ended");
410            },
411        }
412
413        cancel_token.cancel();
414        ws_sender.flush().await.ok();
415        ws_sender.close().await.ok();
416        debug!(session_id, "WebSocket connection closed");
417    });
418    resp
419}
420
421pub(crate) async fn get_iceservers(State(state): State<AppState>) -> Response {
422    if let Some(ice_servers) = state.config.ice_servers.as_ref() {
423        return Json(ice_servers).into_response();
424    }
425    Json(vec![IceServer {
426        urls: vec!["stun:stun.l.google.com:19302".to_string()],
427        ..Default::default()
428    }])
429    .into_response()
430}
431
432pub(crate) async fn list_active_calls(State(state): State<AppState>) -> Response {
433    let calls = state
434        .active_calls
435        .lock()
436        .unwrap()
437        .iter()
438        .map(|(_, c)| {
439            if let Ok(cs) = c.call_state.try_read() {
440                json!({
441                    "id": c.session_id,
442                    "callType": c.call_type,
443                    "cs.option": cs.option,
444                    "ringTime": cs.ring_time,
445                    "startTime": cs.answer_time,
446                })
447            } else {
448                json!({
449                    "id": c.session_id,
450                    "callType": c.call_type,
451                    "status": "locked",
452                })
453            }
454        })
455        .collect::<Vec<_>>();
456    Json(serde_json::json!({ "active_calls": calls })).into_response()
457}
458
459pub(crate) async fn kill_active_call(
460    Path(id): Path<String>,
461    State(state): State<AppState>,
462) -> Response {
463    let active_calls = state.active_calls.lock().unwrap();
464    if let Some(call) = active_calls.get(&id) {
465        call.cancel_token.cancel();
466        Json(serde_json::json!({ "status": "killed", "id": id })).into_response()
467    } else {
468        (
469            axum::http::StatusCode::NOT_FOUND,
470            Json(serde_json::json!({ "status": "not_found", "id": id })),
471        )
472            .into_response()
473    }
474}
475
476pub(crate) async fn stream_events(
477    Path(id): Path<String>,
478    State(state): State<AppState>,
479) -> Response {
480    let mut rx_events;
481    let mut rx_commands;
482    {
483        let active_calls = state.active_calls.lock().unwrap();
484        if let Some(call) = active_calls.get(&id) {
485            rx_events = call.event_sender.subscribe();
486            rx_commands = call.cmd_sender.subscribe();
487        } else {
488            return (axum::http::StatusCode::NOT_FOUND, "track not active").into_response();
489        }
490    }
491
492    let stream = async_stream::stream! {
493        loop {
494            let result = tokio::select! {
495                r = rx_events.recv() => r.map(|e| serde_json::to_string(&e).map(|json| Event::default().event("event").data(json))),
496                r = rx_commands.recv() => r.map(|c| serde_json::to_string(&c).map(|json| Event::default().event("command").data(json))),
497            };
498            match result {
499                Ok(Ok(sse_event)) => yield Ok::<Event, serde_json::Error>(sse_event),
500                Ok(Err(e)) => yield Err(e.into()),
501                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
502                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
503            }
504        }
505    };
506
507    let mut response = Sse::new(stream)
508        .keep_alive(KeepAlive::default())
509        .into_response();
510    response.headers_mut().insert(
511        axum::http::header::CONTENT_TYPE,
512        "text/event-stream;charset=utf-8".parse().unwrap(),
513    );
514    response
515}
516
517pub(crate) async fn send_command(
518    Path(id): Path<String>,
519    State(state): State<AppState>,
520    Json(command): Json<Command>,
521) -> Response {
522    let active_calls = state.active_calls.lock().unwrap();
523    if let Some(call) = active_calls.get(&id) {
524        if let Ok(_) = call.cmd_sender.send(command) {
525            return Json(serde_json::json!({ "status": "sent", "id": id })).into_response();
526        }
527    }
528
529    (
530        axum::http::StatusCode::NOT_FOUND,
531        Json(serde_json::json!({ "status": "not_found", "id": id })),
532    )
533        .into_response()
534}
535
536/// Pre-generate and cache TTS audio without an active call.
537///
538/// Accepts a `Tts` command (same shape as `/command/{id}`): it synthesizes the
539/// text and stores the audio under the exact cache key the real `do_tts` would
540/// use. A later `Tts` with the same parameters then hits the cache instead of
541/// regenerating.
542///
543/// NOTE: the cache key is derived from the command parameters. For a cache hit
544/// the real command must use the same effective `option` (provider, samplerate,
545/// speaker, speed) — or pass an explicit `cacheKey`.
546pub(crate) async fn precache(
547    State(state): State<AppState>,
548    Json(command): Json<Command>,
549) -> Response {
550    let result = match command {
551        Command::Tts {
552            text,
553            speaker,
554            option,
555            cache_key,
556            ..
557        } => precache_tts(&state, text, speaker, option, cache_key).await,
558        _ => {
559            return (
560                axum::http::StatusCode::BAD_REQUEST,
561                Json(json!({ "status": "error", "error": "precache only accepts tts commands" })),
562            )
563                .into_response();
564        }
565    };
566
567    match result {
568        Ok((cache_key, bytes, existed)) => Json(json!({
569            "status": if existed { "exists" } else { "cached" },
570            "cacheKey": cache_key,
571            "bytes": bytes,
572        }))
573        .into_response(),
574        Err(e) => (
575            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
576            Json(json!({ "status": "error", "error": e.to_string() })),
577        )
578            .into_response(),
579    }
580}
581
582/// Synthesize `text` and store the audio under the same cache key `do_tts` uses.
583/// Returns (cache_key, bytes_stored, already_existed).
584async fn precache_tts(
585    state: &AppState,
586    text: String,
587    speaker: Option<String>,
588    option: Option<SynthesisOption>,
589    cache_key: Option<String>,
590) -> anyhow::Result<(String, usize, bool)> {
591    let mut opt = option.ok_or_else(|| anyhow::anyhow!("tts precache requires an option"))?;
592    // Fold the top-level speaker into the option (same precedence as do_tts)
593    opt.speaker = speaker.or(opt.speaker);
594    opt.check_default();
595
596    let mut client = state.stream_engine.create_tts_client(false, &opt).await?;
597    // Cache key must match tts.rs::handle_cache exactly.
598    let sample_rate = opt.samplerate.unwrap_or(16000) as u32;
599    let cache_key = cache_key.unwrap_or_else(|| {
600        cache::generate_cache_key(
601            &format!("tts:{}{}", client.provider(), text),
602            sample_rate,
603            opt.speaker.as_ref(),
604            opt.speed,
605        )
606    });
607    if cache::is_cached(&cache_key).await.unwrap_or(false) {
608        return Ok((cache_key, 0, true));
609    }
610
611    let mut stream = client.start().await?;
612    client.synthesize(&text, Some(0), Some(opt.clone())).await?;
613    client.stop().await?;
614
615    let mut chunks: Vec<Bytes> = Vec::new();
616    let mut first_chunk = true;
617    while let Some((_seq, res)) = stream.next().await {
618        match res? {
619            SynthesisEvent::AudioChunk(mut chunk) => {
620                // Strip the 44-byte WAV header off the first chunk (same as tts.rs).
621                if first_chunk {
622                    if chunk.len() > 44 && chunk[..4] == [0x52, 0x49, 0x46, 0x46] {
623                        let _ = chunk.split_to(44);
624                    }
625                    first_chunk = false;
626                }
627                chunks.push(chunk);
628            }
629            SynthesisEvent::Finished => break,
630            _ => {}
631        }
632    }
633    if chunks.is_empty() {
634        return Err(anyhow::anyhow!("tts produced no audio"));
635    }
636    let bytes: usize = chunks.iter().map(|c| c.len()).sum();
637    cache::store_in_cache_vectored(&cache_key, &chunks).await?;
638    info!(cache_key = %cache_key, bytes, "precache: stored tts audio");
639    Ok((cache_key, bytes, false))
640}
641
642trait IntoWsMessage {
643    fn into_ws_message(self) -> Result<Message, serde_json::Error>;
644}
645
646impl IntoWsMessage for crate::event::SessionEvent {
647    fn into_ws_message(self) -> Result<Message, serde_json::Error> {
648        match self {
649            SessionEvent::Binary { data, .. } => Ok(Message::Binary(data.into())),
650            SessionEvent::Ping { timestamp, payload } => {
651                let payload = payload.unwrap_or_else(|| timestamp.to_string());
652                Ok(Message::Ping(payload.into()))
653            }
654            event => serde_json::to_string(&event).map(|payload| Message::Text(payload.into())),
655        }
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use serde_json::json;
663    use std::collections::HashMap;
664
665    #[test]
666    fn test_filter_headers() {
667        let mut extras = HashMap::new();
668        extras.insert("X-Tenant-ID".to_string(), json!("123"));
669        extras.insert("X-User-ID".to_string(), json!("456"));
670        extras.insert("Custom-Header".to_string(), json!("abc"));
671        extras.insert("Irrelevant-Header".to_string(), json!("ignore"));
672
673        // Test case-insensitive matching
674        let allowed = vec!["x-tenant-id".to_string(), "Custom-Header".to_string()];
675
676        filter_headers(&mut extras, &allowed);
677
678        assert!(extras.contains_key("X-Tenant-ID"));
679        assert!(extras.contains_key("Custom-Header"));
680        assert!(!extras.contains_key("X-User-ID"));
681        assert!(!extras.contains_key("Irrelevant-Header"));
682
683        // ensure values are preserved
684        assert_eq!(extras.get("X-Tenant-ID").unwrap(), &json!("123"));
685        assert_eq!(extras.get("Custom-Header").unwrap(), &json!("abc"));
686    }
687
688    #[tokio::test]
689    async fn test_call_handler_core_extras_are_session_scoped() {
690        use crate::app::AppStateBuilder;
691        use crate::call::{ActiveCallType, Command};
692        use crate::config::Config;
693
694        let mut config = Config::default();
695        config.udp_port = 0;
696        let app_state = AppStateBuilder::new()
697            .with_config(config)
698            .build()
699            .await
700            .expect("Failed to build app state");
701
702        let session_id = "test-session-scoped".to_string();
703        let cancel_token = CancellationToken::new();
704
705        // Pass extras directly as a parameter (not via global map)
706        let mut extras = HashMap::new();
707        extras.insert("X-Custom".to_string(), json!("value"));
708
709        let (_audio_sender, audio_receiver) = tokio::sync::mpsc::unbounded_channel::<Bytes>();
710        let (command_sender, command_receiver) = tokio::sync::mpsc::unbounded_channel::<Command>();
711        let (event_sender, _event_receiver) =
712            tokio::sync::mpsc::unbounded_channel::<crate::event::SessionEvent>();
713
714        // Send a Hangup command immediately to end the call
715        command_sender
716            .send(Command::Hangup {
717                reason: None,
718                initiator: None,
719                headers: None,
720                refer: None,
721            })
722            .ok();
723        drop(command_sender);
724
725        // Run call_handler_core with extras passed directly
726        let final_extras = call_handler_core(
727            ActiveCallType::Sip,
728            session_id.clone(),
729            app_state.clone(),
730            cancel_token,
731            audio_receiver,
732            None,
733            false,
734            0,
735            command_receiver,
736            event_sender,
737            Some(extras), // extras passed directly
738            None,         // no playbook
739        )
740        .await;
741
742        // Verify that final extras are returned and contain our custom header
743        assert!(final_extras.is_some(), "final extras should be returned");
744        let extras = final_extras.unwrap();
745        assert_eq!(
746            extras.get("X-Custom"),
747            Some(&json!("value")),
748            "session-scoped extras should be preserved"
749        );
750    }
751}