Skip to main content

a2a_rs/adapter/transport/
connectrpc.rs

1//! The ConnectRPC transport adapter.
2//!
3//! `ConnectRpcAdapter` is the **outer** half of the service/transport split: a
4//! thin transport adapter that implements the generated [`A2aService`] surface.
5//! Its only job is to decode `buffa` wire views into domain values, delegate to
6//! the inner [`TaskService`], and re-encode the domain results (and map
7//! [`A2AError`] onto ConnectRPC error codes). All use-case orchestration lives
8//! in [`TaskService`]; this layer holds no port traits directly.
9//!
10//! The public constructors (`new`, `with_handler`, `with_streaming_handler`)
11//! each build the inner service and wrap it.
12
13use async_trait::async_trait;
14use buffa::Enumeration;
15use std::pin::Pin;
16
17use crate::{
18    application::{SendOptions, TaskService},
19    domain::{
20        A2AError, AgentCard, SendCompletion, Task, TaskArtifactUpdateEvent, TaskId,
21        TaskPushNotificationConfig, TaskStatusUpdateEvent,
22        generated::{
23            A2aService, CancelTaskRequestView, DeleteTaskPushNotificationConfigRequestView,
24            GetExtendedAgentCardRequestView, GetTaskPushNotificationConfigRequestView,
25            GetTaskRequestView, ListTaskPushNotificationConfigsRequestView,
26            ListTaskPushNotificationConfigsResponse, ListTasksRequest, ListTasksRequestView,
27            ListTasksResponse, SendMessageRequestView, SendMessageResponse, StreamResponse,
28            SubscribeToTaskRequestView, TaskArtifactUpdateEvent as GenTaskArtifactUpdateEvent,
29            TaskPushNotificationConfigView, TaskState,
30            TaskStatusUpdateEvent as GenTaskStatusUpdateEvent, send_message_response,
31            stream_response,
32        },
33    },
34    port::{
35        AsyncMessageHandler, AsyncNotificationManager, AsyncStreamingHandler, AsyncTaskLifecycle,
36        AsyncTaskQuery, AuthPrincipal, RequestContext, SeqEvent, UpdateEvent,
37        streaming_handler::Subscriber,
38    },
39    services::server::AgentInfoProvider,
40};
41
42use super::resume;
43
44/// Tag each event in `stream` with its id, for a client that asked for one, and
45/// map it onto the wire.
46///
47/// Both streaming methods carry ids the same way. See [`resume`] for why the id
48/// travels in the payload's metadata here and in the SSE `id:` field elsewhere.
49// As on the handlers themselves: `ConnectError` is the wire error type, and its
50// size is not ours to change.
51#[allow(clippy::result_large_err)]
52fn wire_stream(
53    stream: crate::application::UpdateStream,
54    stamp_ids: bool,
55) -> impl ::futures::Stream<Item = Result<StreamResponse, ::connectrpc::ConnectError>> + Send {
56    use futures::StreamExt;
57
58    stream.map(move |item| {
59        item.map(|seq| {
60            let mut event = seq.event;
61            if stamp_ids {
62                resume::stamp_event_id(&mut event, seq.id);
63            }
64            map_update_event(event)
65        })
66        .map_err(map_err)
67    })
68}
69
70/// ConnectRPC transport adapter over a [`TaskService`].
71///
72/// Holds no ports directly — it owns the inner application service and forwards
73/// decoded requests to it. Dispatch into the service goes through the service's
74/// `Arc<dyn …>` fields, which is a cold path against the I/O each call performs.
75#[derive(Clone)]
76pub struct ConnectRpcAdapter {
77    service: TaskService,
78}
79
80impl ConnectRpcAdapter {
81    /// Create a new adapter from separate handlers, defaulting to a no-op
82    /// streaming handler.
83    ///
84    /// `tasks` supplies both the lifecycle and query capabilities.
85    pub fn new(
86        message_handler: impl AsyncMessageHandler + 'static,
87        tasks: impl AsyncTaskLifecycle + AsyncTaskQuery + 'static,
88        notification_manager: impl AsyncNotificationManager + 'static,
89        agent_info: impl AgentInfoProvider + 'static,
90    ) -> Self {
91        Self {
92            service: TaskService::new(
93                message_handler,
94                tasks,
95                notification_manager,
96                agent_info,
97                NoopStreamingHandler,
98                crate::port::NoopPushNotifier,
99            ),
100        }
101    }
102
103    /// Create a new adapter from a single handler that implements every port,
104    /// defaulting to a no-op streaming handler.
105    pub fn with_handler(
106        handler: impl AsyncMessageHandler
107        + AsyncTaskLifecycle
108        + AsyncTaskQuery
109        + AsyncNotificationManager
110        + 'static,
111        agent_info: impl AgentInfoProvider + 'static,
112    ) -> Self {
113        Self {
114            service: TaskService::with_handler(
115                handler,
116                agent_info,
117                NoopStreamingHandler,
118                crate::port::NoopPushNotifier,
119            ),
120        }
121    }
122
123    /// Builder-style method to inject custom streaming handler support.
124    pub fn with_streaming_handler(
125        self,
126        streaming_handler: impl AsyncStreamingHandler + 'static,
127    ) -> Self {
128        Self {
129            service: self.service.with_streaming_handler(streaming_handler),
130        }
131    }
132
133    /// Builder-style method to inject a custom push notifier.
134    pub fn with_push_notifier(
135        self,
136        push_notifier: impl crate::port::AsyncPushNotifier + 'static,
137    ) -> Self {
138        Self {
139            service: self.service.with_push_notifier(push_notifier),
140        }
141    }
142}
143
144/// Build the inward-facing request context from a ConnectRPC call.
145///
146/// The principal comes from the HTTP request extensions, where the auth
147/// middleware ([`with_auth`](crate::adapter::auth::with_auth)) put it after
148/// authenticating the request — `connectrpc` moves `parts.extensions` onto its
149/// `Context` verbatim. An unauthenticated server has nothing there and the
150/// context names no caller.
151fn request_context(ctx: &::connectrpc::Context, context_id: &str) -> RequestContext {
152    RequestContext::anonymous()
153        .with_session(context_id)
154        .with_principal(ctx.extensions.get::<AuthPrincipal>().cloned())
155}
156
157/// Helper function to map A2AError to connectrpc::ConnectError
158fn map_err(e: A2AError) -> ::connectrpc::ConnectError {
159    match e {
160        A2AError::TaskNotFound(msg) => {
161            ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::NotFound, msg)
162        }
163        A2AError::InvalidParams(msg) => {
164            ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::InvalidArgument, msg)
165        }
166        A2AError::ValidationError { field, message } => ::connectrpc::ConnectError::new(
167            ::connectrpc::ErrorCode::InvalidArgument,
168            format!("{}: {}", field, message),
169        ),
170        A2AError::UnsupportedOperation(msg) => {
171            ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::Unimplemented, msg)
172        }
173        A2AError::AuthenticatedExtendedCardNotConfigured => ::connectrpc::ConnectError::new(
174            ::connectrpc::ErrorCode::FailedPrecondition,
175            "Authenticated extended card not configured".to_string(),
176        ),
177        A2AError::MethodNotFound(msg) => {
178            ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::Unimplemented, msg)
179        }
180        // A context owned by someone else is a refusal, not a fault. Left to the
181        // catch-all it reported as `Internal`, which reads as a server bug and
182        // invites a retry that will be refused again.
183        A2AError::ContextAccessDenied { context_id } => ::connectrpc::ConnectError::new(
184            ::connectrpc::ErrorCode::PermissionDenied,
185            format!("context {context_id} belongs to another principal"),
186        ),
187        _ => ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::Internal, e.to_string()),
188    }
189}
190
191/// Helper to map domain metadata to protobuf Struct
192fn map_metadata(
193    opt: Option<serde_json::Map<String, serde_json::Value>>,
194) -> ::buffa::MessageField<::buffa_types::google::protobuf::Struct> {
195    if let Some(map) = opt {
196        let val = serde_json::Value::Object(map);
197        if let Ok(struc) = serde_json::from_value::<::buffa_types::google::protobuf::Struct>(val) {
198            return ::buffa::MessageField::some(struc);
199        }
200    }
201    ::buffa::MessageField::none()
202}
203
204fn map_status_update(
205    evt: crate::domain::events::TaskStatusUpdateEvent,
206) -> GenTaskStatusUpdateEvent {
207    GenTaskStatusUpdateEvent {
208        task_id: evt.task_id,
209        context_id: evt.context_id,
210        status: ::buffa::MessageField::some(evt.status),
211        metadata: map_metadata(evt.metadata),
212        ..Default::default()
213    }
214}
215
216fn map_artifact_update(
217    evt: crate::domain::events::TaskArtifactUpdateEvent,
218) -> GenTaskArtifactUpdateEvent {
219    GenTaskArtifactUpdateEvent {
220        task_id: evt.task_id,
221        context_id: evt.context_id,
222        artifact: ::buffa::MessageField::some(evt.artifact),
223        append: evt.append.unwrap_or(false),
224        last_chunk: evt.last_chunk.unwrap_or(false),
225        metadata: map_metadata(evt.metadata),
226        ..Default::default()
227    }
228}
229
230/// Map a domain [`UpdateEvent`] onto its wire [`StreamResponse`].
231///
232/// Shared with the JSON-RPC adapter so both transports map streaming updates
233/// through one path.
234pub(super) fn map_update_event(evt: UpdateEvent) -> StreamResponse {
235    match evt {
236        UpdateEvent::StatusUpdate(event) => StreamResponse {
237            payload: Some(stream_response::Payload::StatusUpdate(Box::new(
238                map_status_update(event),
239            ))),
240            ..Default::default()
241        },
242        UpdateEvent::ArtifactUpdate(event) => StreamResponse {
243            payload: Some(stream_response::Payload::ArtifactUpdate(Box::new(
244                map_artifact_update(event),
245            ))),
246            ..Default::default()
247        },
248    }
249}
250
251impl A2aService for ConnectRpcAdapter {
252    async fn send_message(
253        &self,
254        ctx: ::connectrpc::Context,
255        request: ::buffa::view::OwnedView<SendMessageRequestView<'static>>,
256    ) -> Result<(SendMessageResponse, ::connectrpc::Context), ::connectrpc::ConnectError> {
257        let req = request.to_owned_message();
258        let message = req.message.into_option().ok_or_else(|| {
259            ::connectrpc::ConnectError::new(
260                ::connectrpc::ErrorCode::InvalidArgument,
261                "Missing message".to_string(),
262            )
263        })?;
264        let config = req.configuration.into_option();
265
266        let request_ctx = request_context(&ctx, &message.context_id);
267
268        let task = self
269            .service
270            .send_message(message, &request_ctx, decode_send_config(config))
271            .await
272            .map_err(map_err)?;
273
274        let response = SendMessageResponse {
275            payload: Some(send_message_response::Payload::Task(Box::new(task))),
276            ..Default::default()
277        };
278
279        Ok((response, ctx))
280    }
281
282    #[allow(clippy::result_large_err)]
283    async fn send_streaming_message(
284        &self,
285        ctx: ::connectrpc::Context,
286        request: ::buffa::view::OwnedView<SendMessageRequestView<'static>>,
287    ) -> Result<
288        (
289            ::std::pin::Pin<
290                Box<
291                    dyn ::futures::Stream<Item = Result<StreamResponse, ::connectrpc::ConnectError>>
292                        + Send,
293                >,
294            >,
295            ::connectrpc::Context,
296        ),
297        ::connectrpc::ConnectError,
298    > {
299        let req = request.to_owned_message();
300        let message = req.message.into_option().ok_or_else(|| {
301            ::connectrpc::ConnectError::new(
302                ::connectrpc::ErrorCode::InvalidArgument,
303                "Missing message".to_string(),
304            )
305        })?;
306        let config = req.configuration.into_option();
307
308        let request_ctx = request_context(&ctx, &message.context_id);
309        let stamp_ids = resume::wants_event_ids(&ctx.headers);
310
311        // `completion` is deliberately dropped here rather than passed along:
312        // on a streaming call the stream itself is the wait, so blocking the
313        // initial response would only delay the snapshot the client needs to
314        // start reading.
315        let opts = decode_send_config(config);
316
317        let (task, update_stream) = self
318            .service
319            .send_streaming_message(message, &request_ctx, opts.push_config, opts.history_limit)
320            .await
321            .map_err(map_err)?;
322
323        use futures::StreamExt;
324
325        let initial_response = StreamResponse {
326            payload: Some(stream_response::Payload::Task(Box::new(task))),
327            ..Default::default()
328        };
329
330        // The snapshot carries no id — there is nothing to resume from before
331        // the first update, same as the SSE transports.
332        let chained_stream = futures::stream::once(async { Ok(initial_response) })
333            .chain(wire_stream(update_stream, stamp_ids));
334
335        Ok((Box::pin(chained_stream), ctx))
336    }
337
338    async fn get_task(
339        &self,
340        ctx: ::connectrpc::Context,
341        request: ::buffa::view::OwnedView<GetTaskRequestView<'static>>,
342    ) -> Result<(Task, ::connectrpc::Context), ::connectrpc::ConnectError> {
343        let req = request.to_owned_message();
344        let history_length = req.history_length.map(|l| l as u32);
345        let id: TaskId = req.id.parse().map_err(map_err)?;
346        let task = self
347            .service
348            .get(&id, history_length)
349            .await
350            .map_err(map_err)?;
351        Ok((task, ctx))
352    }
353
354    async fn list_tasks(
355        &self,
356        ctx: ::connectrpc::Context,
357        request: ::buffa::view::OwnedView<ListTasksRequestView<'static>>,
358    ) -> Result<(ListTasksResponse, ::connectrpc::Context), ::connectrpc::ConnectError> {
359        let req = request.to_owned_message();
360        let params = list_request_to_params(req);
361
362        let result = self.service.list(&params).await.map_err(map_err)?;
363
364        let response = ListTasksResponse {
365            tasks: result.tasks,
366            next_page_token: result.next_page_token,
367            page_size: result.page_size,
368            total_size: result.total_size,
369            ..Default::default()
370        };
371
372        Ok((response, ctx))
373    }
374
375    async fn cancel_task(
376        &self,
377        ctx: ::connectrpc::Context,
378        request: ::buffa::view::OwnedView<CancelTaskRequestView<'static>>,
379    ) -> Result<(Task, ::connectrpc::Context), ::connectrpc::ConnectError> {
380        let req = request.to_owned_message();
381        let id: TaskId = req.id.parse().map_err(map_err)?;
382        let task = self.service.cancel(&id).await.map_err(map_err)?;
383        Ok((task, ctx))
384    }
385
386    #[allow(clippy::result_large_err)]
387    async fn subscribe_to_task(
388        &self,
389        ctx: ::connectrpc::Context,
390        request: ::buffa::view::OwnedView<SubscribeToTaskRequestView<'static>>,
391    ) -> Result<
392        (
393            ::std::pin::Pin<
394                Box<
395                    dyn ::futures::Stream<Item = Result<StreamResponse, ::connectrpc::ConnectError>>
396                        + Send,
397                >,
398            >,
399            ::connectrpc::Context,
400        ),
401        ::connectrpc::ConnectError,
402    > {
403        let req = request.to_owned_message();
404        let from_event_id = resume::parse_last_event_id(&ctx.headers);
405        let stamp_ids = resume::wants_event_ids(&ctx.headers);
406
407        let (initial_task, update_stream) = self
408            .service
409            .subscribe(&req.id, from_event_id)
410            .await
411            .map_err(map_err)?;
412
413        use futures::StreamExt;
414
415        let mapped_stream = wire_stream(update_stream, stamp_ids);
416
417        if let Some(task) = initial_task {
418            let initial_response = StreamResponse {
419                payload: Some(stream_response::Payload::Task(Box::new(task))),
420                ..Default::default()
421            };
422            let chained_stream =
423                futures::stream::once(async { Ok(initial_response) }).chain(mapped_stream);
424            Ok((Box::pin(chained_stream), ctx))
425        } else {
426            Ok((Box::pin(mapped_stream), ctx))
427        }
428    }
429
430    async fn create_task_push_notification_config(
431        &self,
432        ctx: ::connectrpc::Context,
433        request: ::buffa::view::OwnedView<TaskPushNotificationConfigView<'static>>,
434    ) -> Result<(TaskPushNotificationConfig, ::connectrpc::Context), ::connectrpc::ConnectError>
435    {
436        let config = request.to_owned_message();
437        let created_config = self
438            .service
439            .set_push_config(&config)
440            .await
441            .map_err(map_err)?;
442        Ok((created_config, ctx))
443    }
444
445    async fn get_task_push_notification_config(
446        &self,
447        ctx: ::connectrpc::Context,
448        request: ::buffa::view::OwnedView<GetTaskPushNotificationConfigRequestView<'static>>,
449    ) -> Result<(TaskPushNotificationConfig, ::connectrpc::Context), ::connectrpc::ConnectError>
450    {
451        let req = request.to_owned_message();
452        let params = crate::domain::GetTaskPushNotificationConfigParams {
453            id: req.task_id,
454            push_notification_config_id: Some(req.id),
455            metadata: None,
456        };
457        let config = self
458            .service
459            .get_push_config(&params)
460            .await
461            .map_err(map_err)?;
462        Ok((config, ctx))
463    }
464
465    async fn list_task_push_notification_configs(
466        &self,
467        ctx: ::connectrpc::Context,
468        request: ::buffa::view::OwnedView<ListTaskPushNotificationConfigsRequestView<'static>>,
469    ) -> Result<
470        (
471            ListTaskPushNotificationConfigsResponse,
472            ::connectrpc::Context,
473        ),
474        ::connectrpc::ConnectError,
475    > {
476        let req = request.to_owned_message();
477        let params = crate::domain::ListTaskPushNotificationConfigsParams {
478            id: req.task_id,
479            metadata: None,
480        };
481        let configs = self
482            .service
483            .list_push_configs(&params)
484            .await
485            .map_err(map_err)?;
486        let response = ListTaskPushNotificationConfigsResponse {
487            configs,
488            ..Default::default()
489        };
490        Ok((response, ctx))
491    }
492
493    async fn get_extended_agent_card(
494        &self,
495        ctx: ::connectrpc::Context,
496        request: ::buffa::view::OwnedView<GetExtendedAgentCardRequestView<'static>>,
497    ) -> Result<(AgentCard, ::connectrpc::Context), ::connectrpc::ConnectError> {
498        let _req = request.to_owned_message();
499        let card = self.service.extended_agent_card().await.map_err(map_err)?;
500        Ok((card, ctx))
501    }
502
503    async fn delete_task_push_notification_config(
504        &self,
505        ctx: ::connectrpc::Context,
506        request: ::buffa::view::OwnedView<DeleteTaskPushNotificationConfigRequestView<'static>>,
507    ) -> Result<
508        (
509            ::buffa_types::google::protobuf::Empty,
510            ::connectrpc::Context,
511        ),
512        ::connectrpc::ConnectError,
513    > {
514        let req = request.to_owned_message();
515        let params = crate::domain::DeleteTaskPushNotificationConfigParams {
516            id: req.task_id,
517            push_notification_config_id: req.id,
518            metadata: None,
519        };
520        self.service
521            .delete_push_config(&params)
522            .await
523            .map_err(map_err)?;
524        Ok((::buffa_types::google::protobuf::Empty::default(), ctx))
525    }
526}
527
528/// Map a generated `ListTasksRequest` (proto wire message) onto the domain
529/// [`ListTasksParams`]. Shared with the JSON-RPC adapter.
530pub(super) fn list_request_to_params(req: ListTasksRequest) -> crate::domain::ListTasksParams {
531    crate::domain::ListTasksParams {
532        context_id: if req.context_id.is_empty() {
533            None
534        } else {
535            Some(req.context_id)
536        },
537        status: match req.status.to_i32() {
538            0 => None,
539            val => Some(TaskState::from_i32(val).unwrap_or(TaskState::TASK_STATE_UNSPECIFIED)),
540        },
541        page_size: req.page_size,
542        page_token: if req.page_token.is_empty() {
543            None
544        } else {
545            Some(req.page_token)
546        },
547        history_length: req.history_length,
548        include_artifacts: req.include_artifacts,
549        status_timestamp_after: req.status_timestamp_after.as_option().map(|t| {
550            let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(t.seconds, t.nanos as u32)
551                .unwrap_or_default();
552            dt.to_rfc3339()
553        }),
554        metadata: None,
555    }
556}
557
558/// Decode the optional `SendMessageConfiguration` view into the [`SendOptions`]
559/// the service expects.
560///
561/// Shared with the JSON-RPC adapter (both decode the same generated config
562/// message), so the two transports agree on the wire shape.
563///
564/// Note the polarity: an **absent** configuration is not "no opinion", it is
565/// `return_immediately = false`, which obliges the server to wait. That is the
566/// proto3 default and the case every conformant client hits by default, so it
567/// has to be the branch that waits — [`SendOptions::default`] gives exactly
568/// that.
569pub(super) fn decode_send_config(
570    config: Option<crate::domain::generated::SendMessageConfiguration>,
571) -> SendOptions {
572    let Some(c) = config else {
573        return SendOptions::default();
574    };
575    SendOptions {
576        push_config: c.task_push_notification_config.into_option(),
577        history_limit: c.history_length.map(|limit| limit as u32),
578        completion: if c.return_immediately {
579            SendCompletion::WhenCreated
580        } else {
581            SendCompletion::WhenSettled
582        },
583    }
584}
585
586/// A no-op [`AsyncStreamingHandler`] used as the adapter's default streaming port
587/// when the caller has no real streaming backend to inject.
588#[derive(Clone, Debug, Default)]
589pub struct NoopStreamingHandler;
590
591#[async_trait]
592impl AsyncStreamingHandler for NoopStreamingHandler {
593    async fn add_status_subscriber(
594        &self,
595        _task_id: &str,
596        _subscriber: Box<dyn Subscriber<TaskStatusUpdateEvent> + Send + Sync>,
597    ) -> Result<String, A2AError> {
598        Err(A2AError::UnsupportedOperation(
599            "Streaming not supported by this processor".to_string(),
600        ))
601    }
602
603    async fn add_artifact_subscriber(
604        &self,
605        _task_id: &str,
606        _subscriber: Box<dyn Subscriber<TaskArtifactUpdateEvent> + Send + Sync>,
607    ) -> Result<String, A2AError> {
608        Err(A2AError::UnsupportedOperation(
609            "Streaming not supported by this processor".to_string(),
610        ))
611    }
612
613    async fn remove_subscription(&self, _subscription_id: &str) -> Result<(), A2AError> {
614        Ok(())
615    }
616
617    async fn remove_task_subscribers(&self, _task_id: &str) -> Result<(), A2AError> {
618        Ok(())
619    }
620
621    async fn get_subscriber_count(&self, _task_id: &str) -> Result<usize, A2AError> {
622        Ok(0)
623    }
624
625    async fn broadcast_status_update(
626        &self,
627        _task_id: &str,
628        _update: TaskStatusUpdateEvent,
629    ) -> Result<(), A2AError> {
630        Ok(())
631    }
632
633    async fn broadcast_artifact_update(
634        &self,
635        _task_id: &str,
636        _update: TaskArtifactUpdateEvent,
637    ) -> Result<(), A2AError> {
638        Ok(())
639    }
640
641    async fn status_update_stream(
642        &self,
643        _task_id: &str,
644    ) -> Result<
645        Pin<Box<dyn ::futures::Stream<Item = Result<TaskStatusUpdateEvent, A2AError>> + Send>>,
646        A2AError,
647    > {
648        Err(A2AError::UnsupportedOperation(
649            "Streaming not supported by this processor".to_string(),
650        ))
651    }
652
653    async fn artifact_update_stream(
654        &self,
655        _task_id: &str,
656    ) -> Result<
657        Pin<Box<dyn ::futures::Stream<Item = Result<TaskArtifactUpdateEvent, A2AError>> + Send>>,
658        A2AError,
659    > {
660        Err(A2AError::UnsupportedOperation(
661            "Streaming not supported by this processor".to_string(),
662        ))
663    }
664
665    async fn combined_update_stream(
666        &self,
667        _task_id: &str,
668        _from_event_id: Option<u64>,
669    ) -> Result<Pin<Box<dyn ::futures::Stream<Item = Result<SeqEvent, A2AError>> + Send>>, A2AError>
670    {
671        Err(A2AError::UnsupportedOperation(
672            "Streaming not supported by this processor".to_string(),
673        ))
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    /// The one line that carries the caller on this transport. `connectrpc`
682    /// moves the HTTP request's extensions onto its `Context`, and the auth
683    /// middleware is what put an `AuthPrincipal` there; if this stops reading it
684    /// the agent goes back to seeing every caller as the same anonymous nobody,
685    /// which compiles and passes every other test.
686    #[test]
687    fn the_request_context_carries_the_principal_from_the_extensions() {
688        let mut extensions = ::http::Extensions::new();
689        extensions.insert(AuthPrincipal::new("alice".to_string(), "jwt".to_string()));
690        let ctx = ::connectrpc::Context::new(::http::HeaderMap::new()).with_extensions(extensions);
691
692        let request_ctx = request_context(&ctx, "ctx-1");
693
694        assert_eq!(request_ctx.caller(), Some("alice"));
695        assert_eq!(request_ctx.session_id(), Some("ctx-1"));
696    }
697
698    /// No middleware, no principal — and an unset wire `context_id` is no
699    /// session rather than an empty one.
700    #[test]
701    fn an_unauthenticated_call_names_nobody() {
702        let ctx = ::connectrpc::Context::new(::http::HeaderMap::new());
703
704        let request_ctx = request_context(&ctx, "");
705
706        assert_eq!(request_ctx.caller(), None);
707        assert_eq!(request_ctx.session_id(), None);
708    }
709}